diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ae62e9b..0473cc4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,6 +42,6 @@ jobs: - name: Lint uses: golangci/golangci-lint-action@9fae48acfc02a90574d7c304a1758ef9895495fa # v7 with: - version: v2.1.6 + version: v2.5.0 install-mode: goinstall args: ./... diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml index 2f064a1..2505e4b 100644 --- a/.github/workflows/sonar.yml +++ b/.github/workflows/sonar.yml @@ -30,7 +30,7 @@ jobs: cache: true - name: Run tests with coverage - run: go test -covermode=atomic -coverprofile=coverage.out ./... + run: go test -covermode=atomic -coverprofile=coverage.out -coverpkg=./... ./... - name: Run SonarCloud scan if: env.SONAR_TOKEN != '' diff --git a/README.md b/README.md index e13c46a..77ec2b6 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,8 @@ `uam` is a terminal dashboard for managing multiple coding-agent CLIs from one place. It gives you a single TUI for launching, peeking, replying to, attaching to, and -stopping long-running agent sessions backed by a private tmux server. +stopping long-running agent sessions — no tmux (or any other multiplexer) +required. Supported providers: @@ -23,21 +24,25 @@ Supported providers: ## What it does -- Runs each managed session inside `tmux -L uam` +- Runs each managed session under its own lightweight, detached host process + (a PTY + terminal emulator + Unix socket) — sessions keep running when the + TUI exits, exactly like a tmux server, with no external dependency - Shows active and closed sessions in one dashboard -- Lets you peek at recent output without attaching +- Lets you peek at recent output without attaching (4000 lines of scrollback) - Sends replies back into running agent sessions -- Persists session metadata across restarts +- Persists session metadata across restarts, including each agent's exit code - Supports pinning, renaming, manual reorder, and group-by-directory - Detects GitHub PR URLs from agent output and can refresh PR state when `gh` is available - Supports per-session command aliases such as a custom Copilot launcher ## Requirements -- Go 1.24+ to build from source -- tmux 3.x +- Go 1.25+ to build from source (the pinned toolchain downloads automatically) - Any provider CLI you want to manage already installed and authenticated +That's it — agents are spawned directly under uam's own session hosts, so +there is nothing else to install. + Providers are capability-probed at runtime. If a CLI is missing, `uam` hides it from the dispatch UI instead of failing the whole app. @@ -87,9 +92,9 @@ uam ls [--json] uam peek uam attach uam last -uam stop # kill tmux session, keep record -uam rm # kill tmux session and remove record -uam kill-all # stop the private tmux server and all sessions +uam stop # kill the session, keep record +uam rm # kill the session and remove record +uam kill-all # stop every managed session uam version ``` @@ -113,6 +118,43 @@ uam version | `?` | Open help | | `Esc` | Close overlays, clear input, or quit | +## Attached sessions + +`uam attach` (or `Enter` in the TUI) bridges your terminal straight to the +agent's PTY: + +- `Ctrl+B d` detaches and returns to the dashboard (`Ctrl+B Ctrl+B` sends a + literal `Ctrl+B` to the agent) +- `←` (left arrow) also detaches when you haven't typed anything since the + last submit/clear — tap it to hop back to the dashboard. Inside a draft it + moves the cursor as usual, and after history/menu navigation it stays + passthrough until the next `Enter`/`Esc`. Set `UAM_ATTACH_BACK_DETACH=0` + to disable. +- The session keeps running after you detach or close the terminal +- `Ctrl+Z` is swallowed while attached — suspending an agent inside a detached + session would leave it impossible to foreground +- Several terminals can attach to the same session at once + +## Resuming sessions + +Detach/reattach never restarts anything — the agent keeps running under its +host and attach is a plain reconnect (this is the tmux property, kept). +Resume only applies to sessions whose process is gone (reboot, `uam stop`): + +- **Claude Code**: uam seeds claude's session id with the uam id at dispatch + (`--session-id`, when the installed claude supports it) and resumes that + exact conversation with `--resume ` — several sessions in the same + directory each resume their own conversation. Sessions dispatched before + this feature (or with an older claude) fall back to `--continue`. +- **Copilot**: exact resume — the session is named with the uam id at + dispatch (`--name`) and resumed by that exact name (`--resume=`). +- **Codex / OpenCode**: these CLIs cannot preset session ids yet, so resume + uses their "most recent" mode (`codex resume --last`, `opencode -c`). When + an opencode record carries a `provider_session_id` (`ses_...`), uam resumes + that exact session via `--session`. **omp**: `-c`. +- After a reboot, records survive in the store and resume on attach — a + scenario where a tmux session would simply be gone. + ## Session storage `uam` stores session metadata at: @@ -124,6 +166,24 @@ ${XDG_CONFIG_HOME:-~/.config}/uam/sessions.json Writes are atomic and lock-protected. If the file needs migration or recovery, `uam` creates backup files next to it. +Per-session runtime state (control sockets and state files) lives in a +per-user directory under the system temp dir — `/tmp/uam-` on most +systems (override with `UAM_SESSION_DIR`) — created owner-only and verified +to be owned by you. The temp dir is used instead of `$XDG_RUNTIME_DIR` +deliberately: logind wipes the runtime dir when your last login session ends, +which would strand detached sessions that survive logout (the same reason +tmux lives in `/tmp/tmux-`). Hosts periodically refresh their files' +timestamps so age-based `/tmp` cleanup never collects a long-idle session. + +Note for distros with `KillUserProcesses=yes` in logind.conf: any detached +process — uam session hosts and tmux alike — is killed at logout unless you +run `loginctl enable-linger`. + +> Upgrading from a tmux-backed release: sessions still running inside the old +> `tmux -L uam` server are not visible to the native backend. Finish or stop +> them first (`tmux -L uam kill-server`); stored session records carry over +> unchanged and remain resumable. + ## Safety model `uam` can launch providers in their full-access or auto-approve mode when the diff --git a/go.mod b/go.mod index 2d7d8f6..aba81bd 100644 --- a/go.mod +++ b/go.mod @@ -1,12 +1,15 @@ module github.com/RandomCodeSpace/unified-agent-manager -go 1.24.0 +go 1.25.0 -toolchain go1.24.7 +toolchain go1.25.11 require ( github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 + github.com/charmbracelet/x/term v0.2.1 + github.com/creack/pty v1.1.24 + github.com/mattn/go-runewidth v0.0.16 ) require ( @@ -14,12 +17,10 @@ require ( github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect github.com/charmbracelet/x/ansi v0.10.1 // indirect github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect - github.com/charmbracelet/x/term v0.2.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.16 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect diff --git a/go.sum b/go.sum index 4789639..c6551b4 100644 --- a/go.sum +++ b/go.sum @@ -12,6 +12,8 @@ github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0G github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= diff --git a/internal/adapter/adapter.go b/internal/adapter/adapter.go index f640d62..a38afcd 100644 --- a/internal/adapter/adapter.go +++ b/internal/adapter/adapter.go @@ -51,20 +51,29 @@ type Session struct { DisplayName string Prompt string Cwd string - TmuxSession string - State State - ProcAlive ProcLiveness - LastChange time.Time - CreatedAt time.Time - PR *PRRef - Pinned bool - Group string - SortIndex int + SessionName string + // ProviderSessionID is the agent CLI's own session identifier, recorded + // when the provider lets uam seed or learn it (e.g. claude --session-id). + // It upgrades resume from "most recent conversation in this cwd" to an + // exact-session resume. + ProviderSessionID string + State State + ProcAlive ProcLiveness + LastChange time.Time + CreatedAt time.Time + PR *PRRef + Pinned bool + Group string + SortIndex int + // ExitCode is the agent process's exit status from its most recent close + // (-1 when it died on a signal), recorded by the session host. Nil while + // the session is live or when no exit has been observed. + ExitCode *int // Closed mirrors store.StatusClosedByUser: true when the user retired - // this session through uam (`uam stop`, exit-in-session via the tmux - // hook, or an external `tmux kill-session`). False otherwise — including - // for dead-pane sessions left over from a reboot, which remain in the - // Active group and resume on attach. + // this session through uam (`uam stop`, or exit-in-session — the host + // marks the record closed when the agent exits). False otherwise — + // including for dead sessions left over from a reboot, which remain in + // the Active group and resume on attach. Closed bool } @@ -81,8 +90,12 @@ type ResumeRequest struct { Prompt string Cwd string Mode string - TmuxSession string - CreatedAt time.Time + SessionName string + // ProviderSessionID is the persisted provider-side session id, when one + // was recorded at dispatch; providers that support exact resume use it + // instead of their "most recent" heuristic. + ProviderSessionID string + CreatedAt time.Time } type ResumableAdapter interface { @@ -91,8 +104,8 @@ type ResumableAdapter interface { // HasSessionAdapter reports whether the agent's underlying session for id is // still live. Optional: Service.Stop probes it after a failed kill to avoid -// deleting/flagging a record whose pane is still running (F04). TmuxAgent -// implements it for free, so all tmux-backed providers inherit it. +// deleting/flagging a record whose process is still running (F04). Agent +// implements it for free, so every provider inherits it. type HasSessionAdapter interface { HasSession(ctx Context, id string) bool } diff --git a/internal/adapter/adaptertest/backend.go b/internal/adapter/adaptertest/backend.go new file mode 100644 index 0000000..3a79e5c --- /dev/null +++ b/internal/adapter/adaptertest/backend.go @@ -0,0 +1,133 @@ +// Package adaptertest provides a recording fake of adapter.Backend for tests. +// It replaces the old fake-tmux-binary harness: instead of grepping a shell +// log of tmux argv, tests assert directly on the recorded backend calls. +package adaptertest + +import ( + "context" + "strings" + "sync" + + "github.com/RandomCodeSpace/unified-agent-manager/internal/session" +) + +// Call is one recorded backend invocation. +type Call struct { + Op string + Name string + Cwd string + Env map[string]string + Command []string + Text string + Lines int + Label string +} + +// Backend is an in-memory adapter.Backend that records every call and serves +// configurable results. +type Backend struct { + mu sync.Mutex + calls []Call + + // Sessions is returned by List. + Sessions []session.Info + // CaptureText is returned by Capture. + CaptureText string + + CreateErr error + ListErr error + CaptureErr error + SendErr error + KillErr error + LabelErr error + HasResult bool + AttachExe string +} + +func (b *Backend) record(c Call) { + b.mu.Lock() + defer b.mu.Unlock() + b.calls = append(b.calls, c) +} + +// Calls returns a copy of all recorded calls. +func (b *Backend) Calls() []Call { + b.mu.Lock() + defer b.mu.Unlock() + out := make([]Call, len(b.calls)) + copy(out, b.calls) + return out +} + +// CallsOf returns the recorded calls with the given op. +func (b *Backend) CallsOf(op string) []Call { + var out []Call + for _, c := range b.Calls() { + if c.Op == op { + out = append(out, c) + } + } + return out +} + +// CommandLog renders the created sessions' argv as lines, for substring +// asserts equivalent to the old fake-tmux log greps. +func (b *Backend) CommandLog() string { + var lines []string + for _, c := range b.CallsOf("create") { + lines = append(lines, strings.Join(c.Command, " ")) + } + return strings.Join(lines, "\n") +} + +func (b *Backend) CreateSession(_ context.Context, name, cwd string, env map[string]string, command []string) error { + b.record(Call{Op: "create", Name: name, Cwd: cwd, Env: env, Command: append([]string{}, command...)}) + return b.CreateErr +} + +func (b *Backend) SetSessionLabel(_ context.Context, name, label string) error { + b.record(Call{Op: "label", Name: name, Label: label}) + return b.LabelErr +} + +func (b *Backend) List(_ context.Context) ([]session.Info, error) { + b.record(Call{Op: "list"}) + if b.ListErr != nil { + return nil, b.ListErr + } + out := make([]session.Info, len(b.Sessions)) + copy(out, b.Sessions) + return out, nil +} + +func (b *Backend) Capture(_ context.Context, name string, lines int) (string, error) { + b.record(Call{Op: "capture", Name: name, Lines: lines}) + if b.CaptureErr != nil { + return "", b.CaptureErr + } + return b.CaptureText, nil +} + +func (b *Backend) SendLine(_ context.Context, name, text string) error { + b.record(Call{Op: "send", Name: name, Text: text}) + return b.SendErr +} + +func (b *Backend) Kill(_ context.Context, name string) error { + b.record(Call{Op: "kill", Name: name}) + return b.KillErr +} + +func (b *Backend) HasSession(_ context.Context, name string) bool { + b.record(Call{Op: "has", Name: name}) + return b.HasResult +} + +func (b *Backend) AttachArgv(name string) ([]string, error) { + b.record(Call{Op: "attach", Name: name}) + exe := b.AttachExe + if exe == "" { + exe = "/usr/local/bin/uam" + } + return []string{exe, "__attach", name}, nil +} diff --git a/internal/adapter/tmux_adapter.go b/internal/adapter/agent.go similarity index 50% rename from internal/adapter/tmux_adapter.go rename to internal/adapter/agent.go index 86cad7a..6de0a4f 100644 --- a/internal/adapter/tmux_adapter.go +++ b/internal/adapter/agent.go @@ -14,21 +14,22 @@ import ( "time" "github.com/RandomCodeSpace/unified-agent-manager/internal/log" - "github.com/RandomCodeSpace/unified-agent-manager/internal/tmux" + "github.com/RandomCodeSpace/unified-agent-manager/internal/session" ) // prRescanInterval is how stale a session's last PR scrape may grow before List -// re-captures its pane. The PR URL is the only thing the per-session capture is -// for, and it appears once early then never changes — so capturing every 2s -// refresh tick forked a capture-pane per session per tick for no new signal. -// First discovery still captures immediately; subsequent ticks within this -// window reuse the store-persisted PR (re-hydrated by mergeStoredMetadata), and -// a fresh capture only fires once the window elapses (F16). +// re-captures its output. The PR URL is the only thing the per-session capture +// is for, and it appears once early then never changes — so capturing every 2s +// refresh tick burned a capture round-trip per session per tick for no new +// signal. First discovery still captures immediately; subsequent ticks within +// this window reuse the store-persisted PR (re-hydrated by +// mergeStoredMetadata), and a fresh capture only fires once the window elapses +// (F16). const prRescanInterval = 60 * time.Second -// prCaptureLines is the pane tail captured for PR scraping. A PR URL is emitted -// near where `gh`/the agent prints it, so a short tail is enough — the 200-line -// grab the peek path uses is wasteful here (F16). +// prCaptureLines is the output tail captured for PR scraping. A PR URL is +// emitted near where `gh`/the agent prints it, so a short tail is enough — the +// 200-line grab the peek path uses is wasteful here (F16). const prCaptureLines = 40 type CommandCandidate struct { @@ -36,36 +37,59 @@ type CommandCandidate struct { Args []string } -type TmuxAgent struct { - NameValue string - DisplayNameValue string - Candidates []CommandCandidate - YoloArgs []string - Tmux *tmux.Client - SessionArgs func(req ResumeRequest, activity string) []string +// Backend is the session-management surface an Agent drives: create / list / +// capture / reply / kill / attach against uam's native session hosts +// (internal/session.Client in production, fakes in tests). +type Backend interface { + CreateSession(ctx context.Context, name, cwd string, env map[string]string, command []string) error + SetSessionLabel(ctx context.Context, name, label string) error + List(ctx context.Context) ([]session.Info, error) + Capture(ctx context.Context, name string, lines int) (string, error) + SendLine(ctx context.Context, name, text string) error + Kill(ctx context.Context, name string) error + HasSession(ctx context.Context, name string) bool + AttachArgv(name string) ([]string, error) +} + +// Agent adapts one provider CLI (claude, codex, ...) onto the shared session +// backend. It was previously named TmuxAgent; the lifecycle contract is +// unchanged, only the backend is now uam's own session hosts. +type Agent struct { + NameValue string + DisplayNameValue string + Candidates []CommandCandidate + YoloArgs []string + Backend Backend + SessionArgs func(req ResumeRequest, activity string) []string + // ProviderSession optionally reports the provider-side session id that + // the launched agent will use (e.g. the uuid claude was seeded with via + // --session-id), or "" when unknown. It is persisted so a later resume + // can target the exact provider session (F-resume). + ProviderSession func(req ResumeRequest, activity string) string SkipPromptOnResume bool randomReader io.Reader // now is the clock used to throttle per-session PR captures; overridable in - // tests. lastPRScan records, per tmux session name, when its pane was last + // tests. lastPRScan records, per session name, when its output was last // captured for PR scraping so List can skip the capture within - // prRescanInterval (F16). + // prRescanInterval (F16). List prunes entries whose session is gone so the + // map cannot grow without bound across many session lifetimes. now func() time.Time prScanMu sync.Mutex lastPRScan map[string]time.Time } -func NewTmuxAgent(name, display string, candidates []CommandCandidate, yoloArgs []string, client *tmux.Client) *TmuxAgent { - if client == nil { - client = tmux.New("uam") +func NewAgent(name, display string, candidates []CommandCandidate, yoloArgs []string, backend Backend) *Agent { + if backend == nil { + backend = session.NewClient() } - return &TmuxAgent{NameValue: name, DisplayNameValue: display, Candidates: candidates, YoloArgs: yoloArgs, Tmux: client, randomReader: rand.Reader, now: time.Now, lastPRScan: map[string]time.Time{}} + return &Agent{NameValue: name, DisplayNameValue: display, Candidates: candidates, YoloArgs: yoloArgs, Backend: backend, randomReader: rand.Reader, now: time.Now, lastPRScan: map[string]time.Time{}} } -func (a *TmuxAgent) Name() string { return a.NameValue } -func (a *TmuxAgent) DisplayName() string { return a.DisplayNameValue } +func (a *Agent) Name() string { return a.NameValue } +func (a *Agent) DisplayName() string { return a.DisplayNameValue } -func (a *TmuxAgent) Available() (bool, string) { +func (a *Agent) Available() (bool, string) { _, ok := a.resolveCommand() if ok { return true, "" @@ -76,7 +100,7 @@ func (a *TmuxAgent) Available() (bool, string) { return false, fmt.Sprintf("%s not on PATH", a.Candidates[0].Display) } -func (a *TmuxAgent) resolveCommand() ([]string, bool) { +func (a *Agent) resolveCommand() ([]string, bool) { for _, c := range a.Candidates { if len(c.Args) == 0 { continue @@ -88,7 +112,7 @@ func (a *TmuxAgent) resolveCommand() ([]string, bool) { return nil, false } -func (a *TmuxAgent) commandForMode(mode string) ([]string, error) { +func (a *Agent) commandForMode(mode string) ([]string, error) { cmd, ok := a.resolveCommand() if !ok { return nil, fmt.Errorf("%s unavailable", a.Name()) @@ -96,7 +120,7 @@ func (a *TmuxAgent) commandForMode(mode string) ([]string, error) { return commandWithModeArgs(cmd, mode, a.YoloArgs), nil } -func (a *TmuxAgent) commandForRequest(ctx context.Context, req ResumeRequest, extra []string) ([]string, error) { +func (a *Agent) commandForRequest(ctx context.Context, req ResumeRequest, extra []string) ([]string, error) { if req.CommandAlias == "" { cmd, err := a.commandForMode(req.Mode) if err != nil { @@ -147,14 +171,14 @@ func shellAliasCommand(ctx context.Context, cmd []string) ([]string, error) { if !filepath.IsAbs(shell) { return nil, fmt.Errorf("invalid SHELL %q: must be absolute for command alias fallback", shell) } - check := exec.CommandContext(ctx, shell, "-ic", "type "+tmux.ShellJoin([]string{cmd[0]})+" >/dev/null 2>&1") // #nosec G204,G702 -- shell is the user's configured absolute shell; alias name is validated and shell-quoted before reaching this path. + check := exec.CommandContext(ctx, shell, "-ic", "type "+ShellJoin([]string{cmd[0]})+" >/dev/null 2>&1") // #nosec G204,G702 -- shell is the user's configured absolute shell; alias name is validated and shell-quoted before reaching this path. if err := check.Run(); err != nil { return nil, fmt.Errorf("command alias %q not found on PATH or in interactive shell: %w", cmd[0], err) } - return []string{shell, "-ic", tmux.ShellJoin(cmd)}, nil + return []string{shell, "-ic", ShellJoin(cmd)}, nil } -func (a *TmuxAgent) Dispatch(ctx context.Context, req DispatchRequest) (Session, error) { +func (a *Agent) Dispatch(ctx context.Context, req DispatchRequest) (Session, error) { id, err := newID(a.randomReader) if err != nil { return Session{}, fmt.Errorf("generate session id: %w", err) @@ -162,7 +186,7 @@ func (a *TmuxAgent) Dispatch(ctx context.Context, req DispatchRequest) (Session, return a.startSession(ctx, ResumeRequest{ID: id, Name: req.Name, CommandAlias: req.CommandAlias, Prompt: req.Prompt, Cwd: req.Cwd, Mode: req.Mode}, "dispatched") } -func (a *TmuxAgent) Resume(ctx context.Context, req ResumeRequest) (Session, error) { +func (a *Agent) Resume(ctx context.Context, req ResumeRequest) (Session, error) { if req.ID == "" { id, err := newID(a.randomReader) if err != nil { @@ -173,7 +197,7 @@ func (a *TmuxAgent) Resume(ctx context.Context, req ResumeRequest) (Session, err return a.startSession(ctx, req, "resumed") } -func (a *TmuxAgent) startSession(ctx context.Context, req ResumeRequest, activity string) (Session, error) { +func (a *Agent) startSession(ctx context.Context, req ResumeRequest, activity string) (Session, error) { extra := []string{} if a.SessionArgs != nil { extra = append(extra, a.SessionArgs(req, activity)...) @@ -186,38 +210,33 @@ func (a *TmuxAgent) startSession(ctx context.Context, req ResumeRequest, activit if err != nil { return Session{}, err } - tmuxName := req.TmuxSession - if tmuxName == "" { - tmuxName = fmt.Sprintf("uam-%s-%s", a.Name(), req.ID[:min(8, len(req.ID))]) + sessionName := req.SessionName + if sessionName == "" { + sessionName = fmt.Sprintf("uam-%s-%s", a.Name(), req.ID[:min(8, len(req.ID))]) } env := map[string]string{"UAM_AGENT": a.Name(), "UAM_ID": req.ID} - if err := a.Tmux.CreateSession(ctx, tmuxName, cwd, env, cmd); err != nil { - return Session{}, fmt.Errorf("create tmux session %s: %w", tmuxName, err) - } - // Best-effort: apply uam-friendly tmux server settings (mouse/clipboard, - // Ctrl+Z). This runs AFTER CreateSession so the server exists — applying it - // first on the very first dispatch fails and used to latch that failure - // (F25). Failures here don't prevent the session from being created. - _ = a.Tmux.EnsureServerConfig(ctx) - // Surface the user-facing name in tmux's status line, terminal title, and - // window list (the canonical uam-- stays as #S for uam's own - // parsing). Cosmetic and best-effort — a failure never affects the session. + if err := a.Backend.CreateSession(ctx, sessionName, cwd, env, cmd); err != nil { + return Session{}, fmt.Errorf("create session %s: %w", sessionName, err) + } + // Surface the user-facing name in attached terminals' titles (the + // canonical uam-- stays the machine-parseable session name). + // Cosmetic and best-effort — a failure never affects the session. displayName := req.Name if displayName == "" { displayName = displayNameFromDir(cwd) } - if err := a.Tmux.SetSessionLabel(ctx, tmuxName, displayName+" · "+a.Name(), displayName); err != nil { - log.Debug("set session label failed", "session", tmuxName, "error", err) + if err := a.Backend.SetSessionLabel(ctx, sessionName, displayName+" · "+a.Name()); err != nil { + log.Debug("set session label failed", "session", sessionName, "error", err) } shouldSendPrompt := strings.TrimSpace(req.Prompt) != "" && (activity != "resumed" || !a.SkipPromptOnResume) if shouldSendPrompt { - if err := a.Tmux.SendLine(ctx, tmuxName, req.Prompt); err != nil { + if err := a.Backend.SendLine(ctx, sessionName, req.Prompt); err != nil { // The session is live but never received its prompt. Roll it back so // it doesn't linger as an orphan the store records as Exited/closed. // Use WithoutCancel so a cancelled dispatch context still tears the // session down; the original SendLine error is what the caller sees. - _ = a.Tmux.Kill(context.WithoutCancel(ctx), tmuxName) - return Session{}, fmt.Errorf("send prompt to %s: %w", tmuxName, err) + _ = a.Backend.Kill(context.WithoutCancel(ctx), sessionName) + return Session{}, fmt.Errorf("send prompt to %s: %w", sessionName, err) } } now := time.Now() @@ -225,7 +244,13 @@ func (a *TmuxAgent) startSession(ctx context.Context, req ResumeRequest, activit if created.IsZero() { created = now } - return Session{ID: req.ID, AgentType: a.Name(), CommandAlias: req.CommandAlias, DisplayName: displayName, Prompt: req.Prompt, Cwd: cwd, TmuxSession: tmuxName, State: Active, ProcAlive: Alive, CreatedAt: created, LastChange: now}, nil + providerID := req.ProviderSessionID + if a.ProviderSession != nil { + if id := a.ProviderSession(req, activity); id != "" { + providerID = id + } + } + return Session{ID: req.ID, AgentType: a.Name(), CommandAlias: req.CommandAlias, DisplayName: displayName, Prompt: req.Prompt, Cwd: cwd, SessionName: sessionName, ProviderSessionID: providerID, State: Active, ProcAlive: Alive, CreatedAt: created, LastChange: now}, nil } func resolveSessionCwd(cwd string) (string, error) { @@ -237,38 +262,40 @@ func resolveSessionCwd(cwd string) (string, error) { } } // Resolve the working directory to an absolute path once, before it is used - // for both CreateSession (the tmux -c arg) and the returned Session.Cwd that - // the store persists. A relative cwd persisted verbatim would be re-resolved - // against uam's process cwd on resume, relaunching the agent in the wrong - // directory (C2-4). + // for both CreateSession and the returned Session.Cwd that the store + // persists. A relative cwd persisted verbatim would be re-resolved against + // uam's process cwd on resume, relaunching the agent in the wrong directory + // (C2-4). if abs, err := filepath.Abs(cwd); err == nil { cwd = abs } return cwd, nil } -func (a *TmuxAgent) List(ctx context.Context) ([]Session, error) { - infos, err := a.Tmux.List(ctx) +func (a *Agent) List(ctx context.Context) ([]Session, error) { + infos, err := a.Backend.List(ctx) if err != nil { return nil, fmt.Errorf("list %s sessions: %w", a.Name(), err) } var out []Session prefix := "uam-" + a.Name() + "-" + seen := make(map[string]struct{}, len(infos)) for _, info := range infos { if !strings.HasPrefix(info.Name, prefix) { continue } + seen[info.Name] = struct{}{} id := strings.TrimPrefix(info.Name, prefix) - state, alive := ClassifyPane(tmux.PaneAlive(info.PanePID)) + state, alive := ClassifyPane(info.Alive) created := time.Unix(info.CreatedUnix, 0) // Scrape the PR URL only on first discovery or once the rescan window // elapses. On a throttled tick PR stays nil; service.mergeStoredMetadata // re-hydrates it from the persisted record so the dashboard never loses - // it (F16). Captures are sequential by design — parallelizing would fork - // a burst of capture-pane subprocesses. + // it (F16). Captures are sequential by design — parallelizing would fire + // a burst of capture round-trips. var prRef *PRRef if a.shouldScanPR(info.Name) { - capture, capErr := a.Tmux.Capture(ctx, info.Name, prCaptureLines) + capture, capErr := a.Backend.Capture(ctx, info.Name, prCaptureLines) if capErr != nil { // Per-session and non-fatal: a failed PR scrape just leaves PR nil // for this tick (mergeStoredMetadata re-hydrates any known PR). Log @@ -277,15 +304,16 @@ func (a *TmuxAgent) List(ctx context.Context) ([]Session, error) { } prRef = ExtractPR(capture) } - out = append(out, Session{ID: id, AgentType: a.Name(), DisplayName: id, Cwd: info.CurrentPath, TmuxSession: info.Name, State: state, ProcAlive: alive, LastChange: time.Now(), CreatedAt: created, PR: prRef}) + out = append(out, Session{ID: id, AgentType: a.Name(), DisplayName: id, Cwd: info.Cwd, SessionName: info.Name, State: state, ProcAlive: alive, LastChange: time.Now(), CreatedAt: created, PR: prRef}) } + a.prunePRScan(seen) return out, nil } -// shouldScanPR reports whether the pane named tmuxName is due for a PR scrape +// shouldScanPR reports whether the session named name is due for a PR scrape // and, if so, stamps the scan time. It is the per-session leaky bucket that -// keeps List from capturing every pane on every refresh tick (F16). -func (a *TmuxAgent) shouldScanPR(tmuxName string) bool { +// keeps List from capturing every session on every refresh tick (F16). +func (a *Agent) shouldScanPR(name string) bool { clock := a.now if clock == nil { clock = time.Now @@ -296,48 +324,58 @@ func (a *TmuxAgent) shouldScanPR(tmuxName string) bool { if a.lastPRScan == nil { a.lastPRScan = map[string]time.Time{} } - last, seen := a.lastPRScan[tmuxName] + last, seen := a.lastPRScan[name] if seen && now.Sub(last) < prRescanInterval { return false } - a.lastPRScan[tmuxName] = now + a.lastPRScan[name] = now return true } -func (a *TmuxAgent) Peek(ctx context.Context, id string) (PeekResult, error) { - target := a.target(id) - capture, err := a.Tmux.Capture(ctx, target, 200) +// prunePRScan drops scan stamps for sessions no longer live so the throttle +// map cannot grow without bound across many session lifetimes. +func (a *Agent) prunePRScan(live map[string]struct{}) { + a.prScanMu.Lock() + defer a.prScanMu.Unlock() + for name := range a.lastPRScan { + if _, ok := live[name]; !ok { + delete(a.lastPRScan, name) + } + } +} + +func (a *Agent) Peek(ctx context.Context, id string) (PeekResult, error) { + capture, err := a.Backend.Capture(ctx, a.target(id), 200) if err != nil { return PeekResult{}, fmt.Errorf("peek %s session %s: %w", a.Name(), id, err) } return PeekResult{TailText: capture}, nil } -func (a *TmuxAgent) Reply(ctx context.Context, id, text string) error { - return a.Tmux.SendLine(ctx, a.target(id), text) +func (a *Agent) Reply(ctx context.Context, id, text string) error { + return a.Backend.SendLine(ctx, a.target(id), text) } -func (a *TmuxAgent) Attach(id string) (AttachSpec, error) { - argv, err := a.Tmux.AttachArgv(a.target(id)) +func (a *Agent) Attach(id string) (AttachSpec, error) { + argv, err := a.Backend.AttachArgv(a.target(id)) if err != nil { return AttachSpec{}, fmt.Errorf("attach %s session %s: %w", a.Name(), id, err) } return AttachSpec{Argv: argv}, nil } -func (a *TmuxAgent) Stop(ctx context.Context, id string) error { return a.Tmux.Kill(ctx, a.target(id)) } -func (a *TmuxAgent) HasSession(ctx context.Context, id string) bool { - return a.Tmux.HasSession(ctx, a.target(id)) +func (a *Agent) Stop(ctx context.Context, id string) error { return a.Backend.Kill(ctx, a.target(id)) } +func (a *Agent) HasSession(ctx context.Context, id string) bool { + return a.Backend.HasSession(ctx, a.target(id)) } -// target resolves an id to a tmux -t target. It anchors the name with tmux's -// `=` exact-match prefix so a longer neighbour that shares the truncated prefix -// is never hit by tmux's default prefix matching (F32). Human-facing prefix -// lookups stay in Service.Find; internal targeting is always exact. -func (a *TmuxAgent) target(id string) string { - name := id - if !strings.HasPrefix(id, "uam-") { - name = fmt.Sprintf("uam-%s-%s", a.Name(), id[:min(8, len(id))]) - } - return "=" + name +// target resolves an id to its canonical session name. Matching is always +// exact: the backend looks sessions up by full name, so a longer neighbour +// sharing the truncated prefix can never be hit (F32). Human-facing prefix +// lookups stay in Service.Find. +func (a *Agent) target(id string) string { + if strings.HasPrefix(id, "uam-") { + return id + } + return fmt.Sprintf("uam-%s-%s", a.Name(), id[:min(8, len(id))]) } func newID(random io.Reader) (string, error) { diff --git a/internal/adapter/agent_test.go b/internal/adapter/agent_test.go new file mode 100644 index 0000000..6b8ddae --- /dev/null +++ b/internal/adapter/agent_test.go @@ -0,0 +1,330 @@ +package adapter + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter/adaptertest" + "github.com/RandomCodeSpace/unified-agent-manager/internal/session" +) + +func newLifecycleAgent(t *testing.T) (*Agent, *adaptertest.Backend) { + t.Helper() + dir := t.TempDir() + writeExecutable(t, filepath.Join(dir, "fakeagent"), "#!/bin/sh\nexit 0\n") + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + be := &adaptertest.Backend{ + Sessions: []session.Info{{Name: "uam-fake-abc12345", CreatedUnix: 1710000000, ChildPID: 1, Cwd: "/tmp/repo", Alive: true}}, + CaptureText: "Thinking...\ncreated https://github.com/o/r/pull/7\n", + } + return NewAgent("fake", "Fake Agent", []CommandCandidate{{Display: "fakeagent", Args: []string{"fakeagent"}}}, []string{"--yolo"}, be), be +} + +func TestAgentLifecycle(t *testing.T) { + ag, be := newLifecycleAgent(t) + if ok, reason := ag.Available(); !ok || reason != "" { + t.Fatalf("Available = %v %q", ok, reason) + } + if ag.Name() != "fake" || ag.DisplayName() != "Fake Agent" { + t.Fatalf("names wrong") + } + sess, err := ag.Dispatch(context.Background(), DispatchRequest{Prompt: "hello", Cwd: "/tmp", Mode: "yolo"}) + if err != nil { + t.Fatalf("Dispatch: %v", err) + } + if sess.AgentType != "fake" || sess.State != Active || sess.SessionName == "" { + t.Fatalf("bad session: %+v", sess) + } + creates := be.CallsOf("create") + if len(creates) != 1 || !strings.Contains(be.CommandLog(), "fakeagent --yolo") { + t.Fatalf("bad create calls: %+v", creates) + } + if creates[0].Env["UAM_AGENT"] != "fake" || creates[0].Env["UAM_ID"] != sess.ID { + t.Fatalf("create env missing UAM_AGENT/UAM_ID: %+v", creates[0].Env) + } + sends := be.CallsOf("send") + if len(sends) != 1 || sends[0].Text != "hello" { + t.Fatalf("dispatch should send the prompt once: %+v", sends) + } + list, err := ag.List(context.Background()) + if err != nil || len(list) != 1 { + t.Fatalf("List len=%d err=%v", len(list), err) + } + if list[0].PR == nil || list[0].PR.Number != 7 { + t.Fatalf("bad classified list: %+v", list[0]) + } + if list[0].State != Active || list[0].ProcAlive != Alive || list[0].Cwd != "/tmp/repo" { + t.Fatalf("bad list session: %+v", list[0]) + } + peek, err := ag.Peek(context.Background(), "abc12345") + if err != nil || !strings.Contains(peek.TailText, "Thinking") { + t.Fatalf("Peek: %+v %v", peek, err) + } + if err := ag.Reply(context.Background(), "abc12345", "ok"); err != nil { + t.Fatal(err) + } + if spec, err := ag.Attach("abc12345"); err != nil || len(spec.Argv) == 0 { + t.Fatalf("Attach: %+v %v", spec, err) + } + if err := ag.Stop(context.Background(), "abc12345"); err != nil { + t.Fatal(err) + } + if kills := be.CallsOf("kill"); len(kills) != 1 || kills[0].Name != "uam-fake-abc12345" { + t.Fatalf("Stop should kill the exact session: %+v", kills) + } +} + +// A dispatched session must get a user-facing label " · " so +// attached terminals' titles show the user's name, not uam--. The +// persisted Session.DisplayName stays the bare name. +func TestDispatchSetsSessionLabel(t *testing.T) { + ag, be := newLifecycleAgent(t) + sess, err := ag.Dispatch(context.Background(), DispatchRequest{Name: "bugfix", Cwd: "/tmp", Mode: "yolo"}) + if err != nil { + t.Fatalf("Dispatch: %v", err) + } + if sess.DisplayName != "bugfix" { + t.Fatalf("DisplayName = %q, want bare name", sess.DisplayName) + } + labels := be.CallsOf("label") + if len(labels) != 1 || labels[0].Label != "bugfix · fake" { + t.Fatalf("label calls = %+v, want one 'bugfix · fake'", labels) + } + if labels[0].Name != sess.SessionName { + t.Fatalf("label target %q != session %q", labels[0].Name, sess.SessionName) + } +} + +// F52 — a per-session capture failure during the PR scrape is non-fatal: the +// session stays in the List result with PR nil. +func TestListKeepsSessionWhenCaptureFails(t *testing.T) { + ag, be := newLifecycleAgent(t) + be.CaptureErr = errors.New("capture exploded") + list, err := ag.List(context.Background()) + if err != nil { + t.Fatalf("List: %v", err) + } + if len(list) != 1 || list[0].PR != nil { + t.Fatalf("capture failure should keep session with nil PR: %+v", list) + } +} + +// Resume must reuse the persisted session name (so the restored session keeps +// its identity) instead of minting a fresh one. +func TestAgentResumeUsesPersistedMetadata(t *testing.T) { + ag, be := newLifecycleAgent(t) + sess, err := ag.Resume(context.Background(), ResumeRequest{ID: "abc12345-dead-beef-cafe-0123456789ab", Name: "named", Prompt: "p", Cwd: "/tmp", Mode: "yolo", SessionName: "uam-fake-abc12345"}) + if err != nil { + t.Fatalf("Resume: %v", err) + } + if sess.SessionName != "uam-fake-abc12345" { + t.Fatalf("SessionName = %q, want persisted name", sess.SessionName) + } + creates := be.CallsOf("create") + if len(creates) != 1 || creates[0].Name != "uam-fake-abc12345" { + t.Fatalf("create should target persisted name: %+v", creates) + } +} + +// An alias resolvable via LookPath replaces the default command argv[0] with +// the alias's absolute path; mode args still apply. +func TestAgentCommandAliasOnPathReplacesDefaultCommand(t *testing.T) { + ag, be := newLifecycleAgent(t) + dir := t.TempDir() + aliasPath := filepath.Join(dir, "myclaude") + writeExecutable(t, aliasPath, "#!/bin/sh\nexit 0\n") + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + _, err := ag.Dispatch(context.Background(), DispatchRequest{CommandAlias: "myclaude", Cwd: "/tmp", Mode: "yolo"}) + if err != nil { + t.Fatalf("Dispatch: %v", err) + } + logText := be.CommandLog() + if !strings.HasPrefix(logText, aliasPath) || !strings.Contains(logText, "--yolo") { + t.Fatalf("alias dispatch argv = %q, want resolved alias path with mode args", logText) + } +} + +// An alias not on PATH falls back to a `$SHELL -ic` probe: when the +// interactive shell knows it, the session command becomes the shell +// invocation. +func TestAgentCommandAliasFallsBackToInteractiveShell(t *testing.T) { + ag, be := newLifecycleAgent(t) + dir := t.TempDir() + shellPath := filepath.Join(dir, "fakeshell") + writeExecutable(t, shellPath, "#!/bin/sh\nexit 0\n") + t.Setenv("SHELL", shellPath) + _, err := ag.Dispatch(context.Background(), DispatchRequest{CommandAlias: "onlyinshell", Cwd: "/tmp", Mode: "yolo"}) + if err != nil { + t.Fatalf("Dispatch: %v", err) + } + logText := be.CommandLog() + if !strings.Contains(logText, shellPath+" -ic") || !strings.Contains(logText, "onlyinshell --yolo") { + t.Fatalf("alias shell fallback argv = %q", logText) + } +} + +// An alias the interactive shell does not know must fail BEFORE any session is +// created. +func TestAgentCommandAliasMissingFromShellFailsBeforeCreate(t *testing.T) { + ag, be := newLifecycleAgent(t) + dir := t.TempDir() + shellPath := filepath.Join(dir, "fakeshell") + writeExecutable(t, shellPath, "#!/bin/sh\nexit 1\n") + t.Setenv("SHELL", shellPath) + _, err := ag.Dispatch(context.Background(), DispatchRequest{CommandAlias: "ghostalias", Cwd: "/tmp", Mode: "yolo"}) + if err == nil || !strings.Contains(err.Error(), "ghostalias") { + t.Fatalf("want alias-not-found error, got %v", err) + } + if len(be.CallsOf("create")) != 0 { + t.Fatal("no session may be created for an unresolvable alias") + } +} + +func TestAgentRejectsUnsafeCommandAlias(t *testing.T) { + ag, be := newLifecycleAgent(t) + for _, alias := range []string{"bad alias", "a;b", "$(boom)", "-flag", "a/b"} { + _, err := ag.Dispatch(context.Background(), DispatchRequest{CommandAlias: alias, Cwd: "/tmp", Mode: "yolo"}) + if err == nil { + t.Fatalf("alias %q must be rejected", alias) + } + } + if len(be.CallsOf("create")) != 0 { + t.Fatal("no session may be created for an unsafe alias") + } +} + +type errReader struct{} + +func (r errReader) Read([]byte) (int, error) { return 0, errors.New("entropy down") } + +func TestDispatchReturnsRandomIDError(t *testing.T) { + ag, _ := newLifecycleAgent(t) + ag.randomReader = errReader{} + _, err := ag.Dispatch(context.Background(), DispatchRequest{Cwd: "/tmp", Mode: "yolo"}) + if err == nil || !strings.Contains(err.Error(), "generate session id") { + t.Fatalf("want id generation error, got %v", err) + } +} + +func TestResumeReturnsRandomIDErrorWhenIDMissing(t *testing.T) { + ag, _ := newLifecycleAgent(t) + ag.randomReader = errReader{} + _, err := ag.Resume(context.Background(), ResumeRequest{Cwd: "/tmp", Mode: "yolo"}) + if err == nil || !strings.Contains(err.Error(), "generate session id") { + t.Fatalf("want id generation error, got %v", err) + } +} + +func TestNewIDKeepsUUIDFormat(t *testing.T) { + id, err := newID(nil) + if err != nil { + t.Fatal(err) + } + parts := strings.Split(id, "-") + if len(parts) != 5 || len(parts[0]) != 8 || len(parts[4]) != 12 { + t.Fatalf("id %q is not UUID-shaped", id) + } + if id[14] != '4' { + t.Fatalf("id %q missing version nibble", id) + } +} + +// A session whose prompt cannot be delivered must be rolled back (killed), or +// it lingers as an orphan the store records as Exited/closed. +func TestStartSessionRollsBackOnSendLineFailure(t *testing.T) { + ag, be := newLifecycleAgent(t) + be.SendErr = errors.New("send broke") + _, err := ag.Dispatch(context.Background(), DispatchRequest{Prompt: "hi", Cwd: "/tmp", Mode: "yolo"}) + if err == nil || !strings.Contains(err.Error(), "send prompt") { + t.Fatalf("want send prompt error, got %v", err) + } + if len(be.CallsOf("kill")) != 1 { + t.Fatal("failed prompt delivery must kill the just-created session") + } +} + +func TestStartSessionReturnsSendLineErrorWhenRollbackKillAlsoFails(t *testing.T) { + ag, be := newLifecycleAgent(t) + be.SendErr = errors.New("send broke") + be.KillErr = errors.New("kill broke too") + _, err := ag.Dispatch(context.Background(), DispatchRequest{Prompt: "hi", Cwd: "/tmp", Mode: "yolo"}) + if err == nil || !strings.Contains(err.Error(), "send broke") { + t.Fatalf("caller must see the SendLine error, got %v", err) + } +} + +func TestAgentDispatchWithoutPromptSkipsSend(t *testing.T) { + ag, be := newLifecycleAgent(t) + if _, err := ag.Dispatch(context.Background(), DispatchRequest{Prompt: " ", Cwd: "/tmp", Mode: "yolo"}); err != nil { + t.Fatalf("Dispatch: %v", err) + } + if len(be.CallsOf("send")) != 0 { + t.Fatal("blank prompt must not be sent") + } +} + +// F32 — internal targeting is always the exact canonical name, never a prefix +// that could hit a longer neighbour. +func TestTargetUsesExactCanonicalName(t *testing.T) { + ag, _ := newLifecycleAgent(t) + if got := ag.target("abc12345-dead-beef-cafe-0123456789ab"); got != "uam-fake-abc12345" { + t.Fatalf("target(uuid) = %q", got) + } + if got := ag.target("uam-fake-abc12345"); got != "uam-fake-abc12345" { + t.Fatalf("target(canonical) = %q", got) + } + if got := ag.target("abc"); got != "uam-fake-abc" { + t.Fatalf("target(short) = %q", got) + } +} + +func TestStartSessionWrapsCreateSessionError(t *testing.T) { + ag, be := newLifecycleAgent(t) + be.CreateErr = errors.New("spawn failed") + _, err := ag.Dispatch(context.Background(), DispatchRequest{Cwd: "/tmp", Mode: "yolo"}) + if err == nil || !strings.Contains(err.Error(), "create session") || !strings.Contains(err.Error(), "spawn failed") { + t.Fatalf("want wrapped create error, got %v", err) + } +} + +func TestListWrapsBackendListError(t *testing.T) { + ag, be := newLifecycleAgent(t) + be.ListErr = errors.New("scan failed") + _, err := ag.List(context.Background()) + if err == nil || !strings.Contains(err.Error(), "list fake sessions") { + t.Fatalf("want wrapped list error, got %v", err) + } +} + +func TestDisplayNameFromDir(t *testing.T) { + if got := displayNameFromDir("/home/dev/projects/uam"); got != "uam" { + t.Fatalf("displayNameFromDir = %q", got) + } + if got := displayNameFromDir("/"); got != "untitled" { + t.Fatalf("displayNameFromDir(/) = %q", got) + } +} + +func TestAgentUnavailable(t *testing.T) { + be := &adaptertest.Backend{} + ag := NewAgent("ghost", "Ghost", []CommandCandidate{{Display: "ghostcli", Args: []string{"definitely-not-installed-cli"}}}, nil, be) + ok, reason := ag.Available() + if ok || !strings.Contains(reason, "ghostcli") { + t.Fatalf("Available = %v %q", ok, reason) + } + none := NewAgent("none", "None", nil, nil, be) + if ok, reason := none.Available(); ok || reason != "no command configured" { + t.Fatalf("Available = %v %q", ok, reason) + } +} + +func writeExecutable(t *testing.T, path, content string) { + t.Helper() + if err := os.WriteFile(path, []byte(content), 0o755); err != nil { + t.Fatal(err) + } +} diff --git a/internal/adapter/capture_cadence_test.go b/internal/adapter/capture_cadence_test.go index cf261ed..97880cf 100644 --- a/internal/adapter/capture_cadence_test.go +++ b/internal/adapter/capture_cadence_test.go @@ -2,57 +2,31 @@ package adapter import ( "context" - "os" - "path/filepath" - "strings" "testing" "time" - "github.com/RandomCodeSpace/unified-agent-manager/internal/tmux" + "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter/adaptertest" + "github.com/RandomCodeSpace/unified-agent-manager/internal/session" ) -// newCadenceAgent builds a TmuxAgent over a fake tmux that records every -// invocation (so capture-pane calls can be counted) and reports one live -// session whose pane prints a PR URL. -func newCadenceAgent(t *testing.T) (*TmuxAgent, string) { - t.Helper() - dir := t.TempDir() - tmuxPath := filepath.Join(dir, "tmux") - writeExecutable(t, tmuxPath, `#!/bin/sh -printf '%s\n' "$*" >> "$TMUX_LOG" -case "$*" in - *"list-sessions"*) echo "uam-fake-abc12345|1710000000|0|1|/tmp/repo|fakeagent" ;; - *"capture-pane"*) printf 'Thinking...\ncreated https://github.com/o/r/pull/7\n' ;; -esac -exit 0 -`) - t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) - logPath := filepath.Join(dir, "tmux.log") - t.Setenv("TMUX_LOG", logPath) - client := tmux.New("uam") - client.Executable = tmuxPath - ag := NewTmuxAgent("fake", "Fake Agent", []CommandCandidate{{Display: "fakeagent", Args: []string{"fakeagent"}}}, []string{"--yolo"}, client) - return ag, logPath -} - -func countCaptures(t *testing.T, logPath string) int { - t.Helper() - b, _ := os.ReadFile(logPath) - n := 0 - for _, line := range strings.Split(string(b), "\n") { - if strings.Contains(line, "capture-pane") { - n++ - } +// newCadenceAgent builds an Agent over a recording backend that reports one +// live session whose output contains a PR URL, so capture calls can be +// counted. +func newCadenceAgent() (*Agent, *adaptertest.Backend) { + be := &adaptertest.Backend{ + Sessions: []session.Info{{Name: "uam-fake-abc12345", CreatedUnix: 1710000000, ChildPID: 1, Cwd: "/tmp/repo", Alive: true}}, + CaptureText: "Thinking...\ncreated https://github.com/o/r/pull/7\n", } - return n + ag := NewAgent("fake", "Fake Agent", []CommandCandidate{{Display: "fakeagent", Args: []string{"fakeagent"}}}, []string{"--yolo"}, be) + return ag, be } -// F16 — capture-pane must NOT run on every List tick. After the first -// discovery, subsequent ticks within the rescan interval must reuse the prior -// result (no new capture), so the dashboard's 2s refresh doesn't fork a -// capture-pane per session per tick. +// F16 — capture must NOT run on every List tick. After the first discovery, +// subsequent ticks within the rescan interval must reuse the prior result (no +// new capture), so the dashboard's 2s refresh doesn't fire a capture +// round-trip per session per tick. func TestListDoesNotCapturePerSessionEveryTick(t *testing.T) { - ag, logPath := newCadenceAgent(t) + ag, be := newCadenceAgent() clock := time.Unix(1710000000, 0) ag.now = func() time.Time { return clock } @@ -60,19 +34,18 @@ func TestListDoesNotCapturePerSessionEveryTick(t *testing.T) { if _, err := ag.List(context.Background()); err != nil { t.Fatalf("List 1: %v", err) } - if got := countCaptures(t, logPath); got != 1 { + if got := len(be.CallsOf("capture")); got != 1 { t.Fatalf("first List should capture exactly once, got %d", got) } // Several more ticks within the rescan interval: no additional capture. for i := 0; i < 5; i++ { clock = clock.Add(2 * time.Second) - ag.now = func() time.Time { return clock } if _, err := ag.List(context.Background()); err != nil { t.Fatalf("List tick %d: %v", i, err) } } - if got := countCaptures(t, logPath); got != 1 { + if got := len(be.CallsOf("capture")); got != 1 { t.Fatalf("ticks within rescan interval must not re-capture, got %d captures", got) } } @@ -80,28 +53,46 @@ func TestListDoesNotCapturePerSessionEveryTick(t *testing.T) { // F16 — once the rescan interval elapses, List must re-capture to pick up a PR // URL that appeared after first discovery. func TestListRescansForNewPRAfterInterval(t *testing.T) { - ag, logPath := newCadenceAgent(t) + ag, be := newCadenceAgent() clock := time.Unix(1710000000, 0) ag.now = func() time.Time { return clock } if _, err := ag.List(context.Background()); err != nil { t.Fatalf("List 1: %v", err) } - if got := countCaptures(t, logPath); got != 1 { + if got := len(be.CallsOf("capture")); got != 1 { t.Fatalf("first List should capture once, got %d", got) } // Advance past the rescan interval. clock = clock.Add(61 * time.Second) - ag.now = func() time.Time { return clock } sessions, err := ag.List(context.Background()) if err != nil { t.Fatalf("List 2: %v", err) } - if got := countCaptures(t, logPath); got != 2 { + if got := len(be.CallsOf("capture")); got != 2 { t.Fatalf("List past rescan interval must re-capture, got %d captures", got) } if len(sessions) != 1 || sessions[0].PR == nil || sessions[0].PR.Number != 7 { t.Fatalf("rescan should re-discover PR: %+v", sessions) } } + +// The PR-scan throttle map must not grow without bound: stamps for sessions +// that disappeared are pruned on the next List. +func TestListPrunesPRScanStampsForGoneSessions(t *testing.T) { + ag, be := newCadenceAgent() + if _, err := ag.List(context.Background()); err != nil { + t.Fatalf("List: %v", err) + } + if len(ag.lastPRScan) != 1 { + t.Fatalf("scan stamp not recorded: %v", ag.lastPRScan) + } + be.Sessions = nil + if _, err := ag.List(context.Background()); err != nil { + t.Fatalf("List 2: %v", err) + } + if len(ag.lastPRScan) != 0 { + t.Fatalf("gone session's stamp must be pruned: %v", ag.lastPRScan) + } +} diff --git a/internal/adapter/claude/claude.go b/internal/adapter/claude/claude.go index 9888694..6d15626 100644 --- a/internal/adapter/claude/claude.go +++ b/internal/adapter/claude/claude.go @@ -1,25 +1,74 @@ package claude import ( + "context" + "os/exec" + "strings" + "sync" + "time" + "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter" - "github.com/RandomCodeSpace/unified-agent-manager/internal/tmux" ) -// sessionArgs appends claude's `--continue` flag on resume so picking "Resume" -// on an Exited claude row reattaches to the agent's most recent session instead -// of relaunching a fresh one. claude has no flag for presetting its session ID -// at dispatch, so uam can't resume by uam-id directly; `--continue` picks -// claude's last session for the current cwd. The uam UUID is never passed. -func sessionArgs(_ adapter.ResumeRequest, activity string) []string { +// sessionArgs picks claude's session flags per activity. +// +// Dispatch seeds claude's own session id with the uam UUID (`--session-id`) +// when the installed claude supports it, so the provider session is +// addressable later. Resume then targets that exact session (`--resume `) +// instead of `--continue`, whose "most recent conversation in this cwd" +// heuristic resumes the WRONG conversation when several uam sessions share a +// directory. Records without a seeded id (older claude, pre-upgrade sessions) +// keep the `--continue` fallback. +func sessionArgs(req adapter.ResumeRequest, activity string) []string { if activity == "resumed" { + if req.ProviderSessionID != "" { + return []string{"--resume", req.ProviderSessionID} + } return []string{"--continue"} } + if req.ID != "" && supportsSessionID() { + return []string{"--session-id", req.ID} + } return nil } -func New(client *tmux.Client) adapter.AgentAdapter { - a := adapter.NewTmuxAgent("claude", "Claude Code", []adapter.CommandCandidate{{Display: "claude", Args: []string{"claude"}}}, []string{"--dangerously-skip-permissions"}, client) +// providerSession reports the provider-side session id a launch will use, for +// persistence: the seeded uam UUID on dispatch, or the id an exact resume +// re-targets. Empty when the installed claude cannot seed ids. +func providerSession(req adapter.ResumeRequest, activity string) string { + if activity == "dispatched" && req.ID != "" && supportsSessionID() { + return req.ID + } + return req.ProviderSessionID +} + +// sessionIDSupport caches, per resolved claude binary, whether its --help +// advertises --session-id. Older claude releases reject unknown flags at +// startup, so seeding must be probed, not assumed. Keyed by path (not a +// sync.Once) so a PATH change — or a test pointing at a different fake — +// re-probes. +var sessionIDSupport sync.Map // map[string]bool + +func supportsSessionID() bool { + path, err := exec.LookPath("claude") + if err != nil { + return false + } + if v, ok := sessionIDSupport.Load(path); ok { + return v.(bool) + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + out, _ := exec.CommandContext(ctx, path, "--help").CombinedOutput() // #nosec G204 -- path resolved via LookPath for the fixed name "claude". + supported := strings.Contains(string(out), "--session-id") + sessionIDSupport.Store(path, supported) + return supported +} + +func New(backend adapter.Backend) adapter.AgentAdapter { + a := adapter.NewAgent("claude", "Claude Code", []adapter.CommandCandidate{{Display: "claude", Args: []string{"claude"}}}, []string{"--dangerously-skip-permissions"}, backend) a.SessionArgs = sessionArgs + a.ProviderSession = providerSession a.SkipPromptOnResume = true return a } diff --git a/internal/adapter/claude/claude_test.go b/internal/adapter/claude/claude_test.go index e108de9..e91a945 100644 --- a/internal/adapter/claude/claude_test.go +++ b/internal/adapter/claude/claude_test.go @@ -9,7 +9,7 @@ import ( "testing" "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter" - "github.com/RandomCodeSpace/unified-agent-manager/internal/tmux" + "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter/adaptertest" ) func TestNew(t *testing.T) { @@ -22,9 +22,9 @@ func TestNew(t *testing.T) { // TestYoloArgs locks in claude's full-access flag exactly. A drift here // silently changes whether dispatched sessions run with permissions skipped. func TestYoloArgs(t *testing.T) { - ta, ok := New(nil).(*adapter.TmuxAgent) + ta, ok := New(nil).(*adapter.Agent) if !ok { - t.Fatalf("expected *adapter.TmuxAgent") + t.Fatalf("expected *adapter.Agent") } if got, want := ta.YoloArgs, []string{"--dangerously-skip-permissions"}; !reflect.DeepEqual(got, want) { t.Fatalf("YoloArgs = %v, want %v", got, want) @@ -35,9 +35,9 @@ func TestYoloArgs(t *testing.T) { // SkipPromptOnResume. Without this wiring, picking "Resume" on a claude row // would relaunch a fresh agent (no --continue) AND re-fire the original prompt. func TestNewWiresSessionArgs(t *testing.T) { - ta, ok := New(nil).(*adapter.TmuxAgent) + ta, ok := New(nil).(*adapter.Agent) if !ok { - t.Fatalf("expected *adapter.TmuxAgent") + t.Fatalf("expected *adapter.Agent") } if ta.SessionArgs == nil { t.Fatal("expected SessionArgs to be wired") @@ -51,80 +51,130 @@ func TestNewWiresSessionArgs(t *testing.T) { // row must use claude's --continue (resume last session) and must NOT replay // the original prompt into the restored session, nor pass the uam UUID. func TestResumeAppendsContinueAndDoesNotReplayPrompt(t *testing.T) { - a, logPath := newTestClaudeAdapter(t) + a, be := newTestClaudeAdapter(t) resumable, ok := a.(adapter.ResumableAdapter) if !ok { t.Fatal("claude adapter should be resumable") } - _, err := resumable.Resume(context.Background(), adapter.ResumeRequest{ID: "abc12345-dead-beef-cafe-0123456789ab", Prompt: "fix parser", Cwd: "/tmp", Mode: "yolo", TmuxSession: "uam-claude-abc12345"}) + _, err := resumable.Resume(context.Background(), adapter.ResumeRequest{ID: "abc12345-dead-beef-cafe-0123456789ab", Prompt: "fix parser", Cwd: "/tmp", Mode: "yolo", SessionName: "uam-claude-abc12345"}) if err != nil { t.Fatalf("Resume: %v", err) } - logText := readLog(t, logPath) - if !strings.Contains(logText, "claude --dangerously-skip-permissions --continue") { - t.Fatalf("claude resume should append --continue: %s", logText) + argv := be.CommandLog() + if !strings.Contains(argv, "claude --dangerously-skip-permissions --continue") { + t.Fatalf("claude resume should append --continue: %s", argv) } // The uam UUID may appear in the UAM_ID env var, but must never be passed // as a flag argument to claude (no --resume / --continue ). - if strings.Contains(logText, "--continue abc12345-dead-beef-cafe-0123456789ab") || - strings.Contains(logText, "--resume") { - t.Fatalf("claude resume must not pass the uam UUID as a flag arg: %s", logText) + if strings.Contains(argv, "--continue abc12345-dead-beef-cafe-0123456789ab") || + strings.Contains(argv, "--resume") { + t.Fatalf("claude resume must not pass the uam UUID as a flag arg: %s", argv) } - // `send-keys -t` is the prompt-injection form; the mouse copy/paste config - // bindings legitimately contain `send-keys -X`/`-M`, so match the targeted - // form (and the prompt text) rather than the bare substring. - if strings.Contains(logText, "send-keys -t") || strings.Contains(logText, "fix parser") { - t.Fatalf("resume should not replay the original prompt: %s", logText) + if sends := be.CallsOf("send"); len(sends) != 0 { + t.Fatalf("resume should not replay the original prompt: %+v", sends) } } // TestDispatchUnchanged_sendsPromptNoContinue: dispatch keeps its byte-identical // argv (no --continue) and still sends the prompt. func TestDispatchUnchanged_sendsPromptNoContinue(t *testing.T) { - a, logPath := newTestClaudeAdapter(t) + a, be := newTestClaudeAdapter(t) _, err := a.Dispatch(context.Background(), adapter.DispatchRequest{Prompt: "fix parser", Cwd: "/tmp", Mode: "yolo"}) if err != nil { t.Fatalf("Dispatch: %v", err) } - logText := readLog(t, logPath) - if strings.Contains(logText, "--continue") { - t.Fatalf("dispatch must not append --continue: %s", logText) + if argv := be.CommandLog(); strings.Contains(argv, "--continue") { + t.Fatalf("dispatch must not append --continue: %s", argv) } - if !strings.Contains(logText, "send-keys") || !strings.Contains(logText, "fix parser") { - t.Fatalf("dispatch should send the prompt: %s", logText) + sends := be.CallsOf("send") + if len(sends) != 1 || sends[0].Text != "fix parser" { + t.Fatalf("dispatch should send the prompt: %+v", sends) } } -func newTestClaudeAdapter(t *testing.T) (adapter.AgentAdapter, string) { +func newTestClaudeAdapter(t *testing.T) (adapter.AgentAdapter, *adaptertest.Backend) { t.Helper() dir := t.TempDir() - writeExecutable(t, filepath.Join(dir, "claude"), "#!/bin/sh\nexit 0\n") - tmuxPath := filepath.Join(dir, "tmux") - writeExecutable(t, tmuxPath, `#!/bin/sh -printf '%s\n' "$*" >> "$TMUX_LOG" -exit 0 -`) + writeExecutable(t, filepath.Join(dir, "claude")) t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) - logPath := filepath.Join(dir, "tmux.log") - t.Setenv("TMUX_LOG", logPath) - - client := tmux.New("uam") - client.Executable = tmuxPath - return New(client), logPath + be := &adaptertest.Backend{} + return New(be), be } -func readLog(t *testing.T, path string) string { +func writeExecutable(t *testing.T, path string) { t.Helper() - logData, err := os.ReadFile(path) - if err != nil { + if err := os.WriteFile(path, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { t.Fatal(err) } - return string(logData) } -func writeExecutable(t *testing.T, path, content string) { +// newSeedingClaudeAdapter installs a fake claude whose --help advertises +// --session-id, enabling the exact-session seeding path. +func newSeedingClaudeAdapter(t *testing.T) (adapter.AgentAdapter, *adaptertest.Backend) { t.Helper() - if err := os.WriteFile(path, []byte(content), 0o755); err != nil { + dir := t.TempDir() + script := "#!/bin/sh\nif [ \"$1\" = \"--help\" ]; then echo ' --session-id Use a specific session ID'; fi\nexit 0\n" + if err := os.WriteFile(filepath.Join(dir, "claude"), []byte(script), 0o755); err != nil { t.Fatal(err) } + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + be := &adaptertest.Backend{} + return New(be), be +} + +// Dispatch must seed claude's session id with the uam UUID when the installed +// claude supports --session-id, and record it as the provider session id so a +// later resume can target the exact conversation. +func TestDispatchSeedsSessionIDWhenSupported(t *testing.T) { + a, be := newSeedingClaudeAdapter(t) + sess, err := a.Dispatch(context.Background(), adapter.DispatchRequest{Cwd: "/tmp", Mode: "yolo"}) + if err != nil { + t.Fatalf("Dispatch: %v", err) + } + argv := be.CommandLog() + if !strings.Contains(argv, "--session-id "+sess.ID) { + t.Fatalf("dispatch should seed --session-id with the uam id: %s", argv) + } + if sess.ProviderSessionID != sess.ID { + t.Fatalf("ProviderSessionID = %q, want the seeded uam id %q", sess.ProviderSessionID, sess.ID) + } +} + +// An older claude whose --help does not advertise --session-id must get the +// bare argv (no unknown flag that would kill the agent at startup). +func TestDispatchSkipsSessionIDWhenUnsupported(t *testing.T) { + a, be := newTestClaudeAdapter(t) + sess, err := a.Dispatch(context.Background(), adapter.DispatchRequest{Cwd: "/tmp", Mode: "yolo"}) + if err != nil { + t.Fatalf("Dispatch: %v", err) + } + if argv := be.CommandLog(); strings.Contains(argv, "--session-id") { + t.Fatalf("unsupported claude must not receive --session-id: %s", argv) + } + if sess.ProviderSessionID != "" { + t.Fatalf("ProviderSessionID = %q, want empty without seeding", sess.ProviderSessionID) + } +} + +// A record carrying a seeded provider session id must resume that EXACT +// session (--resume ), not the cwd's most recent conversation +// (--continue) — two uam sessions in one directory must not collapse into the +// same claude conversation on resume. +func TestResumeTargetsExactSeededSession(t *testing.T) { + a, be := newSeedingClaudeAdapter(t) + resumable := a.(adapter.ResumableAdapter) + _, err := resumable.Resume(context.Background(), adapter.ResumeRequest{ + ID: "abc12345-dead-beef-cafe-0123456789ab", Cwd: "/tmp", Mode: "yolo", + SessionName: "uam-claude-abc12345", ProviderSessionID: "abc12345-dead-beef-cafe-0123456789ab", + }) + if err != nil { + t.Fatalf("Resume: %v", err) + } + argv := be.CommandLog() + if !strings.Contains(argv, "--resume abc12345-dead-beef-cafe-0123456789ab") { + t.Fatalf("resume should target the seeded session id: %s", argv) + } + if strings.Contains(argv, "--continue") { + t.Fatalf("exact resume must not fall back to --continue: %s", argv) + } } diff --git a/internal/adapter/codex/codex.go b/internal/adapter/codex/codex.go index 5d8a8f4..d424d4c 100644 --- a/internal/adapter/codex/codex.go +++ b/internal/adapter/codex/codex.go @@ -2,7 +2,6 @@ package codex import ( "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter" - "github.com/RandomCodeSpace/unified-agent-manager/internal/tmux" ) // sessionArgs appends codex's `resume --last` subcommand on resume so picking @@ -17,8 +16,8 @@ func sessionArgs(_ adapter.ResumeRequest, activity string) []string { return nil } -func New(client *tmux.Client) adapter.AgentAdapter { - a := adapter.NewTmuxAgent("codex", "OpenAI Codex", []adapter.CommandCandidate{{Display: "codex", Args: []string{"codex"}}}, []string{"--sandbox", "danger-full-access"}, client) +func New(backend adapter.Backend) adapter.AgentAdapter { + a := adapter.NewAgent("codex", "OpenAI Codex", []adapter.CommandCandidate{{Display: "codex", Args: []string{"codex"}}}, []string{"--sandbox", "danger-full-access"}, backend) a.SessionArgs = sessionArgs a.SkipPromptOnResume = true return a diff --git a/internal/adapter/codex/codex_test.go b/internal/adapter/codex/codex_test.go index b70e116..d75dd45 100644 --- a/internal/adapter/codex/codex_test.go +++ b/internal/adapter/codex/codex_test.go @@ -9,7 +9,7 @@ import ( "testing" "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter" - "github.com/RandomCodeSpace/unified-agent-manager/internal/tmux" + "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter/adaptertest" ) func TestNew(t *testing.T) { @@ -22,9 +22,9 @@ func TestNew(t *testing.T) { // TestYoloArgs locks in codex's full-access flags exactly. A drift here // silently changes the sandbox posture of dispatched sessions. func TestYoloArgs(t *testing.T) { - ta, ok := New(nil).(*adapter.TmuxAgent) + ta, ok := New(nil).(*adapter.Agent) if !ok { - t.Fatalf("expected *adapter.TmuxAgent") + t.Fatalf("expected *adapter.Agent") } if got, want := ta.YoloArgs, []string{"--sandbox", "danger-full-access"}; !reflect.DeepEqual(got, want) { t.Fatalf("YoloArgs = %v, want %v", got, want) @@ -35,9 +35,9 @@ func TestYoloArgs(t *testing.T) { // SkipPromptOnResume. Without this wiring, picking "Resume" on a codex row // would relaunch a fresh agent (no resume) AND re-fire the original prompt. func TestNewWiresSessionArgs(t *testing.T) { - ta, ok := New(nil).(*adapter.TmuxAgent) + ta, ok := New(nil).(*adapter.Agent) if !ok { - t.Fatalf("expected *adapter.TmuxAgent") + t.Fatalf("expected *adapter.Agent") } if ta.SessionArgs == nil { t.Fatal("expected SessionArgs to be wired") @@ -51,80 +51,59 @@ func TestNewWiresSessionArgs(t *testing.T) { // row must use codex's `resume --last` and must NOT replay the original prompt // into the restored session, nor pass the uam UUID. func TestResumeAppendsResumeLastAndDoesNotReplayPrompt(t *testing.T) { - a, logPath := newTestCodexAdapter(t) + a, be := newTestCodexAdapter(t) resumable, ok := a.(adapter.ResumableAdapter) if !ok { t.Fatal("codex adapter should be resumable") } - _, err := resumable.Resume(context.Background(), adapter.ResumeRequest{ID: "abc12345-dead-beef-cafe-0123456789ab", Prompt: "fix parser", Cwd: "/tmp", Mode: "yolo", TmuxSession: "uam-codex-abc12345"}) + _, err := resumable.Resume(context.Background(), adapter.ResumeRequest{ID: "abc12345-dead-beef-cafe-0123456789ab", Prompt: "fix parser", Cwd: "/tmp", Mode: "yolo", SessionName: "uam-codex-abc12345"}) if err != nil { t.Fatalf("Resume: %v", err) } - logText := readLog(t, logPath) - if !strings.Contains(logText, "codex --sandbox danger-full-access resume --last") { - t.Fatalf("codex resume should append resume --last: %s", logText) + argv := be.CommandLog() + if !strings.Contains(argv, "codex --sandbox danger-full-access resume --last") { + t.Fatalf("codex resume should append resume --last: %s", argv) } // The uam UUID may appear in the UAM_ID env var, but must never be passed // as a flag argument to codex (no resume / --resume ). - if strings.Contains(logText, "resume --last abc12345-dead-beef-cafe-0123456789ab") || - strings.Contains(logText, "--resume") { - t.Fatalf("codex resume must not pass the uam UUID as a flag arg: %s", logText) + if strings.Contains(argv, "resume --last abc12345-dead-beef-cafe-0123456789ab") || + strings.Contains(argv, "--resume") { + t.Fatalf("codex resume must not pass the uam UUID as a flag arg: %s", argv) } - // `send-keys -t` is the prompt-injection form; the mouse copy/paste config - // bindings legitimately contain `send-keys -X`/`-M`, so match the targeted - // form (and the prompt text) rather than the bare substring. - if strings.Contains(logText, "send-keys -t") || strings.Contains(logText, "fix parser") { - t.Fatalf("resume should not replay the original prompt: %s", logText) + if sends := be.CallsOf("send"); len(sends) != 0 { + t.Fatalf("resume should not replay the original prompt: %+v", sends) } } // TestDispatchUnchanged_sendsPromptNoResume: dispatch keeps its byte-identical // argv (no resume) and still sends the prompt. func TestDispatchUnchanged_sendsPromptNoResume(t *testing.T) { - a, logPath := newTestCodexAdapter(t) + a, be := newTestCodexAdapter(t) _, err := a.Dispatch(context.Background(), adapter.DispatchRequest{Prompt: "fix parser", Cwd: "/tmp", Mode: "yolo"}) if err != nil { t.Fatalf("Dispatch: %v", err) } - logText := readLog(t, logPath) - if strings.Contains(logText, "resume --last") { - t.Fatalf("dispatch must not append resume --last: %s", logText) + if argv := be.CommandLog(); strings.Contains(argv, "resume --last") { + t.Fatalf("dispatch must not append resume --last: %s", argv) } - if !strings.Contains(logText, "send-keys") || !strings.Contains(logText, "fix parser") { - t.Fatalf("dispatch should send the prompt: %s", logText) + sends := be.CallsOf("send") + if len(sends) != 1 || sends[0].Text != "fix parser" { + t.Fatalf("dispatch should send the prompt: %+v", sends) } } -func newTestCodexAdapter(t *testing.T) (adapter.AgentAdapter, string) { +func newTestCodexAdapter(t *testing.T) (adapter.AgentAdapter, *adaptertest.Backend) { t.Helper() dir := t.TempDir() - writeExecutable(t, filepath.Join(dir, "codex"), "#!/bin/sh\nexit 0\n") - tmuxPath := filepath.Join(dir, "tmux") - writeExecutable(t, tmuxPath, `#!/bin/sh -printf '%s\n' "$*" >> "$TMUX_LOG" -exit 0 -`) + writeExecutable(t, filepath.Join(dir, "codex")) t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) - logPath := filepath.Join(dir, "tmux.log") - t.Setenv("TMUX_LOG", logPath) - - client := tmux.New("uam") - client.Executable = tmuxPath - return New(client), logPath -} - -func readLog(t *testing.T, path string) string { - t.Helper() - logData, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - return string(logData) + be := &adaptertest.Backend{} + return New(be), be } -func writeExecutable(t *testing.T, path, content string) { +func writeExecutable(t *testing.T, path string) { t.Helper() - if err := os.WriteFile(path, []byte(content), 0o755); err != nil { + if err := os.WriteFile(path, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { t.Fatal(err) } } diff --git a/internal/adapter/copilot/copilot.go b/internal/adapter/copilot/copilot.go index 529a52a..1c9aaa2 100644 --- a/internal/adapter/copilot/copilot.go +++ b/internal/adapter/copilot/copilot.go @@ -2,11 +2,13 @@ package copilot import ( "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter" - "github.com/RandomCodeSpace/unified-agent-manager/internal/tmux" ) -func New(client *tmux.Client) adapter.AgentAdapter { - agent := adapter.NewTmuxAgent("copilot", "GitHub Copilot", []adapter.CommandCandidate{{Display: "copilot", Args: []string{"copilot"}}}, []string{"--yolo"}, client) +func New(backend adapter.Backend) adapter.AgentAdapter { + agent := adapter.NewAgent("copilot", "GitHub Copilot", []adapter.CommandCandidate{{Display: "copilot", Args: []string{"copilot"}}}, []string{"--yolo"}, backend) + // copilot supports exact-session resume natively: --name seeds the new + // session's name with the uam id at dispatch, and --resume matches it + // exactly (case-insensitive) on resume. agent.SessionArgs = func(req adapter.ResumeRequest, activity string) []string { if req.ID == "" { return nil @@ -16,6 +18,14 @@ func New(client *tmux.Client) adapter.AgentAdapter { } return []string{"--resume=" + req.ID} } + // Record the seeded name as the provider session id so the store reflects + // what resume will target (parity with the claude adapter). + agent.ProviderSession = func(req adapter.ResumeRequest, activity string) string { + if req.ID != "" { + return req.ID + } + return req.ProviderSessionID + } agent.SkipPromptOnResume = true return agent } diff --git a/internal/adapter/copilot/copilot_test.go b/internal/adapter/copilot/copilot_test.go index e71c60c..9dac94f 100644 --- a/internal/adapter/copilot/copilot_test.go +++ b/internal/adapter/copilot/copilot_test.go @@ -8,7 +8,7 @@ import ( "testing" "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter" - "github.com/RandomCodeSpace/unified-agent-manager/internal/tmux" + "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter/adaptertest" ) func TestNew(t *testing.T) { @@ -19,107 +19,86 @@ func TestNew(t *testing.T) { } func TestAvailableRequiresCopilotBinary(t *testing.T) { - dir := t.TempDir() - writeExecutable(t, filepath.Join(dir, "gh"), "#!/bin/sh\nexit 0\n") - t.Setenv("PATH", dir) - + t.Setenv("PATH", t.TempDir()) a := New(nil) - available, reason := a.Available() - if available { - t.Fatal("copilot adapter should not fall back to gh copilot") - } - if !strings.Contains(reason, "copilot not on PATH") { - t.Fatalf("unexpected unavailable reason: %q", reason) + if ok, reason := a.Available(); ok || reason == "" { + t.Fatalf("Available = %v %q, want unavailable with reason", ok, reason) } } func TestYoloModeUsesYoloFlag(t *testing.T) { - a, logPath := newTestCopilotAdapter(t) + a, be := newTestCopilotAdapter(t) _, err := a.Dispatch(context.Background(), adapter.DispatchRequest{Cwd: "/tmp", Mode: "yolo"}) if err != nil { t.Fatalf("Dispatch: %v", err) } - logText := readLog(t, logPath) - if !strings.Contains(logText, "copilot --yolo") { - t.Fatalf("copilot yolo mode should use --yolo: %s", logText) + argv := be.CommandLog() + if !strings.Contains(argv, "copilot --yolo") { + t.Fatalf("copilot yolo mode should use --yolo: %s", argv) } - if strings.Contains(logText, "--autopilot") { - t.Fatalf("copilot yolo mode should not use --autopilot: %s", logText) + if strings.Contains(argv, "--autopilot") { + t.Fatalf("copilot yolo mode should not use --autopilot: %s", argv) } } func TestDispatchSeedsCopilotSessionIDForFutureResume(t *testing.T) { - a, logPath := newTestCopilotAdapter(t) + a, be := newTestCopilotAdapter(t) sess, err := a.Dispatch(context.Background(), adapter.DispatchRequest{Prompt: "fix parser", Cwd: "/tmp", Mode: "yolo"}) if err != nil { t.Fatalf("Dispatch: %v", err) } - - logText := readLog(t, logPath) - if !strings.Contains(logText, "--name "+sess.ID) { - t.Fatalf("copilot dispatch should name the provider session with the UAM id: %s", logText) + argv := be.CommandLog() + if !strings.Contains(argv, "--name "+sess.ID) { + t.Fatalf("copilot dispatch should name the provider session with the UAM id: %s", argv) } - if strings.Contains(logText, "--resume=") { - t.Fatalf("initial dispatch should not try to resume a new Copilot session: %s", logText) + if strings.Contains(argv, "--resume=") { + t.Fatalf("initial dispatch should not try to resume a new Copilot session: %s", argv) } - if !strings.Contains(logText, "send-keys") || !strings.Contains(logText, "fix parser") { - t.Fatalf("initial dispatch should still send the prompt: %s", logText) + sends := be.CallsOf("send") + if len(sends) != 1 || sends[0].Text != "fix parser" { + t.Fatalf("initial dispatch should still send the prompt: %+v", sends) } } func TestResumeUsesCopilotSessionIDAndDoesNotReplayPrompt(t *testing.T) { - a, logPath := newTestCopilotAdapter(t) + a, be := newTestCopilotAdapter(t) resumable, ok := a.(adapter.ResumableAdapter) if !ok { t.Fatal("copilot adapter should be resumable") } - _, err := resumable.Resume(context.Background(), adapter.ResumeRequest{ID: "abc12345-dead-beef-cafe-0123456789ab", Prompt: "fix parser", Cwd: "/tmp", Mode: "yolo", TmuxSession: "uam-copilot-abc12345"}) + _, err := resumable.Resume(context.Background(), adapter.ResumeRequest{ID: "abc12345-dead-beef-cafe-0123456789ab", Prompt: "fix parser", Cwd: "/tmp", Mode: "yolo", SessionName: "uam-copilot-abc12345"}) if err != nil { t.Fatalf("Resume: %v", err) } - - logText := readLog(t, logPath) - if !strings.Contains(logText, "copilot --yolo --resume=abc12345-dead-beef-cafe-0123456789ab") { - t.Fatalf("copilot resume should pass the persisted provider session id: %s", logText) + argv := be.CommandLog() + if !strings.Contains(argv, "copilot --yolo --resume=abc12345-dead-beef-cafe-0123456789ab") { + t.Fatalf("copilot resume should pass the persisted provider session id: %s", argv) } - // `send-keys -t` is the prompt-injection form; the mouse copy/paste config - // bindings legitimately contain `send-keys -X`/`-M`, so match the targeted - // form (and the prompt text) rather than the bare substring. - if strings.Contains(logText, "send-keys -t") || strings.Contains(logText, "fix parser") { - t.Fatalf("resume should not replay the original prompt into the restored session: %s", logText) + if sends := be.CallsOf("send"); len(sends) != 0 { + t.Fatalf("resume should not replay the original prompt into the restored session: %+v", sends) } } -func newTestCopilotAdapter(t *testing.T) (adapter.AgentAdapter, string) { +func newTestCopilotAdapter(t *testing.T) (adapter.AgentAdapter, *adaptertest.Backend) { t.Helper() dir := t.TempDir() - writeExecutable(t, filepath.Join(dir, "copilot"), "#!/bin/sh\nexit 0\n") - tmuxPath := filepath.Join(dir, "tmux") - writeExecutable(t, tmuxPath, `#!/bin/sh -printf '%s\n' "$*" >> "$TMUX_LOG" -exit 0 -`) + if err := os.WriteFile(filepath.Join(dir, "copilot"), []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatal(err) + } t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) - logPath := filepath.Join(dir, "tmux.log") - t.Setenv("TMUX_LOG", logPath) - - client := tmux.New("uam") - client.Executable = tmuxPath - return New(client), logPath + be := &adaptertest.Backend{} + return New(be), be } -func readLog(t *testing.T, path string) string { - t.Helper() - logData, err := os.ReadFile(path) +// Dispatch must record the seeded session name as the provider session id so +// the store reflects exactly what --resume will target. +func TestDispatchRecordsProviderSessionID(t *testing.T) { + a, _ := newTestCopilotAdapter(t) + sess, err := a.Dispatch(context.Background(), adapter.DispatchRequest{Cwd: "/tmp", Mode: "yolo"}) if err != nil { - t.Fatal(err) + t.Fatalf("Dispatch: %v", err) } - return string(logData) -} - -func writeExecutable(t *testing.T, path, content string) { - t.Helper() - if err := os.WriteFile(path, []byte(content), 0o755); err != nil { - t.Fatal(err) + if sess.ProviderSessionID != sess.ID { + t.Fatalf("ProviderSessionID = %q, want the uam id %q", sess.ProviderSessionID, sess.ID) } } diff --git a/internal/adapter/cwd_abs_test.go b/internal/adapter/cwd_abs_test.go index 3d3d71e..8c824a1 100644 --- a/internal/adapter/cwd_abs_test.go +++ b/internal/adapter/cwd_abs_test.go @@ -4,37 +4,28 @@ import ( "context" "os" "path/filepath" - "strings" "testing" - "github.com/RandomCodeSpace/unified-agent-manager/internal/tmux" + "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter/adaptertest" ) // C2-4 — a relative --cwd must be resolved to an absolute path exactly once in -// startSession, before BOTH CreateSession (the tmux -c arg) and the returned -// Session.Cwd. Otherwise the relative path is persisted verbatim and a later -// resume re-resolves it against uam's process cwd, launching the agent in the -// wrong directory. -func TestTmuxAgentDispatchRelativeCwdIsNormalized(t *testing.T) { +// startSession, before BOTH CreateSession and the returned Session.Cwd. +// Otherwise the relative path is persisted verbatim and a later resume +// re-resolves it against uam's process cwd, launching the agent in the wrong +// directory. +func TestAgentDispatchRelativeCwdIsNormalized(t *testing.T) { dir := t.TempDir() writeExecutable(t, filepath.Join(dir, "fakeagent"), "#!/bin/sh\nexit 0\n") - tmuxPath := filepath.Join(dir, "tmux") - writeExecutable(t, tmuxPath, `#!/bin/sh -printf '%s\n' "$*" >> "$TMUX_LOG" -exit 0 -`) t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) - logPath := filepath.Join(dir, "tmux.log") - t.Setenv("TMUX_LOG", logPath) // Run from a known directory so filepath.Abs has a deterministic base. base := t.TempDir() t.Chdir(base) wantAbs := filepath.Join(base, "sub", "project") - client := tmux.New("uam") - client.Executable = tmuxPath - ag := NewTmuxAgent("fake", "Fake Agent", []CommandCandidate{{Display: "fakeagent", Args: []string{"fakeagent"}}}, []string{"--yolo"}, client) + be := &adaptertest.Backend{} + ag := NewAgent("fake", "Fake Agent", []CommandCandidate{{Display: "fakeagent", Args: []string{"fakeagent"}}}, []string{"--yolo"}, be) sess, err := ag.Dispatch(context.Background(), DispatchRequest{Prompt: "hi", Cwd: "sub/project", Mode: "yolo"}) if err != nil { @@ -43,11 +34,8 @@ exit 0 if sess.Cwd != wantAbs { t.Fatalf("returned Session.Cwd = %q, want absolute %q", sess.Cwd, wantAbs) } - logText := func() string { b, _ := os.ReadFile(logPath); return string(b) }() - if !strings.Contains(logText, "-c "+wantAbs) { - t.Fatalf("tmux new-session -c arg must be the absolute cwd %q, log: %s", wantAbs, logText) - } - if strings.Contains(logText, "-c sub/project") { - t.Fatalf("relative cwd must not reach tmux verbatim, log: %s", logText) + creates := be.CallsOf("create") + if len(creates) != 1 || creates[0].Cwd != wantAbs { + t.Fatalf("CreateSession cwd must be the absolute path %q, got %+v", wantAbs, creates) } } diff --git a/internal/adapter/hermes/hermes.go b/internal/adapter/hermes/hermes.go index 81f4a10..e9173f8 100644 --- a/internal/adapter/hermes/hermes.go +++ b/internal/adapter/hermes/hermes.go @@ -2,7 +2,6 @@ package hermes import ( "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter" - "github.com/RandomCodeSpace/unified-agent-manager/internal/tmux" ) // hermes is launched bare. `--tui` fails to start the agent, and like @@ -11,6 +10,6 @@ import ( // session list. Launch as plain `hermes` until a real flag is confirmed. var yoloArgs []string -func New(client *tmux.Client) adapter.AgentAdapter { - return adapter.NewTmuxAgent("hermes", "Hermes Agent", []adapter.CommandCandidate{{Display: "hermes", Args: []string{"hermes"}}}, yoloArgs, client) +func New(backend adapter.Backend) adapter.AgentAdapter { + return adapter.NewAgent("hermes", "Hermes Agent", []adapter.CommandCandidate{{Display: "hermes", Args: []string{"hermes"}}}, yoloArgs, backend) } diff --git a/internal/adapter/hermes/hermes_test.go b/internal/adapter/hermes/hermes_test.go index 8e0581c..47ca5be 100644 --- a/internal/adapter/hermes/hermes_test.go +++ b/internal/adapter/hermes/hermes_test.go @@ -9,22 +9,22 @@ import ( func TestNewUsesBareHermesCommand(t *testing.T) { a := New(nil) - tmuxAgent, ok := a.(*adapter.TmuxAgent) + ag, ok := a.(*adapter.Agent) if !ok { t.Fatalf("adapter type = %T", a) } - if tmuxAgent.Name() != "hermes" || tmuxAgent.DisplayName() != "Hermes Agent" { - t.Fatalf("bad adapter names: %q %q", tmuxAgent.Name(), tmuxAgent.DisplayName()) + if ag.Name() != "hermes" || ag.DisplayName() != "Hermes Agent" { + t.Fatalf("bad adapter names: %q %q", ag.Name(), ag.DisplayName()) } - if len(tmuxAgent.Candidates) != 1 { - t.Fatalf("candidates = %+v", tmuxAgent.Candidates) + if len(ag.Candidates) != 1 { + t.Fatalf("candidates = %+v", ag.Candidates) } // Launched bare: no --tui (fails to start) and no --yolo (unknown flag // kills the pane, same as opencode). - if got, want := tmuxAgent.Candidates[0].Args, []string{"hermes"}; !reflect.DeepEqual(got, want) { + if got, want := ag.Candidates[0].Args, []string{"hermes"}; !reflect.DeepEqual(got, want) { t.Fatalf("candidate args = %v, want %v", got, want) } - if len(tmuxAgent.YoloArgs) != 0 { - t.Fatalf("yolo args = %v, want none", tmuxAgent.YoloArgs) + if len(ag.YoloArgs) != 0 { + t.Fatalf("yolo args = %v, want none", ag.YoloArgs) } } diff --git a/internal/adapter/omp/omp.go b/internal/adapter/omp/omp.go index fad7517..688d570 100644 --- a/internal/adapter/omp/omp.go +++ b/internal/adapter/omp/omp.go @@ -2,7 +2,6 @@ package omp import ( "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter" - "github.com/RandomCodeSpace/unified-agent-manager/internal/tmux" ) // omp (Oh My Pi, github.com/can1357/oh-my-pi) launches bare: a plain `omp` @@ -27,8 +26,8 @@ func sessionArgs(_ adapter.ResumeRequest, activity string) []string { return nil } -func New(client *tmux.Client) adapter.AgentAdapter { - a := adapter.NewTmuxAgent("omp", "Oh My Pi", []adapter.CommandCandidate{{Display: "omp", Args: []string{"omp"}}}, yoloArgs, client) +func New(backend adapter.Backend) adapter.AgentAdapter { + a := adapter.NewAgent("omp", "Oh My Pi", []adapter.CommandCandidate{{Display: "omp", Args: []string{"omp"}}}, yoloArgs, backend) a.SessionArgs = sessionArgs a.SkipPromptOnResume = true return a diff --git a/internal/adapter/omp/omp_test.go b/internal/adapter/omp/omp_test.go index 0684620..3512661 100644 --- a/internal/adapter/omp/omp_test.go +++ b/internal/adapter/omp/omp_test.go @@ -18,9 +18,9 @@ func TestNew(t *testing.T) { // subcommand); the auto-approve flag rides in YoloArgs, not the candidate. func TestNewUsesBareOmpCommand(t *testing.T) { got := New(nil) - ta, ok := got.(*adapter.TmuxAgent) + ta, ok := got.(*adapter.Agent) if !ok { - t.Fatalf("expected *adapter.TmuxAgent, got %T", got) + t.Fatalf("expected *adapter.Agent, got %T", got) } if len(ta.Candidates) != 1 { t.Fatalf("candidates = %+v", ta.Candidates) @@ -35,9 +35,9 @@ func TestNewUsesBareOmpCommand(t *testing.T) { // tool-call approval — matching claude/codex/copilot. func TestYoloArgsUsesAutoApprove(t *testing.T) { got := New(nil) - ta, ok := got.(*adapter.TmuxAgent) + ta, ok := got.(*adapter.Agent) if !ok { - t.Fatalf("expected *adapter.TmuxAgent, got %T", got) + t.Fatalf("expected *adapter.Agent, got %T", got) } if want := []string{"--auto-approve"}; !reflect.DeepEqual(ta.YoloArgs, want) { t.Fatalf("YoloArgs = %v, want %v", ta.YoloArgs, want) @@ -59,9 +59,9 @@ func TestSessionArgsAppendsContinueOnResume(t *testing.T) { // "Resume" continues omp's prior session instead of starting a fresh TUI. func TestNewWiresSessionArgs(t *testing.T) { got := New(nil) - ta, ok := got.(*adapter.TmuxAgent) + ta, ok := got.(*adapter.Agent) if !ok { - t.Fatalf("expected *adapter.TmuxAgent, got %T", got) + t.Fatalf("expected *adapter.Agent, got %T", got) } if ta.SessionArgs == nil { t.Fatal("expected SessionArgs to be wired") diff --git a/internal/adapter/opencode/opencode.go b/internal/adapter/opencode/opencode.go index 25863c1..5e662b7 100644 --- a/internal/adapter/opencode/opencode.go +++ b/internal/adapter/opencode/opencode.go @@ -2,7 +2,6 @@ package opencode import ( "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter" - "github.com/RandomCodeSpace/unified-agent-manager/internal/tmux" ) // opencode has no CLI flag for auto-approval / yolo: permission @@ -21,15 +20,22 @@ var yoloArgs []string // opencode rows share a cwd, all of them resume to the same // most-recent session — a limitation of opencode's CLI surface, not // of this wiring. -func sessionArgs(_ adapter.ResumeRequest, activity string) []string { +// sessionArgs picks opencode's resume flags. opencode supports exact resume +// (`--session ses_...`) but cannot preset the id at launch, so uam only knows +// it when a provider session id was recorded some other way; otherwise resume +// falls back to `-c` (continue the project's last session). +func sessionArgs(req adapter.ResumeRequest, activity string) []string { if activity == "resumed" { + if req.ProviderSessionID != "" { + return []string{"--session", req.ProviderSessionID} + } return []string{"-c"} } return nil } -func New(client *tmux.Client) adapter.AgentAdapter { - a := adapter.NewTmuxAgent("opencode", "OpenCode", []adapter.CommandCandidate{{Display: "opencode", Args: []string{"opencode"}}}, yoloArgs, client) +func New(backend adapter.Backend) adapter.AgentAdapter { + a := adapter.NewAgent("opencode", "OpenCode", []adapter.CommandCandidate{{Display: "opencode", Args: []string{"opencode"}}}, yoloArgs, backend) a.SessionArgs = sessionArgs a.SkipPromptOnResume = true return a diff --git a/internal/adapter/opencode/opencode_test.go b/internal/adapter/opencode/opencode_test.go index 9bbb6e7..03e5a1e 100644 --- a/internal/adapter/opencode/opencode_test.go +++ b/internal/adapter/opencode/opencode_test.go @@ -21,9 +21,9 @@ func TestNew(t *testing.T) { // pane on dispatch. Regression guard. func TestNoYoloArgs(t *testing.T) { got := New(nil) - ta, ok := got.(*adapter.TmuxAgent) + ta, ok := got.(*adapter.Agent) if !ok { - t.Fatalf("expected *adapter.TmuxAgent, got %T", got) + t.Fatalf("expected *adapter.Agent, got %T", got) } if len(ta.YoloArgs) != 0 { t.Fatalf("opencode YoloArgs must be empty, got %v", ta.YoloArgs) @@ -50,9 +50,9 @@ func TestSessionArgsAppendsContinueOnResume(t *testing.T) { // flag, starting a fresh TUI instead of resuming the prior session. func TestNewWiresSessionArgs(t *testing.T) { got := New(nil) - ta, ok := got.(*adapter.TmuxAgent) + ta, ok := got.(*adapter.Agent) if !ok { - t.Fatalf("expected *adapter.TmuxAgent, got %T", got) + t.Fatalf("expected *adapter.Agent, got %T", got) } if ta.SessionArgs == nil { t.Fatal("expected SessionArgs to be wired") @@ -61,3 +61,19 @@ func TestNewWiresSessionArgs(t *testing.T) { t.Fatal("expected SkipPromptOnResume to be true") } } + +// A recorded provider session id must resume that exact opencode session +// (--session ses_...) instead of the project's most recent (-c). +func TestResumeTargetsExactSessionWhenIDKnown(t *testing.T) { + ag, ok := New(nil).(*adapter.Agent) + if !ok { + t.Fatalf("expected *adapter.Agent") + } + got := ag.SessionArgs(adapter.ResumeRequest{ProviderSessionID: "ses_2132323b6ffe"}, "resumed") + if len(got) != 2 || got[0] != "--session" || got[1] != "ses_2132323b6ffe" { + t.Fatalf("resume args = %v, want exact --session", got) + } + if got := ag.SessionArgs(adapter.ResumeRequest{}, "resumed"); len(got) != 1 || got[0] != "-c" { + t.Fatalf("resume args without id = %v, want -c fallback", got) + } +} diff --git a/internal/adapter/shell.go b/internal/adapter/shell.go new file mode 100644 index 0000000..3e55acd --- /dev/null +++ b/internal/adapter/shell.go @@ -0,0 +1,45 @@ +package adapter + +import "strings" + +// ShellJoin renders argv as a single /bin/sh command string with every value +// POSIX single-quote escaped. The native backend execs agent argv directly — +// no shell anywhere on the dispatch path — so this survives only for the +// command-alias fallback, where the user's interactive shell must resolve an +// alias/function that LookPath cannot see (shellAliasCommand). +func ShellJoin(args []string) string { + quoted := make([]string, 0, len(args)) + for _, arg := range args { + quoted = append(quoted, shellQuote(arg)) + } + return strings.Join(quoted, " ") +} + +func shellQuote(s string) string { + if s == "" { + return "''" + } + if strings.IndexFunc(s, func(r rune) bool { + return !isShellSafeRune(r) + }) == -1 { + return s + } + // POSIX single-quote escaping: wrap in single quotes and rewrite any + // embedded single quote as the close-reopen idiom '\''. Inside single + // quotes /bin/sh performs no expansion, so $(), ``, $VAR, and newlines + // all reach the command literally. + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" +} + +func isShellSafeRune(r rune) bool { + if r == '_' || r == '-' || r == '.' || r == '/' || r == ':' || r == '=' || r == '+' { + return true + } + if r >= '0' && r <= '9' { + return true + } + if r >= 'A' && r <= 'Z' { + return true + } + return r >= 'a' && r <= 'z' +} diff --git a/internal/adapter/tmux_adapter_test.go b/internal/adapter/tmux_adapter_test.go deleted file mode 100644 index d505aa6..0000000 --- a/internal/adapter/tmux_adapter_test.go +++ /dev/null @@ -1,634 +0,0 @@ -package adapter - -import ( - "bytes" - "context" - "errors" - "log/slog" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/RandomCodeSpace/unified-agent-manager/internal/log" - "github.com/RandomCodeSpace/unified-agent-manager/internal/tmux" -) - -func TestTmuxAgentLifecycleWithFakeTmux(t *testing.T) { - ag, logPath := setupLifecycleAgent(t) - assertAgentAvailable(t, ag) - assertAgentDispatchAndList(t, ag) - assertAgentInteractions(t, ag) - assertTmuxLifecycleLog(t, logPath) -} - -func setupLifecycleAgent(t *testing.T) (*TmuxAgent, string) { - t.Helper() - dir := t.TempDir() - writeExecutable(t, filepath.Join(dir, "fakeagent"), "#!/bin/sh\nexit 0\n") - tmuxPath := filepath.Join(dir, "tmux") - writeExecutable(t, tmuxPath, `#!/bin/sh -printf '%s\n' "$*" >> "$TMUX_LOG" -case "$*" in - *"list-sessions"*) echo "uam-fake-abc12345|1710000000|0|1|/tmp/repo|fakeagent" ;; - *"capture-pane"*) printf 'Thinking...\ncreated https://github.com/o/r/pull/7\n' ;; -esac -exit 0 -`) - t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) - logPath := filepath.Join(dir, "tmux.log") - t.Setenv("TMUX_LOG", logPath) - client := tmux.New("uam") - client.Executable = tmuxPath - return NewTmuxAgent("fake", "Fake Agent", []CommandCandidate{{Display: "fakeagent", Args: []string{"fakeagent"}}}, []string{"--yolo"}, client), logPath -} - -func assertAgentAvailable(t *testing.T, ag *TmuxAgent) { - t.Helper() - if ok, reason := ag.Available(); !ok || reason != "" { - t.Fatalf("Available = %v %q", ok, reason) - } - if ag.Name() != "fake" || ag.DisplayName() != "Fake Agent" { - t.Fatalf("names wrong") - } -} - -func assertAgentDispatchAndList(t *testing.T, ag *TmuxAgent) { - t.Helper() - sess, err := ag.Dispatch(context.Background(), DispatchRequest{Prompt: "hello", Cwd: "/tmp", Mode: "yolo"}) - if err != nil { - t.Fatalf("Dispatch: %v", err) - } - if sess.AgentType != "fake" || sess.State != Active || sess.TmuxSession == "" { - t.Fatalf("bad session: %+v", sess) - } - list, err := ag.List(context.Background()) - if err != nil || len(list) != 1 { - t.Fatalf("List len=%d err=%v", len(list), err) - } - if list[0].PR == nil || list[0].PR.Number != 7 { - t.Fatalf("bad classified list: %+v", list[0]) - } -} - -func assertAgentInteractions(t *testing.T, ag *TmuxAgent) { - t.Helper() - peek, err := ag.Peek(context.Background(), "abc12345") - if err != nil || !strings.Contains(peek.TailText, "Thinking") { - t.Fatalf("Peek: %+v %v", peek, err) - } - for _, action := range []func() error{ - func() error { return ag.Reply(context.Background(), "abc12345", "ok") }, - func() error { _, err := ag.Attach("abc12345"); return err }, - func() error { return ag.Stop(context.Background(), "abc12345") }, - } { - if err := action(); err != nil { - t.Fatal(err) - } - } -} - -func assertTmuxLifecycleLog(t *testing.T, logPath string) { - t.Helper() - logData, _ := os.ReadFile(logPath) - logText := string(logData) - for _, want := range []string{"set-option", "bind-key", "new-session", "send-keys", "kill-session"} { - if !strings.Contains(logText, want) { - t.Fatalf("log missing %s: %s", want, logData) - } - } - if strings.Contains(logText, "exec bash") { - t.Fatalf("agent exit should terminate tmux session, log should not keep a fallback shell: %s", logData) - } -} - -// A dispatched session must get a user-facing label: @uam_label (for the tmux -// status line / title) set to " · ", and its window renamed to -// the short name — so tmux shows the user's name, not uam--. The -// persisted Session.DisplayName stays the bare name. -func TestDispatchSetsSessionLabel(t *testing.T) { - dir := t.TempDir() - writeExecutable(t, filepath.Join(dir, "fakeagent"), "#!/bin/sh\nexit 0\n") - tmuxPath := filepath.Join(dir, "tmux") - writeExecutable(t, tmuxPath, "#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$TMUX_LOG\"\nexit 0\n") - t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) - logPath := filepath.Join(dir, "tmux.log") - t.Setenv("TMUX_LOG", logPath) - client := tmux.New("uam") - client.Executable = tmuxPath - ag := NewTmuxAgent("fake", "Fake Agent", []CommandCandidate{{Display: "fakeagent", Args: []string{"fakeagent"}}}, nil, client) - - sess, err := ag.Dispatch(context.Background(), DispatchRequest{Prompt: "hi", Cwd: "/tmp", Mode: "yolo", Name: "tracker"}) - if err != nil { - t.Fatalf("Dispatch: %v", err) - } - if sess.DisplayName != "tracker" { - t.Fatalf("DisplayName = %q, want tracker", sess.DisplayName) - } - data, _ := os.ReadFile(logPath) - logText := string(data) - if !strings.Contains(logText, "@uam_label tracker · fake") { - t.Fatalf("expected @uam_label 'tracker · fake': %s", logText) - } - if !strings.Contains(logText, "rename-window -t "+sess.TmuxSession+" tracker") { - t.Fatalf("expected window rename to tracker: %s", logText) - } -} - -// F52 — a PR-scrape capture-pane failure must be logged (debug) and stay -// per-session non-fatal: List still returns the session, just without a PR. -func TestListLogsCaptureFailureButKeepsSession(t *testing.T) { - dir := t.TempDir() - writeExecutable(t, filepath.Join(dir, "fakeagent"), "#!/bin/sh\nexit 0\n") - tmuxPath := filepath.Join(dir, "tmux") - // list-sessions succeeds; capture-pane fails for the PR scrape. - writeExecutable(t, tmuxPath, `#!/bin/sh -printf '%s\n' "$*" >> "$TMUX_LOG" -case "$*" in - *"list-sessions"*) echo "uam-fake-abc12345|1710000000|0|1|/tmp/repo|fakeagent" ;; - *"capture-pane"*) echo "capture boom" >&2; exit 1 ;; -esac -exit 0 -`) - t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) - t.Setenv("TMUX_LOG", filepath.Join(dir, "tmux.log")) - client := tmux.New("uam") - client.Executable = tmuxPath - ag := NewTmuxAgent("fake", "Fake Agent", []CommandCandidate{{Display: "fakeagent", Args: []string{"fakeagent"}}}, []string{"--yolo"}, client) - - var buf bytes.Buffer - prev := log.SetLogger(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))) - defer log.SetLogger(prev) - - list, err := ag.List(context.Background()) - if err != nil { - t.Fatalf("a per-session capture failure must not fail List: %v", err) - } - if len(list) != 1 { - t.Fatalf("session should still be listed despite capture failure, got %d", len(list)) - } - if list[0].PR != nil { - t.Fatalf("PR should be nil when capture failed, got %+v", list[0].PR) - } - if !strings.Contains(buf.String(), "capture") { - t.Fatalf("capture failure should be logged, got: %q", buf.String()) - } -} - -func TestTmuxAgentResumeUsesPersistedMetadata(t *testing.T) { - dir := t.TempDir() - writeExecutable(t, filepath.Join(dir, "fakeagent"), "#!/bin/sh\nexit 0\n") - tmuxPath := filepath.Join(dir, "tmux") - writeExecutable(t, tmuxPath, `#!/bin/sh -printf '%s\n' "$*" >> "$TMUX_LOG" -exit 0 -`) - t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) - logPath := filepath.Join(dir, "tmux.log") - t.Setenv("TMUX_LOG", logPath) - - client := tmux.New("uam") - client.Executable = tmuxPath - ag := NewTmuxAgent("fake", "Fake Agent", []CommandCandidate{{Display: "fakeagent", Args: []string{"fakeagent"}}}, []string{"--yolo"}, client) - sess, err := ag.Resume(context.Background(), ResumeRequest{ID: "abc12345-dead-beef-cafe-0123456789ab", Name: "bugfix", Prompt: "fix parser", Cwd: "/tmp/project", Mode: "yolo", TmuxSession: "uam-fake-abc12345"}) - if err != nil { - t.Fatalf("Resume: %v", err) - } - if sess.ID != "abc12345-dead-beef-cafe-0123456789ab" || sess.DisplayName != "bugfix" || sess.Prompt != "fix parser" || sess.Cwd != "/tmp/project" || sess.TmuxSession != "uam-fake-abc12345" || sess.ProcAlive != Alive { - t.Fatalf("resumed session did not preserve metadata: %+v", sess) - } - logData, _ := os.ReadFile(logPath) - logText := string(logData) - for _, want := range []string{"new-session", "uam-fake-abc12345", "/tmp/project", "fakeagent --yolo", "send-keys", "fix parser"} { - if !strings.Contains(logText, want) { - t.Fatalf("resume log missing %q: %s", want, logText) - } - } -} - -func TestTmuxAgentCommandAliasOnPathReplacesDefaultCommand(t *testing.T) { - dir := t.TempDir() - writeExecutable(t, filepath.Join(dir, "ghcp"), "#!/bin/sh\nexit 0\n") - tmuxPath := filepath.Join(dir, "tmux") - writeExecutable(t, tmuxPath, `#!/bin/sh -printf '%s\n' "$*" >> "$TMUX_LOG" -exit 0 -`) - t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) - logPath := filepath.Join(dir, "tmux.log") - t.Setenv("TMUX_LOG", logPath) - client := tmux.New("uam") - client.Executable = tmuxPath - ag := NewTmuxAgent("fake", "Fake Agent", []CommandCandidate{{Display: "fakeagent", Args: []string{"fakeagent"}}}, []string{"--yolo"}, client) - ag.SessionArgs = func(req ResumeRequest, activity string) []string { return []string{"--session", req.ID} } - - sess, err := ag.Dispatch(context.Background(), DispatchRequest{CommandAlias: "ghcp", Cwd: "/tmp", Mode: "yolo"}) - if err != nil { - t.Fatalf("Dispatch: %v", err) - } - if sess.AgentType != "fake" || sess.CommandAlias != "ghcp" { - t.Fatalf("alias dispatch should keep agent type and alias, got %+v", sess) - } - logData, _ := os.ReadFile(logPath) - logText := string(logData) - if !strings.Contains(logText, filepath.Join(dir, "ghcp")+" --yolo --session "+sess.ID) { - t.Fatalf("alias on PATH should launch resolved alias command with yolo/session args: %s", logText) - } - if strings.Contains(logText, "fakeagent") { - t.Fatalf("alias should replace the default candidate, got: %s", logText) - } -} - -func TestTmuxAgentCommandAliasFallsBackToInteractiveShell(t *testing.T) { - dir := t.TempDir() - shellPath := filepath.Join(dir, "shell") - writeExecutable(t, shellPath, `#!/bin/sh -printf '%s\n' "$*" >> "$SHELL_LOG" -case "$*" in - *"type ghcp"*) exit 0 ;; -esac -exit 1 -`) - tmuxPath := filepath.Join(dir, "tmux") - writeExecutable(t, tmuxPath, `#!/bin/sh -printf '%s\n' "$*" >> "$TMUX_LOG" -exit 0 -`) - t.Setenv("PATH", dir) - t.Setenv("SHELL", shellPath) - logPath := filepath.Join(dir, "tmux.log") - t.Setenv("TMUX_LOG", logPath) - t.Setenv("SHELL_LOG", filepath.Join(dir, "shell.log")) - client := tmux.New("uam") - client.Executable = tmuxPath - ag := NewTmuxAgent("fake", "Fake Agent", nil, []string{"--yolo"}, client) - ag.SessionArgs = func(req ResumeRequest, activity string) []string { return []string{"two words", "semi;colon"} } - - if _, err := ag.Dispatch(context.Background(), DispatchRequest{CommandAlias: "ghcp", Cwd: "/tmp", Mode: "yolo"}); err != nil { - t.Fatalf("Dispatch: %v", err) - } - logData, _ := os.ReadFile(logPath) - logText := string(logData) - if !strings.Contains(logText, shellPath+" -ic") || !strings.Contains(logText, "ghcp --yolo") { - t.Fatalf("missing interactive shell fallback: %s", logText) - } - if !strings.Contains(logText, "'two words'") || !strings.Contains(logText, "'semi;colon'") { - t.Fatalf("fallback must shell-quote non-alias args: %s", logText) - } - shellData, _ := os.ReadFile(filepath.Join(dir, "shell.log")) - if !strings.Contains(string(shellData), "type ghcp") { - t.Fatalf("fallback should preflight alias in interactive shell: %s", shellData) - } -} - -func TestTmuxAgentCommandAliasMissingFromShellFailsBeforeCreate(t *testing.T) { - dir := t.TempDir() - shellPath := filepath.Join(dir, "shell") - writeExecutable(t, shellPath, "#!/bin/sh\nexit 1\n") - tmuxPath := filepath.Join(dir, "tmux") - writeExecutable(t, tmuxPath, "#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$TMUX_LOG\"\nexit 0\n") - t.Setenv("PATH", dir) - t.Setenv("SHELL", shellPath) - logPath := filepath.Join(dir, "tmux.log") - t.Setenv("TMUX_LOG", logPath) - client := tmux.New("uam") - client.Executable = tmuxPath - ag := NewTmuxAgent("fake", "Fake Agent", nil, nil, client) - - _, err := ag.Dispatch(context.Background(), DispatchRequest{CommandAlias: "ghcp", Cwd: "/tmp", Mode: "yolo"}) - if err == nil || !strings.Contains(err.Error(), "not found on PATH or in interactive shell") { - t.Fatalf("Dispatch error = %v, want missing alias", err) - } - if data, _ := os.ReadFile(logPath); len(data) != 0 { - t.Fatalf("tmux session should not be created for missing alias: %s", data) - } -} - -func TestTmuxAgentRejectsUnsafeCommandAlias(t *testing.T) { - ag := NewTmuxAgent("fake", "Fake Agent", nil, nil, nil) - for _, alias := range []string{"-ghcp", "gh/cp", "gh cp", "gh;cp", "gh$cp", "gh`cp`"} { - t.Run(alias, func(t *testing.T) { - _, err := ag.Dispatch(context.Background(), DispatchRequest{CommandAlias: alias}) - if err == nil || !strings.Contains(err.Error(), "invalid command alias") { - t.Fatalf("Dispatch error = %v, want invalid alias", err) - } - }) - } -} - -func TestDispatchReturnsRandomIDError(t *testing.T) { - wantErr := errors.New("random unavailable") - ag := NewTmuxAgent("fake", "Fake Agent", nil, nil, nil) - ag.randomReader = errReader{err: wantErr} - - _, err := ag.Dispatch(context.Background(), DispatchRequest{}) - if err == nil { - t.Fatal("expected dispatch error when random ID generation fails") - } - if !errors.Is(err, wantErr) { - t.Fatalf("dispatch error should wrap random reader error, got: %v", err) - } - if !strings.Contains(err.Error(), "generate session id") { - t.Fatalf("dispatch error should include ID generation context, got: %v", err) - } -} - -func TestResumeReturnsRandomIDErrorWhenIDMissing(t *testing.T) { - wantErr := errors.New("random unavailable") - ag := NewTmuxAgent("fake", "Fake Agent", nil, nil, nil) - ag.randomReader = errReader{err: wantErr} - - _, err := ag.Resume(context.Background(), ResumeRequest{}) - if err == nil { - t.Fatal("expected resume error when random ID generation fails") - } - if !errors.Is(err, wantErr) { - t.Fatalf("resume error should wrap random reader error, got: %v", err) - } - if !strings.Contains(err.Error(), "generate session id") { - t.Fatalf("resume error should include ID generation context, got: %v", err) - } -} - -func TestNewIDKeepsUUIDFormat(t *testing.T) { - id, err := newID(bytes.NewReader([]byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15})) - if err != nil { - t.Fatalf("newID: %v", err) - } - if id != "00010203-0405-4607-8809-0a0b0c0d0e0f" { - t.Fatalf("id = %q, want UUID v4 format with version/variant bits", id) - } -} - -// F19 — a resume/dispatch that creates the tmux session but then fails to send -// the prompt must roll back the live (prompt-less) session, otherwise it lingers -// as an orphan the store records as Exited/closed. -func TestStartSessionRollsBackTmuxOnSendLineFailure(t *testing.T) { - dir := t.TempDir() - writeExecutable(t, filepath.Join(dir, "fakeagent"), "#!/bin/sh\nexit 0\n") - tmuxPath := filepath.Join(dir, "tmux") - // send-keys fails; everything else (new-session, kill-session) succeeds. - writeExecutable(t, tmuxPath, `#!/bin/sh -printf '%s\n' "$*" >> "$TMUX_LOG" -case "$*" in - *"send-keys"*) echo "boom" >&2; exit 1 ;; -esac -exit 0 -`) - t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) - logPath := filepath.Join(dir, "tmux.log") - t.Setenv("TMUX_LOG", logPath) - - client := tmux.New("uam") - client.Executable = tmuxPath - ag := NewTmuxAgent("fake", "Fake Agent", []CommandCandidate{{Display: "fakeagent", Args: []string{"fakeagent"}}}, []string{"--yolo"}, client) - _, err := ag.Dispatch(context.Background(), DispatchRequest{Prompt: "hello", Cwd: "/tmp", Mode: "yolo"}) - if err == nil { - t.Fatal("expected dispatch error when send-keys fails") - } - logData, _ := os.ReadFile(logPath) - logText := string(logData) - if !strings.Contains(logText, "new-session") { - t.Fatalf("session should have been created: %s", logText) - } - if !strings.Contains(logText, "kill-session") { - t.Fatalf("send-keys failure must roll back the created session via kill-session: %s", logText) - } -} - -// F19 trap — if the rollback Kill itself fails, the caller must still see the -// original SendLine error (not the kill error). -func TestStartSessionReturnsSendLineErrorWhenRollbackKillAlsoFails(t *testing.T) { - dir := t.TempDir() - writeExecutable(t, filepath.Join(dir, "fakeagent"), "#!/bin/sh\nexit 0\n") - tmuxPath := filepath.Join(dir, "tmux") - writeExecutable(t, tmuxPath, `#!/bin/sh -printf '%s\n' "$*" >> "$TMUX_LOG" -case "$*" in - *"send-keys"*) echo "sendboom" >&2; exit 1 ;; - *"kill-session"*) echo "killboom" >&2; exit 1 ;; -esac -exit 0 -`) - t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) - logPath := filepath.Join(dir, "tmux.log") - t.Setenv("TMUX_LOG", logPath) - - client := tmux.New("uam") - client.Executable = tmuxPath - ag := NewTmuxAgent("fake", "Fake Agent", []CommandCandidate{{Display: "fakeagent", Args: []string{"fakeagent"}}}, []string{"--yolo"}, client) - _, err := ag.Dispatch(context.Background(), DispatchRequest{Prompt: "hello", Cwd: "/tmp", Mode: "yolo"}) - if err == nil { - t.Fatal("expected dispatch error") - } - if !strings.Contains(err.Error(), "sendboom") { - t.Fatalf("error should surface the original send-keys failure, got: %v", err) - } -} - -// F25 — startSession must create the tmux session BEFORE applying server -// config. On first dispatch the server doesn't exist yet, so configuring it -// first fails and (pre-fix) latched the failure forever. Assert new-session -// precedes set-option in the recorded command log. -func TestStartSessionConfiguresServerAfterCreate(t *testing.T) { - dir := t.TempDir() - writeExecutable(t, filepath.Join(dir, "fakeagent"), "#!/bin/sh\nexit 0\n") - tmuxPath := filepath.Join(dir, "tmux") - writeExecutable(t, tmuxPath, `#!/bin/sh -printf '%s\n' "$*" >> "$TMUX_LOG" -exit 0 -`) - t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) - logPath := filepath.Join(dir, "tmux.log") - t.Setenv("TMUX_LOG", logPath) - - client := tmux.New("uam") - client.Executable = tmuxPath - ag := NewTmuxAgent("fake", "Fake Agent", []CommandCandidate{{Display: "fakeagent", Args: []string{"fakeagent"}}}, []string{"--yolo"}, client) - if _, err := ag.Dispatch(context.Background(), DispatchRequest{Prompt: "hello", Cwd: "/tmp", Mode: "yolo"}); err != nil { - t.Fatalf("Dispatch: %v", err) - } - logText := func() string { b, _ := os.ReadFile(logPath); return string(b) }() - nsIdx := strings.Index(logText, "new-session") - soIdx := strings.Index(logText, "set-option") - if nsIdx < 0 || soIdx < 0 { - t.Fatalf("expected both new-session and set-option in log: %s", logText) - } - if nsIdx > soIdx { - t.Fatalf("CreateSession must precede server config so the server exists: %s", logText) - } -} - -func TestTmuxAgentDispatchWithoutPromptSkipsSendKeys(t *testing.T) { - dir := t.TempDir() - writeExecutable(t, filepath.Join(dir, "fakeagent"), "#!/bin/sh\nexit 0\n") - tmuxPath := filepath.Join(dir, "tmux") - writeExecutable(t, tmuxPath, `#!/bin/sh -printf '%s\n' "$*" >> "$TMUX_LOG" -exit 0 -`) - t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) - logPath := filepath.Join(dir, "tmux.log") - t.Setenv("TMUX_LOG", logPath) - - client := tmux.New("uam") - client.Executable = tmuxPath - ag := NewTmuxAgent("fake", "Fake Agent", []CommandCandidate{{Display: "fakeagent", Args: []string{"fakeagent"}}}, []string{"--yolo"}, client) - sess, err := ag.Dispatch(context.Background(), DispatchRequest{Cwd: "/tmp", Mode: "yolo"}) - if err != nil { - t.Fatalf("Dispatch: %v", err) - } - if sess.DisplayName != "tmp" { - t.Fatalf("DisplayName=%q, want dir-derived name", sess.DisplayName) - } - logData, _ := os.ReadFile(logPath) - // `send-keys -t` is the prompt-injection form; the mouse copy/paste config - // bindings legitimately contain `send-keys -X`/`-M`, so match the targeted - // form rather than the bare substring. - if strings.Contains(string(logData), "send-keys -t") { - t.Fatalf("empty prompt should not be sent: %s", logData) - } -} - -// F32 — target() must use tmux exact-match (`=` prefix) so a neighbour session -// whose name shares the truncated prefix is never hit by `-t`. Drive Stop/Peek -// through a fake tmux and assert the recorded `-t` token is exact-anchored. -func newTargetingAgent(t *testing.T) (*TmuxAgent, string) { - t.Helper() - dir := t.TempDir() - tmuxPath := filepath.Join(dir, "tmux") - writeExecutable(t, tmuxPath, `#!/bin/sh -printf '%s\n' "$*" >> "$TMUX_LOG" -case "$*" in - *"capture-pane"*) printf 'tail\n' ;; -esac -exit 0 -`) - t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) - logPath := filepath.Join(dir, "tmux.log") - t.Setenv("TMUX_LOG", logPath) - client := tmux.New("uam") - client.Executable = tmuxPath - return NewTmuxAgent("fake", "Fake Agent", []CommandCandidate{{Display: "fakeagent", Args: []string{"fakeagent"}}}, []string{"--yolo"}, client), logPath -} - -func TestTargetUsesExactMatchForFullUUID(t *testing.T) { - ag, logPath := newTargetingAgent(t) - // A full-UUID id whose first 8 chars name the session. - if err := ag.Stop(context.Background(), "abc12345-dead-beef-cafe-0123456789ab"); err != nil { - t.Fatalf("Stop: %v", err) - } - logData, _ := os.ReadFile(logPath) - logText := string(logData) - if !strings.Contains(logText, "-t =uam-fake-abc12345") { - t.Fatalf("kill must target the exact session, got: %s", logText) - } -} - -func TestTargetUsesExactMatchForCanonicalName(t *testing.T) { - ag, logPath := newTargetingAgent(t) - // A canonical (already uam-prefixed) name must also be exact-anchored so a - // longer neighbour ("uam-fake-abc123450" etc.) is never matched by prefix. - if _, err := ag.Peek(context.Background(), "uam-fake-abc12345"); err != nil { - t.Fatalf("Peek: %v", err) - } - logData, _ := os.ReadFile(logPath) - logText := string(logData) - if !strings.Contains(logText, "-t =uam-fake-abc12345") { - t.Fatalf("capture must target the exact session, got: %s", logText) - } -} - -// F57 — startSession must wrap a CreateSession failure with the tmux session -// name (boundary context) while preserving the underlying error for errors.Is. -func TestStartSessionWrapsCreateSessionError(t *testing.T) { - dir := t.TempDir() - writeExecutable(t, filepath.Join(dir, "fakeagent"), "#!/bin/sh\nexit 0\n") - tmuxPath := filepath.Join(dir, "tmux") - writeExecutable(t, tmuxPath, `#!/bin/sh -printf '%s\n' "$*" >> "$TMUX_LOG" -case "$*" in - *"new-session"*) echo "create boom" >&2; exit 1 ;; -esac -exit 0 -`) - t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) - t.Setenv("TMUX_LOG", filepath.Join(dir, "tmux.log")) - client := tmux.New("uam") - client.Executable = tmuxPath - ag := NewTmuxAgent("fake", "Fake Agent", []CommandCandidate{{Display: "fakeagent", Args: []string{"fakeagent"}}}, []string{"--yolo"}, client) - _, err := ag.Dispatch(context.Background(), DispatchRequest{Prompt: "hi", Cwd: "/tmp", Mode: "yolo"}) - if err == nil { - t.Fatal("expected dispatch error when new-session fails") - } - if !strings.Contains(err.Error(), "uam-fake-") { - t.Fatalf("CreateSession failure must be wrapped with the tmux session name, got: %v", err) - } - if !strings.Contains(err.Error(), "create boom") { - t.Fatalf("wrapped error must preserve the underlying cause, got: %v", err) - } -} - -// F57 — List must wrap a tmux list failure with the agent name so a caller -// (and the log) can tell which provider's scan failed. -func TestListWrapsTmuxListError(t *testing.T) { - dir := t.TempDir() - tmuxPath := filepath.Join(dir, "tmux") - writeExecutable(t, tmuxPath, `#!/bin/sh -case "$*" in - *"list-sessions"*) echo "protocol mismatch" >&2; exit 1 ;; -esac -exit 0 -`) - client := tmux.New("uam") - client.Executable = tmuxPath - ag := NewTmuxAgent("fake", "Fake Agent", []CommandCandidate{{Display: "fakeagent", Args: []string{"fakeagent"}}}, []string{"--yolo"}, client) - _, err := ag.List(context.Background()) - if err == nil { - t.Fatal("expected List error on a genuine list-sessions failure") - } - if !strings.Contains(err.Error(), "fake") { - t.Fatalf("List failure must be wrapped with the agent name, got: %v", err) - } - if !strings.Contains(err.Error(), "protocol mismatch") { - t.Fatalf("wrapped error must preserve the underlying cause, got: %v", err) - } -} - -func TestDisplayNameFromDir(t *testing.T) { - if got := displayNameFromDir("/home/dev/projects/uam"); got != "uam" { - t.Fatalf("dir name = %q, want uam", got) - } - if got := displayNameFromDir("/"); got != "untitled" { - t.Fatalf("root dir name = %q, want untitled", got) - } - cwd, _ := os.Getwd() - if got := displayNameFromDir("."); got != filepath.Base(cwd) { - t.Fatalf("relative dir name = %q, want %q", got, filepath.Base(cwd)) - } -} - -func TestTmuxAgentUnavailable(t *testing.T) { - ag := NewTmuxAgent("missing", "Missing", []CommandCandidate{{Display: "definitely-missing", Args: []string{"definitely-missing-uam-test"}}}, nil, nil) - if ok, reason := ag.Available(); ok || reason == "" { - t.Fatalf("Available = %v %q", ok, reason) - } - if _, err := ag.Dispatch(context.Background(), DispatchRequest{}); err == nil { - t.Fatal("expected dispatch error") - } -} - -func writeExecutable(t *testing.T, path, content string) { - t.Helper() - if err := os.WriteFile(path, []byte(content), 0o755); err != nil { - t.Fatal(err) - } -} - -type errReader struct { - err error -} - -func (r errReader) Read([]byte) (int, error) { - return 0, r.err -} diff --git a/internal/agents/agents.go b/internal/agents/agents.go index c7f23d3..9be07c2 100644 --- a/internal/agents/agents.go +++ b/internal/agents/agents.go @@ -3,7 +3,7 @@ // wiring (internal/cli) build their registry from Default so the two can never // drift — previously each hand-maintained its own list and app.New silently // omitted hermes (F14). It is a leaf package: the providers import -// internal/adapter and internal/tmux, and nothing imports back into agents, so +// internal/adapter and internal/session, and nothing imports back into agents, so // there is no import cycle. package agents @@ -15,20 +15,19 @@ import ( "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter/hermes" "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter/omp" "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter/opencode" - "github.com/RandomCodeSpace/unified-agent-manager/internal/tmux" ) -// Default returns every supported agent adapter, built against client. The -// returned slice is the pre-availability list (it is not LookPath-filtered); -// callers pass it to adapter.NewRegistry, which probes Available() and hides -// the ones whose CLI is not installed. -func Default(client *tmux.Client) []adapter.AgentAdapter { +// Default returns every supported agent adapter, built against the shared +// session backend. The returned slice is the pre-availability list (it is not +// LookPath-filtered); callers pass it to adapter.NewRegistry, which probes +// Available() and hides the ones whose CLI is not installed. +func Default(backend adapter.Backend) []adapter.AgentAdapter { return []adapter.AgentAdapter{ - claude.New(client), - codex.New(client), - copilot.New(client), - hermes.New(client), - omp.New(client), - opencode.New(client), + claude.New(backend), + codex.New(backend), + copilot.New(backend), + hermes.New(backend), + omp.New(backend), + opencode.New(backend), } } diff --git a/internal/agents/agents_test.go b/internal/agents/agents_test.go index 73099ca..f7ae06b 100644 --- a/internal/agents/agents_test.go +++ b/internal/agents/agents_test.go @@ -4,7 +4,7 @@ import ( "sort" "testing" - "github.com/RandomCodeSpace/unified-agent-manager/internal/tmux" + "github.com/RandomCodeSpace/unified-agent-manager/internal/session" ) // F14 — Default is the single source of truth for the adapter list. It must @@ -13,7 +13,7 @@ import ( // on the raw pre-availability list (Enabled() would be LookPath-filtered to // empty in CI). func TestDefaultBuildsAllProviders(t *testing.T) { - client := tmux.New("uam") + client := session.NewClient() got := make([]string, 0) for _, a := range Default(client) { got = append(got, a.Name()) @@ -34,7 +34,7 @@ func TestDefaultBuildsAllProviders(t *testing.T) { // F14 — the brief calls out hermes specifically because the old hand-maintained // app.New list omitted it; assert it is present. func TestDefaultIncludesHermes(t *testing.T) { - client := tmux.New("uam") + client := session.NewClient() for _, a := range Default(client) { if a.Name() == "hermes" { return diff --git a/internal/app/app.go b/internal/app/app.go index aeda07f..f3d25a8 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -14,8 +14,9 @@ import ( "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter" "github.com/RandomCodeSpace/unified-agent-manager/internal/agents" + "github.com/RandomCodeSpace/unified-agent-manager/internal/log" + "github.com/RandomCodeSpace/unified-agent-manager/internal/session" "github.com/RandomCodeSpace/unified-agent-manager/internal/store" - "github.com/RandomCodeSpace/unified-agent-manager/internal/tmux" "github.com/RandomCodeSpace/unified-agent-manager/internal/version" ) @@ -130,8 +131,14 @@ type promptEditedMsg struct { } func New() Model { - st, _ := store.Open(store.DefaultPath()) - client := tmux.New("uam") + st, err := store.Open(store.DefaultPath()) + if err != nil { + // The TUI degrades gracefully with a nil store (nothing persists), but + // that must not happen silently — log it so "my sessions vanished" is + // diagnosable. + log.Warn("open store failed; running without persistence", "error", err) + } + client := session.NewClient() // Build the registry from the single shared adapter list so the TUI and the // CLI service can never diverge (the old hand-rolled list here omitted // hermes — F14). @@ -280,6 +287,17 @@ func (m Model) handleSessionsLoaded(msg sessionsLoadedMsg) Model { if msg.sessions != nil { m.sessions = msg.sessions m.groupByDir = msg.groupByDir + // Drop peek throttle stamps for sessions that no longer exist so the + // map cannot grow without bound across many session lifetimes. + live := make(map[string]struct{}, len(m.sessions)) + for _, sess := range m.sessions { + live[sess.ID] = struct{}{} + } + for id := range m.lastPeekAt { + if _, ok := live[id]; !ok { + delete(m.lastPeekAt, id) + } + } } if msg.defaultAgent != "" { // A persisted default may name an agent whose CLI was since uninstalled; @@ -590,7 +608,7 @@ func (m *Model) handleSpaceKey(key string) tea.Cmd { m.input += key return nil } - // A stopped session has no live tmux pane to peek into — Space restarts it + // A stopped session has no live process to peek into — Space restarts it // in the background instead. if sess, ok := m.selectedSession(); ok && sess.ProcAlive == adapter.Exited { m.setMessage("restarting " + firstNonEmpty(sess.DisplayName, sess.ID)) @@ -1000,7 +1018,7 @@ func (m Model) peekSelectedCmd() tea.Cmd { } } -// resumeSelectedCmd restarts the selected session's tmux session in the +// resumeSelectedCmd restarts the selected session's backend session in the // background, then reloads so it moves into the ACTIVE group. func (m Model) resumeSelectedCmd() tea.Cmd { sess, ok := m.selectedSession() @@ -1261,8 +1279,8 @@ func (m Model) renderTable() string { } // Two groups: Active (anything not flagged closed_by_user — including // reboot-survivors that will resume on attach) and Closed (the user - // explicitly retired these via uam stop, exit-in-session, or external - // tmux kill-session). + // explicitly retired these via uam stop, exit-in-session, or an external + // kill). g1 := m.renderGroup(groupRenderOptions{label: "ACTIVE", total: active, start: start, end: end, wantClosed: false, nameWidth: nameWidth, taskWidth: taskWidth, showTask: showTask}) g2 := m.renderGroup(groupRenderOptions{label: "CLOSED", total: closed, start: start, end: end, wantClosed: true, nameWidth: nameWidth, taskWidth: taskWidth, showTask: showTask}) b.WriteString(g1) @@ -1451,6 +1469,7 @@ func (m Model) renderHelp() string { "Tab cycle agent Ctrl+T pin Ctrl+R rename", "Ctrl+X stop/remove Ctrl+S group-by-dir", "e new session Esc quit", + "in session: ← detach (when input empty) Ctrl+B d detach", "dispatch: @agent:alias #name prompt (alias, name & prompt optional)", } var b strings.Builder diff --git a/internal/app/app_cmd_test.go b/internal/app/app_cmd_test.go index 7dd07e9..6a8f080 100644 --- a/internal/app/app_cmd_test.go +++ b/internal/app/app_cmd_test.go @@ -73,7 +73,7 @@ func TestWizardTabPersistsDefaultAgentChoice(t *testing.T) { func TestModelCommandFactories(t *testing.T) { dir := t.TempDir() st, _ := store.Open(filepath.Join(dir, "sessions.json")) - fake := &svcFakeAdapter{name: "fake", available: true, sessions: []adapter.Session{{ID: "abc12345", AgentType: "fake", DisplayName: "live", Cwd: "/tmp", TmuxSession: "uam-fake-abc12345", State: adapter.Active, CreatedAt: time.Now()}}} + fake := &svcFakeAdapter{name: "fake", available: true, sessions: []adapter.Session{{ID: "abc12345", AgentType: "fake", DisplayName: "live", Cwd: "/tmp", SessionName: "uam-fake-abc12345", State: adapter.Active, CreatedAt: time.Now()}}} m := NewWithDeps(st, adapter.NewRegistry([]adapter.AgentAdapter{fake})) m.sessions = fake.sessions m.defaultAgent = "fake" @@ -112,7 +112,7 @@ func TestModelCommandFactories(t *testing.T) { func TestHandleKeyBranches(t *testing.T) { dir := t.TempDir() st, _ := store.Open(filepath.Join(dir, "sessions.json")) - fake := &svcFakeAdapter{name: "fake", available: true, sessions: []adapter.Session{{ID: "abc12345", AgentType: "fake", DisplayName: "live", Cwd: "/tmp", TmuxSession: "uam-fake-abc12345", State: adapter.Active, CreatedAt: time.Now()}}} + fake := &svcFakeAdapter{name: "fake", available: true, sessions: []adapter.Session{{ID: "abc12345", AgentType: "fake", DisplayName: "live", Cwd: "/tmp", SessionName: "uam-fake-abc12345", State: adapter.Active, CreatedAt: time.Now()}}} m := NewWithDeps(st, adapter.NewRegistry([]adapter.AgentAdapter{fake})) m.sessions = fake.sessions m.defaultAgent = "fake" @@ -149,7 +149,7 @@ func TestHandleKeyBranches(t *testing.T) { func TestPromptTypingAllowsAgentMentionsSpacesAndShortcutLetters(t *testing.T) { dir := t.TempDir() st, _ := store.Open(filepath.Join(dir, "sessions.json")) - fake := &svcFakeAdapter{name: "fake", available: true, sessions: []adapter.Session{{ID: "abc12345", AgentType: "fake", DisplayName: "live", Cwd: "/tmp", TmuxSession: "uam-fake-abc12345", State: adapter.Active, CreatedAt: time.Now()}}} + fake := &svcFakeAdapter{name: "fake", available: true, sessions: []adapter.Session{{ID: "abc12345", AgentType: "fake", DisplayName: "live", Cwd: "/tmp", SessionName: "uam-fake-abc12345", State: adapter.Active, CreatedAt: time.Now()}}} m := NewWithDeps(st, adapter.NewRegistry([]adapter.AgentAdapter{fake})) m.sessions = fake.sessions m.defaultAgent = "fake" @@ -178,7 +178,7 @@ func TestPromptTypingAllowsAgentMentionsSpacesAndShortcutLetters(t *testing.T) { func TestRenameAndWizardEnterBranches(t *testing.T) { dir := t.TempDir() st, _ := store.Open(filepath.Join(dir, "sessions.json")) - fake := &svcFakeAdapter{name: "fake", available: true, sessions: []adapter.Session{{ID: "abc12345", AgentType: "fake", DisplayName: "live", Cwd: "/tmp", TmuxSession: "uam-fake-abc12345", State: adapter.Active, CreatedAt: time.Now()}}} + fake := &svcFakeAdapter{name: "fake", available: true, sessions: []adapter.Session{{ID: "abc12345", AgentType: "fake", DisplayName: "live", Cwd: "/tmp", SessionName: "uam-fake-abc12345", State: adapter.Active, CreatedAt: time.Now()}}} m := NewWithDeps(st, adapter.NewRegistry([]adapter.AgentAdapter{fake})) m.sessions = fake.sessions m.defaultAgent = "fake" diff --git a/internal/app/app_more_test.go b/internal/app/app_more_test.go index e360fab..e43e087 100644 --- a/internal/app/app_more_test.go +++ b/internal/app/app_more_test.go @@ -209,8 +209,8 @@ func TestDispatchedFailureWithoutSessionDoesNotAttach(t *testing.T) { func TestViewShowsDetailsOnTopAndTaskInTable(t *testing.T) { m := NewWithDeps(nil, nil) m.sessions = []adapter.Session{ - {ID: "1", AgentType: "fake", DisplayName: "one", Prompt: "fix the parser", Cwd: "/tmp/project", TmuxSession: "uam-fake-1", ProcAlive: adapter.Alive}, - {ID: "2", AgentType: "fake", DisplayName: "old", Prompt: "old prompt", Cwd: "/tmp/old", TmuxSession: "uam-fake-2", ProcAlive: adapter.Exited, Closed: true}, + {ID: "1", AgentType: "fake", DisplayName: "one", Prompt: "fix the parser", Cwd: "/tmp/project", SessionName: "uam-fake-1", ProcAlive: adapter.Alive}, + {ID: "2", AgentType: "fake", DisplayName: "old", Prompt: "old prompt", Cwd: "/tmp/old", SessionName: "uam-fake-2", ProcAlive: adapter.Exited, Closed: true}, } view := m.View() if !strings.Contains(view, "cwd: /tmp/project") { diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 72bbd21..9829ddc 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -65,7 +65,7 @@ func TestRenderDetailsShowsPromptOnMobileOnly(t *testing.T) { DisplayName: "bugfix", Prompt: "fix the parser", Cwd: "/tmp/repo", - TmuxSession: "uam-claude-abc12345", + SessionName: "uam-claude-abc12345", ProcAlive: adapter.Alive, State: adapter.Active, CreatedAt: time.Date(2026, time.May, 18, 7, 4, 0, 0, time.UTC), diff --git a/internal/app/lifecycle_test.go b/internal/app/lifecycle_test.go index 1387f26..273b760 100644 --- a/internal/app/lifecycle_test.go +++ b/internal/app/lifecycle_test.go @@ -14,14 +14,14 @@ import ( // running. Without this, an old CreatedAt + never-updated LastSeenAt makes a // long-running live session look prunable. func TestLastSeenAtBumpedForLiveSessions(t *testing.T) { - live := adapter.Session{ID: "aaaa1111", AgentType: "fake", DisplayName: "A", Cwd: "/tmp", TmuxSession: "uam-fake-aaaa1111", State: adapter.Active, ProcAlive: adapter.Alive, CreatedAt: time.Now().Add(-30 * 24 * time.Hour)} + live := adapter.Session{ID: "aaaa1111", AgentType: "fake", DisplayName: "A", Cwd: "/tmp", SessionName: "uam-fake-aaaa1111", State: adapter.Active, ProcAlive: adapter.Alive, CreatedAt: time.Now().Add(-30 * 24 * time.Hour)} svc, st, _ := newLoadService(t, []adapter.Session{live}) // Seed a stale record for the same live session: LastSeenAt far in the past. key := store.Key("fake", "aaaa1111") stale := time.Now().Add(-30 * 24 * time.Hour) if err := st.Update(func(cfg *store.Config) error { - cfg.Sessions[key] = store.SessionRecord{ID: "aaaa1111", Agent: "fake", Name: "A", TmuxSession: "uam-fake-aaaa1111", Status: store.StatusActive, LastSeenAt: stale} + cfg.Sessions[key] = store.SessionRecord{ID: "aaaa1111", Agent: "fake", Name: "A", SessionName: "uam-fake-aaaa1111", Status: store.StatusActive, LastSeenAt: stale} return nil }); err != nil { t.Fatalf("seed: %v", err) @@ -52,7 +52,7 @@ func TestStartupPruneSkipsWhenServerDown(t *testing.T) { key := store.Key("fake", "bbbb2222") old := time.Now().Add(-90 * 24 * time.Hour) if err := st.Update(func(cfg *store.Config) error { - cfg.Sessions[key] = store.SessionRecord{ID: "bbbb2222", Agent: "fake", Name: "B", TmuxSession: "uam-fake-bbbb2222", Status: store.StatusActive, LastSeenAt: old} + cfg.Sessions[key] = store.SessionRecord{ID: "bbbb2222", Agent: "fake", Name: "B", SessionName: "uam-fake-bbbb2222", Status: store.StatusActive, LastSeenAt: old} return nil }); err != nil { t.Fatalf("seed: %v", err) @@ -73,15 +73,15 @@ func TestStartupPruneSkipsWhenServerDown(t *testing.T) { // F20 Stage 2 — when the server is up (at least one live session proves it), // startup pruning removes a stale, dead-pane record but keeps the live one. func TestStartupPruneRemovesStaleDeadRecordWhenServerUp(t *testing.T) { - live := adapter.Session{ID: "cccc3333", AgentType: "fake", DisplayName: "C", Cwd: "/tmp", TmuxSession: "uam-fake-cccc3333", State: adapter.Active, ProcAlive: adapter.Alive, CreatedAt: time.Now()} + live := adapter.Session{ID: "cccc3333", AgentType: "fake", DisplayName: "C", Cwd: "/tmp", SessionName: "uam-fake-cccc3333", State: adapter.Active, ProcAlive: adapter.Alive, CreatedAt: time.Now()} svc, st, _ := newLoadService(t, []adapter.Session{live}) liveKey := store.Key("fake", "cccc3333") deadKey := store.Key("fake", "dddd4444") old := time.Now().Add(-90 * 24 * time.Hour) if err := st.Update(func(cfg *store.Config) error { - cfg.Sessions[liveKey] = store.SessionRecord{ID: "cccc3333", Agent: "fake", Name: "C", TmuxSession: "uam-fake-cccc3333", Status: store.StatusActive, LastSeenAt: time.Now()} - cfg.Sessions[deadKey] = store.SessionRecord{ID: "dddd4444", Agent: "fake", Name: "D", TmuxSession: "uam-fake-dddd4444", Status: store.StatusActive, LastSeenAt: old} + cfg.Sessions[liveKey] = store.SessionRecord{ID: "cccc3333", Agent: "fake", Name: "C", SessionName: "uam-fake-cccc3333", Status: store.StatusActive, LastSeenAt: time.Now()} + cfg.Sessions[deadKey] = store.SessionRecord{ID: "dddd4444", Agent: "fake", Name: "D", SessionName: "uam-fake-dddd4444", Status: store.StatusActive, LastSeenAt: old} return nil }); err != nil { t.Fatalf("seed: %v", err) diff --git a/internal/app/livesessions_test.go b/internal/app/livesessions_test.go index 8543746..0af724d 100644 --- a/internal/app/livesessions_test.go +++ b/internal/app/livesessions_test.go @@ -35,7 +35,7 @@ func TestLiveSessionsLogsAdapterListFailure(t *testing.T) { t.Fatal(err) } healthy := &svcFakeAdapter{name: "healthy", available: true, sessions: []adapter.Session{ - {ID: "live0001", AgentType: "healthy", DisplayName: "live", Cwd: "/tmp", TmuxSession: "uam-healthy-live0001", State: adapter.Active, CreatedAt: time.Now()}, + {ID: "live0001", AgentType: "healthy", DisplayName: "live", Cwd: "/tmp", SessionName: "uam-healthy-live0001", State: adapter.Active, CreatedAt: time.Now()}, }} broken := &svcFakeAdapter{name: "broken", available: true, listErr: errors.New("tmux exploded")} svc := NewService(st, adapter.NewRegistry([]adapter.AgentAdapter{healthy, broken})) @@ -61,7 +61,7 @@ func TestLiveSessionsDoesNotWarnOnSuccess(t *testing.T) { t.Fatal(err) } healthy := &svcFakeAdapter{name: "healthy", available: true, sessions: []adapter.Session{ - {ID: "live0001", AgentType: "healthy", DisplayName: "live", Cwd: "/tmp", TmuxSession: "uam-healthy-live0001", State: adapter.Active, CreatedAt: time.Now()}, + {ID: "live0001", AgentType: "healthy", DisplayName: "live", Cwd: "/tmp", SessionName: "uam-healthy-live0001", State: adapter.Active, CreatedAt: time.Now()}, }} svc := NewService(st, adapter.NewRegistry([]adapter.AgentAdapter{healthy})) diff --git a/internal/app/loadsessions_test.go b/internal/app/loadsessions_test.go index 03a7c6d..890c190 100644 --- a/internal/app/loadsessions_test.go +++ b/internal/app/loadsessions_test.go @@ -32,13 +32,13 @@ func newLoadService(t *testing.T, sessions []adapter.Session) (*Service, *store. // A, so B's pin survives. func TestLoadSessionsRefreshDoesNotClobberConcurrentPin(t *testing.T) { // Live session A has no store record -> refresh must backfill it (a write). - liveA := adapter.Session{ID: "aaaa1111", AgentType: "fake", DisplayName: "A", Cwd: "/tmp", TmuxSession: "uam-fake-aaaa1111", State: adapter.Active, ProcAlive: adapter.Alive, CreatedAt: time.Now()} + liveA := adapter.Session{ID: "aaaa1111", AgentType: "fake", DisplayName: "A", Cwd: "/tmp", SessionName: "uam-fake-aaaa1111", State: adapter.Active, ProcAlive: adapter.Alive, CreatedAt: time.Now()} svc, st, _ := newLoadService(t, []adapter.Session{liveA}) // Record B belongs to a different session and is already persisted, unpinned. keyB := store.Key("fake", "bbbb2222") if err := st.Update(func(cfg *store.Config) error { - cfg.Sessions[keyB] = store.SessionRecord{ID: "bbbb2222", Agent: "fake", Name: "B", TmuxSession: "uam-fake-bbbb2222"} + cfg.Sessions[keyB] = store.SessionRecord{ID: "bbbb2222", Agent: "fake", Name: "B", SessionName: "uam-fake-bbbb2222"} return nil }); err != nil { t.Fatalf("seed B: %v", err) @@ -78,12 +78,12 @@ func TestLoadSessionsRefreshDoesNotClobberConcurrentPin(t *testing.T) { // F01 (-race) — concurrent LoadSessions refreshes and TogglePin calls must not // race and must not lose the final pin state. func TestLoadSessionsConcurrentWithTogglePin_Race(t *testing.T) { - liveA := adapter.Session{ID: "aaaa1111", AgentType: "fake", DisplayName: "A", Cwd: "/tmp", TmuxSession: "uam-fake-aaaa1111", State: adapter.Active, ProcAlive: adapter.Alive, CreatedAt: time.Now()} + liveA := adapter.Session{ID: "aaaa1111", AgentType: "fake", DisplayName: "A", Cwd: "/tmp", SessionName: "uam-fake-aaaa1111", State: adapter.Active, ProcAlive: adapter.Alive, CreatedAt: time.Now()} svc, st, _ := newLoadService(t, []adapter.Session{liveA}) keyB := store.Key("fake", "bbbb2222") if err := st.Update(func(cfg *store.Config) error { - cfg.Sessions[keyB] = store.SessionRecord{ID: "bbbb2222", Agent: "fake", Name: "B", TmuxSession: "uam-fake-bbbb2222", Pinned: true} + cfg.Sessions[keyB] = store.SessionRecord{ID: "bbbb2222", Agent: "fake", Name: "B", SessionName: "uam-fake-bbbb2222", Pinned: true} return nil }); err != nil { t.Fatalf("seed B: %v", err) @@ -113,7 +113,7 @@ func TestLoadSessionsConcurrentWithTogglePin_Race(t *testing.T) { // C1-1 — a live session lacking a store record must be backfilled and persisted. func TestRefreshBackfillsOrphanRecord(t *testing.T) { - live := adapter.Session{ID: "cccc3333", AgentType: "fake", DisplayName: "orphan", Cwd: "/tmp", TmuxSession: "uam-fake-cccc3333", State: adapter.Active, ProcAlive: adapter.Alive, CreatedAt: time.Now()} + live := adapter.Session{ID: "cccc3333", AgentType: "fake", DisplayName: "orphan", Cwd: "/tmp", SessionName: "uam-fake-cccc3333", State: adapter.Active, ProcAlive: adapter.Alive, CreatedAt: time.Now()} svc, st, _ := newLoadService(t, []adapter.Session{live}) if _, _, err := svc.LoadSessions(context.Background()); err != nil { @@ -127,7 +127,7 @@ func TestRefreshBackfillsOrphanRecord(t *testing.T) { if !ok { t.Fatalf("orphan live session was not backfilled into the store: %+v", cfg.Sessions) } - if rec.ID != "cccc3333" || rec.TmuxSession != "uam-fake-cccc3333" { + if rec.ID != "cccc3333" || rec.SessionName != "uam-fake-cccc3333" { t.Fatalf("backfilled record is malformed: %+v", rec) } } @@ -135,7 +135,7 @@ func TestRefreshBackfillsOrphanRecord(t *testing.T) { // C1-1 — once a record exists, repeated refreshes with no real change must not // rewrite the store (no write-storm of no-op saves). func TestRefreshIsIdempotentNoRedundantSave(t *testing.T) { - live := adapter.Session{ID: "dddd4444", AgentType: "fake", DisplayName: "d", Cwd: "/tmp", TmuxSession: "uam-fake-dddd4444", State: adapter.Active, ProcAlive: adapter.Alive, CreatedAt: time.Now()} + live := adapter.Session{ID: "dddd4444", AgentType: "fake", DisplayName: "d", Cwd: "/tmp", SessionName: "uam-fake-dddd4444", State: adapter.Active, ProcAlive: adapter.Alive, CreatedAt: time.Now()} svc, st, _ := newLoadService(t, []adapter.Session{live}) // First load backfills. @@ -179,13 +179,13 @@ func TestRefreshDoesNotPersistEmptyIDRecord(t *testing.T) { // F18 — a session the user closed but whose pane is still alive must reconcile // to Active (it renders under ACTIVE, not CLOSED, because Closed => Exited). func TestLoadSessionsReconcilesLiveUserClosedSessionToActive(t *testing.T) { - live := adapter.Session{ID: "eeee5555", AgentType: "fake", DisplayName: "e", Cwd: "/tmp", TmuxSession: "uam-fake-eeee5555", State: adapter.Active, ProcAlive: adapter.Alive, CreatedAt: time.Now()} + live := adapter.Session{ID: "eeee5555", AgentType: "fake", DisplayName: "e", Cwd: "/tmp", SessionName: "uam-fake-eeee5555", State: adapter.Active, ProcAlive: adapter.Alive, CreatedAt: time.Now()} svc, st, _ := newLoadService(t, []adapter.Session{live}) // Persist a record flagged closed-by-user even though the pane is alive. key := store.Key("fake", "eeee5555") if err := st.Update(func(cfg *store.Config) error { - cfg.Sessions[key] = store.SessionRecord{ID: "eeee5555", Agent: "fake", Name: "e", TmuxSession: "uam-fake-eeee5555", Status: store.StatusClosedByUser} + cfg.Sessions[key] = store.SessionRecord{ID: "eeee5555", Agent: "fake", Name: "e", SessionName: "uam-fake-eeee5555", Status: store.StatusClosedByUser} return nil }); err != nil { t.Fatalf("seed: %v", err) @@ -212,12 +212,12 @@ func TestLoadSessionsReconcilesLiveUserClosedSessionToActive(t *testing.T) { // F18 anti-flap — the persisted Status of a closed record whose pane survived // the close must be reset to Active so it does not flap back to Closed. func TestLoadSessionsResetsPersistedStatusWhenLivePaneSurvivesClose(t *testing.T) { - live := adapter.Session{ID: "ffff6666", AgentType: "fake", DisplayName: "f", Cwd: "/tmp", TmuxSession: "uam-fake-ffff6666", State: adapter.Active, ProcAlive: adapter.Alive, CreatedAt: time.Now()} + live := adapter.Session{ID: "ffff6666", AgentType: "fake", DisplayName: "f", Cwd: "/tmp", SessionName: "uam-fake-ffff6666", State: adapter.Active, ProcAlive: adapter.Alive, CreatedAt: time.Now()} svc, st, _ := newLoadService(t, []adapter.Session{live}) key := store.Key("fake", "ffff6666") if err := st.Update(func(cfg *store.Config) error { - cfg.Sessions[key] = store.SessionRecord{ID: "ffff6666", Agent: "fake", Name: "f", TmuxSession: "uam-fake-ffff6666", Status: store.StatusClosedByUser} + cfg.Sessions[key] = store.SessionRecord{ID: "ffff6666", Agent: "fake", Name: "f", SessionName: "uam-fake-ffff6666", Status: store.StatusClosedByUser} return nil }); err != nil { t.Fatalf("seed: %v", err) diff --git a/internal/app/pr_refresh_test.go b/internal/app/pr_refresh_test.go index ca0fa1f..e84cd79 100644 --- a/internal/app/pr_refresh_test.go +++ b/internal/app/pr_refresh_test.go @@ -31,7 +31,7 @@ func TestUpdatePRRecordWritesLastCheckedOnTimeout(t *testing.T) { // gh sleeps long enough to blow the per-check timeout. writeFakeGH(t, "#!/bin/sh\nsleep 30\n") - live := adapter.Session{ID: "aaaa1111", AgentType: "fake", DisplayName: "A", Cwd: "/tmp", TmuxSession: "uam-fake-aaaa1111", State: adapter.Active, ProcAlive: adapter.Alive, CreatedAt: time.Now(), PR: &adapter.PRRef{URL: "https://github.com/o/r/pull/1", Number: 1, Status: adapter.PROpen}} + live := adapter.Session{ID: "aaaa1111", AgentType: "fake", DisplayName: "A", Cwd: "/tmp", SessionName: "uam-fake-aaaa1111", State: adapter.Active, ProcAlive: adapter.Alive, CreatedAt: time.Now(), PR: &adapter.PRRef{URL: "https://github.com/o/r/pull/1", Number: 1, Status: adapter.PROpen}} svc, st, _ := newLoadService(t, []adapter.Session{live}) before := time.Now() @@ -72,7 +72,7 @@ func TestRefreshDoesNotStackConcurrentLoads(t *testing.T) { t.Setenv("UAM_GH_BIN", gh) t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) - live := adapter.Session{ID: "bbbb2222", AgentType: "fake", DisplayName: "B", Cwd: "/tmp", TmuxSession: "uam-fake-bbbb2222", State: adapter.Active, ProcAlive: adapter.Alive, CreatedAt: time.Now(), PR: &adapter.PRRef{URL: "https://github.com/o/r/pull/2", Number: 2, Status: adapter.PROpen}} + live := adapter.Session{ID: "bbbb2222", AgentType: "fake", DisplayName: "B", Cwd: "/tmp", SessionName: "uam-fake-bbbb2222", State: adapter.Active, ProcAlive: adapter.Alive, CreatedAt: time.Now(), PR: &adapter.PRRef{URL: "https://github.com/o/r/pull/2", Number: 2, Status: adapter.PROpen}} svc, _, _ := newLoadService(t, []adapter.Session{live}) var wg sync.WaitGroup diff --git a/internal/app/registry_parity_test.go b/internal/app/registry_parity_test.go index 050a5d2..0887f56 100644 --- a/internal/app/registry_parity_test.go +++ b/internal/app/registry_parity_test.go @@ -8,7 +8,7 @@ import ( "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter" "github.com/RandomCodeSpace/unified-agent-manager/internal/agents" - "github.com/RandomCodeSpace/unified-agent-manager/internal/tmux" + "github.com/RandomCodeSpace/unified-agent-manager/internal/session" ) // F14 — app.New builds its registry from the shared agents.Default list (it used @@ -26,7 +26,7 @@ func TestAppRegistryMatchesSharedAdapterSet(t *testing.T) { } t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) - client := tmux.New("uam") + client := session.NewClient() reg := adapter.NewRegistry(agents.Default(client)) got := make([]string, 0) diff --git a/internal/app/rename_input_test.go b/internal/app/rename_input_test.go index 4be27ff..5ad6565 100644 --- a/internal/app/rename_input_test.go +++ b/internal/app/rename_input_test.go @@ -65,8 +65,8 @@ func TestRenameEnterOnEmptiedListDoesNotPanic(t *testing.T) { // target session id is snapshotted at startRename and resolved at Enter. func TestRenameTargetsOriginalSessionAfterReorder(t *testing.T) { live := []adapter.Session{ - {ID: "alpha", AgentType: "fake", DisplayName: "alpha", TmuxSession: "uam-fake-alpha", State: adapter.Active, CreatedAt: time.Now()}, - {ID: "beta", AgentType: "fake", DisplayName: "beta", TmuxSession: "uam-fake-beta", State: adapter.Active, CreatedAt: time.Now()}, + {ID: "alpha", AgentType: "fake", DisplayName: "alpha", SessionName: "uam-fake-alpha", State: adapter.Active, CreatedAt: time.Now()}, + {ID: "beta", AgentType: "fake", DisplayName: "beta", SessionName: "uam-fake-beta", State: adapter.Active, CreatedAt: time.Now()}, } m, st := renameTestModel(t, live) m.selected = 0 @@ -155,8 +155,8 @@ func TestWizardInputAcceptsMultibyteAndIgnoresAlt(t *testing.T) { // opened, not whatever row a refresh reorder slid under the cursor. func TestStopConfirmTargetsOriginalSessionAfterReorder(t *testing.T) { live := []adapter.Session{ - {ID: "alpha", AgentType: "fake", DisplayName: "alpha", TmuxSession: "uam-fake-alpha", State: adapter.Active, CreatedAt: time.Now()}, - {ID: "beta", AgentType: "fake", DisplayName: "beta", TmuxSession: "uam-fake-beta", State: adapter.Active, CreatedAt: time.Now()}, + {ID: "alpha", AgentType: "fake", DisplayName: "alpha", SessionName: "uam-fake-alpha", State: adapter.Active, CreatedAt: time.Now()}, + {ID: "beta", AgentType: "fake", DisplayName: "beta", SessionName: "uam-fake-beta", State: adapter.Active, CreatedAt: time.Now()}, } m, st := renameTestModel(t, live) m.selected = 0 diff --git a/internal/app/reply_test.go b/internal/app/reply_test.go index 3bf7654..c46a87a 100644 --- a/internal/app/reply_test.go +++ b/internal/app/reply_test.go @@ -17,7 +17,7 @@ func replyTestModel(t *testing.T) (Model, *svcFakeAdapter) { if err != nil { t.Fatal(err) } - sessions := []adapter.Session{{ID: "abc12345", AgentType: "fake", DisplayName: "live", Cwd: "/tmp", TmuxSession: "uam-fake-abc12345", State: adapter.Active, ProcAlive: adapter.Alive, CreatedAt: time.Now()}} + sessions := []adapter.Session{{ID: "abc12345", AgentType: "fake", DisplayName: "live", Cwd: "/tmp", SessionName: "uam-fake-abc12345", State: adapter.Active, ProcAlive: adapter.Alive, CreatedAt: time.Now()}} fake := &svcFakeAdapter{name: "fake", available: true, sessions: sessions} m := NewWithDeps(st, adapter.NewRegistry([]adapter.AgentAdapter{fake})) m.sessions = append([]adapter.Session(nil), sessions...) diff --git a/internal/app/service.go b/internal/app/service.go index e22bc5f..f3e6c83 100644 --- a/internal/app/service.go +++ b/internal/app/service.go @@ -69,30 +69,29 @@ func (s *Service) LoadSessions(ctx context.Context) ([]adapter.Session, store.Co return out, cfg, nil } -// PruneStartup removes long-stale, dead-pane records from the store so -// sessions.json does not grow unbounded. It is server-down-safe: a tmux server -// that is down looks exactly like an empty one (zero live sessions), so with no -// live session to prove the server is up it skips pruning entirely rather than -// risk wiping every record during a transient outage. When at least one live -// session is visible the server is up, and PruneOld then drops records whose -// pane is gone and that have not been seen within pruneMaxAge (F20 Stage 2). +// PruneStartup removes long-stale, dead records from the store so +// sessions.json does not grow unbounded. It is outage-safe: pruning only runs +// when at least one live session is visible, so a transient failure to scan +// the session runtime directory (which looks exactly like "no sessions") can +// never wipe every record. PruneOld then drops records whose process is gone +// and that have not been seen within pruneMaxAge (F20 Stage 2). func (s *Service) PruneStartup(ctx context.Context) error { if s.Store == nil { return nil } live := s.liveSessions(ctx) if len(live) == 0 { - // Unknown server state — don't prune. + // No live session visible — could be a scan failure; don't prune. return nil } - liveTmux := make(map[string]struct{}, len(live)) + liveNames := make(map[string]struct{}, len(live)) for _, sess := range live { - if sess.TmuxSession != "" { - liveTmux[sess.TmuxSession] = struct{}{} + if sess.SessionName != "" { + liveNames[sess.SessionName] = struct{}{} } } - exists := func(tmuxName string) bool { - _, ok := liveTmux[tmuxName] + exists := func(sessionName string) bool { + _, ok := liveNames[sessionName] return ok } return s.Store.Update(func(cfg *store.Config) error { @@ -156,9 +155,18 @@ func (s *Service) mergeStoredSessions(live map[string]adapter.Session, cfg store } func mergeStoredMetadata(sess adapter.Session, rec store.SessionRecord) adapter.Session { + // A live session only knows the 8-char id embedded in its session name; + // the record carries the full UUID. Restore it so Find can match the full + // id the dispatch command printed — without this, peek/stop/attach by full + // id fail exactly while the session is alive (they worked once it died, + // because dead rows are built from the record). + if rec.ID != "" && strings.HasPrefix(rec.ID, sess.ID) { + sess.ID = rec.ID + } sess.DisplayName = firstNonEmpty(rec.Name, sess.DisplayName) sess.CommandAlias = firstNonEmpty(rec.CommandAlias, sess.CommandAlias) sess.Prompt = firstNonEmpty(rec.Prompt, sess.Prompt) + sess.ProviderSessionID = firstNonEmpty(rec.ProviderSessionID, sess.ProviderSessionID) sess.Pinned = rec.Pinned sess.Group = rec.Group sess.SortIndex = rec.SortIndex @@ -183,7 +191,7 @@ func mergeStoredMetadata(sess adapter.Session, rec store.SessionRecord) adapter. } func deadSessionFromRecord(rec store.SessionRecord, now time.Time) adapter.Session { - return adapter.Session{ID: rec.ID, AgentType: rec.Agent, CommandAlias: rec.CommandAlias, DisplayName: rec.Name, Prompt: rec.Prompt, Cwd: rec.Workdir, TmuxSession: rec.TmuxSession, State: adapter.Failed, ProcAlive: adapter.Exited, CreatedAt: rec.CreatedAt, LastChange: now, Pinned: rec.Pinned, Group: rec.Group, SortIndex: rec.SortIndex, Closed: rec.Status == store.StatusClosedByUser} + return adapter.Session{ExitCode: rec.LastExitCode, ProviderSessionID: rec.ProviderSessionID, ID: rec.ID, AgentType: rec.Agent, CommandAlias: rec.CommandAlias, DisplayName: rec.Name, Prompt: rec.Prompt, Cwd: rec.Workdir, SessionName: rec.SessionName, State: adapter.Failed, ProcAlive: adapter.Exited, CreatedAt: rec.CreatedAt, LastChange: now, Pinned: rec.Pinned, Group: rec.Group, SortIndex: rec.SortIndex, Closed: rec.Status == store.StatusClosedByUser} } // refreshSessionRecords reconciles live sessions against the loaded config and @@ -377,7 +385,7 @@ func (s *Service) Stop(ctx context.Context, id string, remove bool) error { } func (s *Service) stopAdapterSession(ctx context.Context, sess adapter.Session) error { - if s.Registry == nil || sess.TmuxSession == "" { + if s.Registry == nil || sess.SessionName == "" { return nil } a, ok := s.Registry.Get(sess.AgentType) @@ -397,21 +405,20 @@ func (s *Service) stopAdapterSession(ctx context.Context, sess adapter.Session) return nil } -// NotifyClosed flags the record whose tmux name matches tmuxSession as -// user-closed. It is the entry point for the `uam notify-closed` CLI -// subcommand wired into tmux's session-closed hook: when the user types -// `exit` in a session (or someone runs `tmux -L uam kill-session`), tmux -// destroys the session and fires this so the status survives the close. +// NotifyClosed flags the record whose backend session name matches as +// user-closed. It backs the `uam notify-closed` CLI subcommand. Session hosts +// normally mark records closed in-process when their agent exits +// (store.MarkSessionClosed); this stays as the scriptable entry point. // // Idempotent: a no-op if the record is already StatusClosedByUser or if // no record matches (e.g., uam already deleted it via Ctrl+X / `uam rm`). -func (s *Service) NotifyClosed(tmuxSession string) error { - if s.Store == nil || tmuxSession == "" { +func (s *Service) NotifyClosed(sessionName string) error { + if s.Store == nil || sessionName == "" { return nil } return s.Store.Update(func(cfg *store.Config) error { for key, rec := range cfg.Sessions { - if rec.TmuxSession != tmuxSession { + if rec.SessionName != sessionName { continue } if rec.Status == store.StatusClosedByUser { @@ -482,13 +489,13 @@ func (s *Service) Find(ctx context.Context, id string) (adapter.Session, store.C return adapter.Session{}, cfg, err } for _, sess := range sessions { - if sess.ID == id || sess.TmuxSession == id { + if sess.ID == id || sess.SessionName == id { return sess, cfg, nil } } var match adapter.Session for _, sess := range sessions { - if strings.HasPrefix(sess.ID, id) || strings.HasPrefix(sess.TmuxSession, id) { + if strings.HasPrefix(sess.ID, id) || strings.HasPrefix(sess.SessionName, id) { if match.ID != "" { return adapter.Session{}, cfg, fmt.Errorf("session %q is ambiguous; matches multiple sessions", id) } @@ -542,8 +549,8 @@ func (s *Service) AttachSpec(ctx context.Context, id string) (adapter.AttachSpec return a.Attach(sess.ID) } -// ResumeBackground restarts a stopped session's tmux session without attaching -// to it. It is a no-op when the session is already running. +// ResumeBackground restarts a stopped session's backend session without +// attaching to it. It is a no-op when the session is already running. func (s *Service) ResumeBackground(ctx context.Context, id string) error { sess, cfg, err := s.Find(ctx, id) if err != nil { @@ -564,7 +571,7 @@ func (s *Service) ResumeBackground(ctx context.Context, id string) error { if rec.ID == "" { rec = RecordFromSession(sess, store.ModeYolo) } - resumed, err := resumable.Resume(ctx, adapter.ResumeRequest{ID: rec.ID, Name: rec.Name, CommandAlias: rec.CommandAlias, Prompt: rec.Prompt, Cwd: rec.Workdir, Mode: string(rec.Mode), TmuxSession: rec.TmuxSession, CreatedAt: rec.CreatedAt}) + resumed, err := resumable.Resume(ctx, adapter.ResumeRequest{ID: rec.ID, Name: rec.Name, CommandAlias: rec.CommandAlias, Prompt: rec.Prompt, Cwd: rec.Workdir, Mode: string(rec.Mode), SessionName: rec.SessionName, ProviderSessionID: rec.ProviderSessionID, CreatedAt: rec.CreatedAt}) if err != nil { return err } @@ -577,11 +584,12 @@ func (s *Service) ResumeBackground(ctx context.Context, id string) error { if rec.ID == "" { rec = RecordFromSession(resumed, store.ModeYolo) } - rec.TmuxSession = resumed.TmuxSession + rec.SessionName = resumed.SessionName rec.Workdir = resumed.Cwd rec.CommandAlias = firstNonEmpty(rec.CommandAlias, resumed.CommandAlias) + rec.ProviderSessionID = firstNonEmpty(resumed.ProviderSessionID, rec.ProviderSessionID) rec.LastSeenAt = time.Now() - // Resuming a closed_by_user session reactivates it. The tmux hook + // Resuming a closed_by_user session reactivates it. The session host // will flip Status back to closed_by_user on the next exit. rec.Status = store.StatusActive cfg.Sessions[key] = rec @@ -612,7 +620,7 @@ func RecordFromSession(sess adapter.Session, mode store.Mode) store.SessionRecor if sess.Closed { status = store.StatusClosedByUser } - return store.SessionRecord{ID: sess.ID, Agent: sess.AgentType, CommandAlias: sess.CommandAlias, Name: name, Prompt: sess.Prompt, Mode: mode, Workdir: sess.Cwd, TmuxSession: sess.TmuxSession, CreatedAt: sess.CreatedAt, LastSeenAt: time.Now(), Pinned: sess.Pinned, Group: sess.Group, SortIndex: sess.SortIndex, Status: status} + return store.SessionRecord{ID: sess.ID, Agent: sess.AgentType, CommandAlias: sess.CommandAlias, Name: name, Prompt: sess.Prompt, Mode: mode, Workdir: sess.Cwd, SessionName: sess.SessionName, ProviderSessionID: sess.ProviderSessionID, CreatedAt: sess.CreatedAt, LastSeenAt: time.Now(), Pinned: sess.Pinned, Group: sess.Group, SortIndex: sess.SortIndex, Status: status} } func (s *Service) UpdateSortOrder(sessions []adapter.Session) error { diff --git a/internal/app/service_test.go b/internal/app/service_test.go index 53cac6b..55f8cba 100644 --- a/internal/app/service_test.go +++ b/internal/app/service_test.go @@ -43,11 +43,11 @@ func (f *svcFakeAdapter) Dispatch(ctx adapter.Context, req adapter.DispatchReque if req.Prompt == "fail" { return adapter.Session{}, errors.New("fail") } - return adapter.Session{ID: "12345678", AgentType: f.name, CommandAlias: req.CommandAlias, DisplayName: firstNonEmpty(req.Name, req.Prompt, "untitled"), Prompt: req.Prompt, Cwd: firstNonEmpty(req.Cwd, "/tmp"), TmuxSession: "uam-" + f.name + "-12345678", State: adapter.Active, ProcAlive: adapter.Alive, CreatedAt: time.Now()}, nil + return adapter.Session{ID: "12345678", AgentType: f.name, CommandAlias: req.CommandAlias, DisplayName: firstNonEmpty(req.Name, req.Prompt, "untitled"), Prompt: req.Prompt, Cwd: firstNonEmpty(req.Cwd, "/tmp"), SessionName: "uam-" + f.name + "-12345678", State: adapter.Active, ProcAlive: adapter.Alive, CreatedAt: time.Now()}, nil } func (f *svcFakeAdapter) Resume(ctx adapter.Context, req adapter.ResumeRequest) (adapter.Session, error) { f.resumed = &req - return adapter.Session{ID: req.ID, AgentType: f.name, CommandAlias: req.CommandAlias, DisplayName: req.Name, Prompt: req.Prompt, Cwd: req.Cwd, TmuxSession: req.TmuxSession, State: adapter.Active, ProcAlive: adapter.Alive, CreatedAt: time.Now()}, nil + return adapter.Session{ID: req.ID, AgentType: f.name, CommandAlias: req.CommandAlias, DisplayName: req.Name, Prompt: req.Prompt, Cwd: req.Cwd, SessionName: req.SessionName, State: adapter.Active, ProcAlive: adapter.Alive, CreatedAt: time.Now()}, nil } func (f *svcFakeAdapter) List(ctx adapter.Context) ([]adapter.Session, error) { return f.sessions, f.listErr @@ -83,7 +83,7 @@ func newWorkflowService(t *testing.T) (*Service, *svcFakeAdapter) { if err != nil { t.Fatal(err) } - fake := &svcFakeAdapter{name: "fake", available: true, sessions: []adapter.Session{{ID: "live0001", AgentType: "fake", DisplayName: "live", Cwd: "/tmp", TmuxSession: "uam-fake-live0001", State: adapter.Active, CreatedAt: time.Now(), PR: &adapter.PRRef{URL: "https://github.com/o/r/pull/1", Number: 1, Status: adapter.PROpen}}}} + fake := &svcFakeAdapter{name: "fake", available: true, sessions: []adapter.Session{{ID: "live0001", AgentType: "fake", DisplayName: "live", Cwd: "/tmp", SessionName: "uam-fake-live0001", State: adapter.Active, CreatedAt: time.Now(), PR: &adapter.PRRef{URL: "https://github.com/o/r/pull/1", Number: 1, Status: adapter.PROpen}}}} return NewService(st, adapter.NewRegistry([]adapter.AgentAdapter{fake})), fake } @@ -117,8 +117,8 @@ func assertWorkflowLoadAndFind(t *testing.T, svc *Service, idPrefix string) []ad func TestServiceFindRejectsAmbiguousPrefix(t *testing.T) { fake := &svcFakeAdapter{name: "fake", available: true, sessions: []adapter.Session{ - {ID: "abc12345", AgentType: "fake", DisplayName: "one", TmuxSession: "uam-fake-abc12345", State: adapter.Active, CreatedAt: time.Now()}, - {ID: "abc67890", AgentType: "fake", DisplayName: "two", TmuxSession: "uam-fake-abc67890", State: adapter.Active, CreatedAt: time.Now()}, + {ID: "abc12345", AgentType: "fake", DisplayName: "one", SessionName: "uam-fake-abc12345", State: adapter.Active, CreatedAt: time.Now()}, + {ID: "abc67890", AgentType: "fake", DisplayName: "two", SessionName: "uam-fake-abc67890", State: adapter.Active, CreatedAt: time.Now()}, }} svc := NewService(nil, adapter.NewRegistry([]adapter.AgentAdapter{fake})) @@ -130,10 +130,10 @@ func TestServiceFindRejectsAmbiguousPrefix(t *testing.T) { func TestServiceFindExactMatchWinsOverAmbiguousPrefix(t *testing.T) { fake := &svcFakeAdapter{name: "fake", available: true, sessions: []adapter.Session{ - {ID: "abc", AgentType: "fake", DisplayName: "exact-id", TmuxSession: "uam-fake-exact", State: adapter.Active, CreatedAt: time.Now()}, - {ID: "abc67890", AgentType: "fake", DisplayName: "prefix-id", TmuxSession: "uam-fake-prefix", State: adapter.Active, CreatedAt: time.Now()}, - {ID: "def12345", AgentType: "fake", DisplayName: "exact-tmux", TmuxSession: "uam-fake-abc", State: adapter.Active, CreatedAt: time.Now()}, - {ID: "def67890", AgentType: "fake", DisplayName: "prefix-tmux", TmuxSession: "uam-fake-abc-extra", State: adapter.Active, CreatedAt: time.Now()}, + {ID: "abc", AgentType: "fake", DisplayName: "exact-id", SessionName: "uam-fake-exact", State: adapter.Active, CreatedAt: time.Now()}, + {ID: "abc67890", AgentType: "fake", DisplayName: "prefix-id", SessionName: "uam-fake-prefix", State: adapter.Active, CreatedAt: time.Now()}, + {ID: "def12345", AgentType: "fake", DisplayName: "exact-tmux", SessionName: "uam-fake-abc", State: adapter.Active, CreatedAt: time.Now()}, + {ID: "def67890", AgentType: "fake", DisplayName: "prefix-tmux", SessionName: "uam-fake-abc-extra", State: adapter.Active, CreatedAt: time.Now()}, }} svc := NewService(nil, adapter.NewRegistry([]adapter.AgentAdapter{fake})) @@ -227,7 +227,7 @@ func TestServicePersistsPromptAndReportsDeadTmuxRecord(t *testing.T) { func TestServicePersistsAndMergesCommandAlias(t *testing.T) { dir := t.TempDir() st, _ := store.Open(filepath.Join(dir, "sessions.json")) - fake := &svcFakeAdapter{name: "fake", available: true, sessions: []adapter.Session{{ID: "12345678", AgentType: "fake", DisplayName: "live", Cwd: "/tmp", TmuxSession: "uam-fake-12345678", State: adapter.Active, ProcAlive: adapter.Alive, CreatedAt: time.Now()}}} + fake := &svcFakeAdapter{name: "fake", available: true, sessions: []adapter.Session{{ID: "12345678", AgentType: "fake", DisplayName: "live", Cwd: "/tmp", SessionName: "uam-fake-12345678", State: adapter.Active, ProcAlive: adapter.Alive, CreatedAt: time.Now()}}} svc := NewService(st, adapter.NewRegistry([]adapter.AgentAdapter{fake})) if _, err := svc.DispatchNamedWithAlias(context.Background(), "fake", "ghcp", "bugfix", "fix parser", "/tmp/project", "yolo"); err != nil { t.Fatal(err) @@ -255,7 +255,7 @@ func TestAttachSpecResumesDeadSessionFromMetadata(t *testing.T) { SchemaVersion: store.CurrentSchemaVersion, DefaultAgent: "fake", Sessions: map[string]store.SessionRecord{ - "fake:abc12345": {ID: "abc12345-dead-beef-cafe-0123456789ab", Agent: "fake", CommandAlias: "ghcp", Name: "bugfix", Prompt: "fix parser", Mode: store.ModeYolo, Workdir: "/tmp/project", TmuxSession: "uam-fake-abc12345", CreatedAt: created}, + "fake:abc12345": {ID: "abc12345-dead-beef-cafe-0123456789ab", Agent: "fake", CommandAlias: "ghcp", Name: "bugfix", Prompt: "fix parser", Mode: store.ModeYolo, Workdir: "/tmp/project", SessionName: "uam-fake-abc12345", CreatedAt: created}, }, }); err != nil { t.Fatal(err) @@ -271,7 +271,7 @@ func TestAttachSpecResumesDeadSessionFromMetadata(t *testing.T) { if fake.resumed == nil { t.Fatal("dead metadata-backed session should be resumed before attach") } - if fake.resumed.ID != "abc12345-dead-beef-cafe-0123456789ab" || fake.resumed.Name != "bugfix" || fake.resumed.CommandAlias != "ghcp" || fake.resumed.Prompt != "fix parser" || fake.resumed.Cwd != "/tmp/project" || fake.resumed.Mode != "yolo" || fake.resumed.TmuxSession != "uam-fake-abc12345" { + if fake.resumed.ID != "abc12345-dead-beef-cafe-0123456789ab" || fake.resumed.Name != "bugfix" || fake.resumed.CommandAlias != "ghcp" || fake.resumed.Prompt != "fix parser" || fake.resumed.Cwd != "/tmp/project" || fake.resumed.Mode != "yolo" || fake.resumed.SessionName != "uam-fake-abc12345" { t.Fatalf("resume metadata = %+v", fake.resumed) } } @@ -283,7 +283,7 @@ func TestSortSessionsAndRecord(t *testing.T) { if sessions[0].ID != "p" || sessions[1].ID != "live" { t.Fatalf("order=%+v", sessions) } - rec := RecordFromSession(adapter.Session{ID: "id", AgentType: "fake", CommandAlias: "ghcp", Prompt: "do work", Cwd: "/tmp", TmuxSession: "tm", CreatedAt: now}, "") + rec := RecordFromSession(adapter.Session{ID: "id", AgentType: "fake", CommandAlias: "ghcp", Prompt: "do work", Cwd: "/tmp", SessionName: "tm", CreatedAt: now}, "") if rec.Mode != store.ModeYolo || rec.Name != "id" || rec.CommandAlias != "ghcp" || rec.Prompt != "do work" { t.Fatalf("rec=%+v", rec) } @@ -542,7 +542,7 @@ func TestDispatchReturnsLiveSessionOnPersistFailure(t *testing.T) { if !strings.Contains(err.Error(), sess.ID) { t.Fatalf("advisory error should reference the live session id %q: %v", sess.ID, err) } - if sess.ID == "" || sess.TmuxSession == "" { + if sess.ID == "" || sess.SessionName == "" { t.Fatalf("live session must be returned despite persist failure: %+v", sess) } if fake.stopped { @@ -575,7 +575,7 @@ func TestDispatchPersistsRequestedMode(t *testing.T) { // dies. We drive the no-record path directly through renameRecord so the test is // independent of the refresh backfill (which would otherwise mask the bug). func TestRenameLiveSessionWithoutStoreRecordBackfillsRecord(t *testing.T) { - live := adapter.Session{ID: "aaaa1111", AgentType: "fake", DisplayName: "A", Cwd: "/tmp", TmuxSession: "uam-fake-aaaa1111", State: adapter.Active, ProcAlive: adapter.Alive, CreatedAt: time.Now()} + live := adapter.Session{ID: "aaaa1111", AgentType: "fake", DisplayName: "A", Cwd: "/tmp", SessionName: "uam-fake-aaaa1111", State: adapter.Active, ProcAlive: adapter.Alive, CreatedAt: time.Now()} svc, st, _ := newLoadService(t, []adapter.Session{live}) // Empty store: no record for the live session. if err := st.Save(store.DefaultConfig()); err != nil { @@ -586,7 +586,7 @@ func TestRenameLiveSessionWithoutStoreRecordBackfillsRecord(t *testing.T) { } cfg, _ := st.Load() rec := cfg.Sessions[store.Key("fake", "aaaa1111")] - if rec.ID != "aaaa1111" || rec.TmuxSession != "uam-fake-aaaa1111" { + if rec.ID != "aaaa1111" || rec.SessionName != "uam-fake-aaaa1111" { t.Fatalf("rename must backfill a full record, got %+v", rec) } if rec.Name != "renamed" { @@ -595,7 +595,7 @@ func TestRenameLiveSessionWithoutStoreRecordBackfillsRecord(t *testing.T) { } func TestTogglePinLiveSessionWithoutStoreRecordBackfillsRecord(t *testing.T) { - live := adapter.Session{ID: "bbbb2222", AgentType: "fake", DisplayName: "B", Cwd: "/tmp", TmuxSession: "uam-fake-bbbb2222", State: adapter.Active, ProcAlive: adapter.Alive, CreatedAt: time.Now()} + live := adapter.Session{ID: "bbbb2222", AgentType: "fake", DisplayName: "B", Cwd: "/tmp", SessionName: "uam-fake-bbbb2222", State: adapter.Active, ProcAlive: adapter.Alive, CreatedAt: time.Now()} svc, st, _ := newLoadService(t, []adapter.Session{live}) if err := st.Save(store.DefaultConfig()); err != nil { t.Fatal(err) @@ -605,7 +605,7 @@ func TestTogglePinLiveSessionWithoutStoreRecordBackfillsRecord(t *testing.T) { } cfg, _ := st.Load() rec := cfg.Sessions[store.Key("fake", "bbbb2222")] - if rec.ID != "bbbb2222" || rec.TmuxSession != "uam-fake-bbbb2222" { + if rec.ID != "bbbb2222" || rec.SessionName != "uam-fake-bbbb2222" { t.Fatalf("toggle pin must backfill a full record, got %+v", rec) } if !rec.Pinned { diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 819c1bd..f7439e5 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -17,8 +17,8 @@ import ( "github.com/RandomCodeSpace/unified-agent-manager/internal/agents" "github.com/RandomCodeSpace/unified-agent-manager/internal/app" "github.com/RandomCodeSpace/unified-agent-manager/internal/log" + "github.com/RandomCodeSpace/unified-agent-manager/internal/session" "github.com/RandomCodeSpace/unified-agent-manager/internal/store" - "github.com/RandomCodeSpace/unified-agent-manager/internal/tmux" "github.com/RandomCodeSpace/unified-agent-manager/internal/version" ) @@ -57,8 +57,8 @@ func Usage() { fmt.Fprintln(os.Stderr, " uam peek ") fmt.Fprintln(os.Stderr, " uam stop ") fmt.Fprintln(os.Stderr, " uam rm ") - fmt.Fprintln(os.Stderr, " uam kill-all stop the private tmux server and all sessions") - fmt.Fprintln(os.Stderr, " uam notify-closed (internal: tmux session-closed hook)") + fmt.Fprintln(os.Stderr, " uam kill-all stop every managed session") + fmt.Fprintln(os.Stderr, " uam notify-closed (internal: flag a record user-closed)") } // Run executes the CLI using the default Bubble Tea TUI runner. @@ -106,7 +106,14 @@ func runCommand(ctx context.Context, svc *app.Service, args []string, runTUI fun case "notify-closed": return runNotifyClosed(svc, args[1:]) case "kill-all": - return runKillAll(ctx, tmux.New("uam").KillServer) + return runKillAll(ctx, session.NewClient().KillAll) + case "__host": + // Internal: the detached per-session host process (see + // internal/session). Spawned by CreateSession, never typed by hand. + return session.RunHost(args[1:]) + case "__attach": + // Internal: the interactive attach client run by AttachSpec argv. + return session.RunAttach(args[1:]) case "attach": id, err := requireArg(args[1:], "attach requires ") if err != nil { @@ -150,30 +157,27 @@ func runStop(ctx context.Context, svc *app.Service, cmd string, args []string) e return svc.Stop(ctx, id, cmd == "rm") } -// runNotifyClosed is invoked from tmux's session-closed hook via: -// -// tmux set-hook -g session-closed 'run-shell " notify-closed #{hook_session_name}"' -// -// It flags the matching record as user-closed so exit-in-session and external -// `tmux kill-session` calls aren't mistaken for reboot-killed sessions on -// the next uam launch. Idempotent; safe to run repeatedly. +// runNotifyClosed flags the matching record as user-closed. Session hosts +// mark records closed in-process when their agent exits, so uam itself no +// longer shells out to this; it stays for scripts and older tmux hooks that +// still call it. Idempotent; safe to run repeatedly. func runNotifyClosed(svc *app.Service, args []string) error { - tmuxName, err := requireArg(args, "notify-closed requires ") + name, err := requireArg(args, "notify-closed requires ") if err != nil { return err } - return svc.NotifyClosed(tmuxName) + return svc.NotifyClosed(name) } -// runKillAll tears down the private tmux server (and every managed session) via -// the injected killer. uam never auto-kills on TUI quit — reboot-recovery of -// dead-pane sessions is intentional — so this explicit command is the only -// teardown path (F24). The killer is idempotent on an already-dead server. +// runKillAll tears down every managed session via the injected killer. uam +// never auto-kills on TUI quit — reboot-recovery of dead sessions is +// intentional — so this explicit command is the only teardown path (F24). The +// killer is idempotent when nothing is running. func runKillAll(ctx context.Context, kill func(context.Context) error) error { if err := kill(ctx); err != nil { return fmt.Errorf("kill-all: %w", err) } - fmt.Println("uam tmux server stopped") + fmt.Println("all uam sessions stopped") return nil } @@ -215,10 +219,11 @@ func requireArg(args []string, message string) (string, error) { // NewService wires the app service and supported agent adapters. func NewService(st *store.Store) *app.Service { - client := tmux.New("uam") - // Let migration distinguish reboot-survivors (live pane) from user-stopped - // sessions (dead pane) so a v1->v2 upgrade does not auto-resume the latter - // on attach (F07). The store stays tmux-free; this only injects the probe. + client := session.NewClient() + // Let migration distinguish reboot-survivors (live session) from + // user-stopped sessions (dead) so a v1->v2 upgrade does not auto-resume the + // latter on attach (F07). The store stays backend-free; this only injects + // the probe. st.SetSessionProbe(func(name string) bool { return client.HasSession(context.Background(), name) }) @@ -334,7 +339,7 @@ func runNew(ctx context.Context, svc *app.Service) error { } fmt.Fprintln(os.Stderr, "warning:", err) } - fmt.Printf("dispatched %s (%s)\n", sess.ID, sess.TmuxSession) + fmt.Printf("dispatched %s (%s)\n", sess.ID, sess.SessionName) return nil } diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index bff0566..c7ac587 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -32,7 +32,7 @@ func (f *cliFakeAdapter) Dispatch(ctx adapter.Context, req adapter.DispatchReque if req.Prompt == "fail" { return adapter.Session{}, errors.New("fail") } - sess := adapter.Session{ID: "abc12345", AgentType: "fake", CommandAlias: req.CommandAlias, DisplayName: firstNonEmpty(req.Name, req.Prompt, "untitled"), Prompt: req.Prompt, Cwd: req.Cwd, TmuxSession: "uam-fake-abc12345", State: adapter.Active, ProcAlive: adapter.Alive, CreatedAt: time.Now()} + sess := adapter.Session{ID: "abc12345", AgentType: "fake", CommandAlias: req.CommandAlias, DisplayName: firstNonEmpty(req.Name, req.Prompt, "untitled"), Prompt: req.Prompt, Cwd: req.Cwd, SessionName: "uam-fake-abc12345", State: adapter.Active, ProcAlive: adapter.Alive, CreatedAt: time.Now()} f.sessions = append(f.sessions, sess) return sess, nil } @@ -153,7 +153,7 @@ func TestRunNotifyClosedFlagsRecord(t *testing.T) { if id == "" { t.Fatal("dispatch did not return an id") } - // Hook payload is the tmux session name, not the agent id. + // The notify payload is the backend session name, not the agent id. if err := runNotifyClosed(svc, []string{"uam-fake-" + id}); err != nil { t.Fatalf("notify-closed: %v", err) } @@ -173,7 +173,7 @@ func TestRunNotifyClosedFlagsRecord(t *testing.T) { func TestRunNotifyClosedRequiresName(t *testing.T) { svc, _ := newCLITestService(t) if err := runNotifyClosed(svc, nil); err == nil { - t.Fatal("notify-closed without tmux name should fail") + t.Fatal("notify-closed without a session name should fail") } } @@ -273,3 +273,17 @@ func firstNonEmpty(values ...string) string { } return "" } + +// The internal __host/__attach subcommands are routed through runCommand; +// invalid input must surface as errors rather than silently doing nothing. +func TestRunCommandInternalSubcommands(t *testing.T) { + t.Setenv("UAM_SESSION_DIR", t.TempDir()) + svc, _ := newCLITestService(t) + noTUI := func(context.Context, tea.Model) error { return nil } + if err := runCommand(context.Background(), svc, []string{"__host", "--name", "bad name", "--", "/bin/true"}, noTUI); err == nil { + t.Fatal("__host with an invalid name must fail") + } + if err := runCommand(context.Background(), svc, []string{"__attach"}, noTUI); err == nil { + t.Fatal("__attach without a session must fail") + } +} diff --git a/internal/cli/killall_test.go b/internal/cli/killall_test.go index 7fac36d..b5bb76a 100644 --- a/internal/cli/killall_test.go +++ b/internal/cli/killall_test.go @@ -3,14 +3,12 @@ package cli import ( "context" "errors" - "os" - "path/filepath" "testing" tea "github.com/charmbracelet/bubbletea" ) -// F24 — `uam kill-all` must invoke the tmux server teardown exactly once. +// F24 — `uam kill-all` must invoke the session teardown exactly once. func TestRunKillAllInvokesServerTeardown(t *testing.T) { calls := 0 kill := func(ctx context.Context) error { @@ -25,8 +23,8 @@ func TestRunKillAllInvokesServerTeardown(t *testing.T) { } } -// F24 — a teardown error must propagate so the user learns the server is still -// up (idempotency on a dead server is handled inside KillServer, not here). +// F24 — a teardown error must propagate so the user learns sessions are still +// up (idempotency when nothing is running is handled inside KillAll, not here). func TestRunKillAllPropagatesError(t *testing.T) { kill := func(ctx context.Context) error { return errors.New("boom") } if err := runKillAll(context.Background(), kill); err == nil { @@ -35,17 +33,11 @@ func TestRunKillAllPropagatesError(t *testing.T) { } // F24 — `kill-all` must be routed by runCommand to the default teardown path. -// A fake tmux (via UAM_TMUX_BIN) keeps the test off any real `uam` server and -// host-independent: it reports a dead server, which the idempotent KillServer -// treats as success. +// An empty session runtime dir (via UAM_SESSION_DIR) keeps the test off any +// real sessions: KillAll over zero sessions is an idempotent success. func TestRunCommandKillAllDispatches(t *testing.T) { svc, _ := newCLITestService(t) - dir := t.TempDir() - fakeTmux := filepath.Join(dir, "tmux") - if err := os.WriteFile(fakeTmux, []byte("#!/bin/sh\necho 'no server running on /tmp/tmux' >&2\nexit 1\n"), 0o755); err != nil { - t.Fatal(err) - } - t.Setenv("UAM_TMUX_BIN", fakeTmux) + t.Setenv("UAM_SESSION_DIR", t.TempDir()) out := captureCLIStdout(t, func() { if err := runCommand(context.Background(), svc, []string{"kill-all"}, func(context.Context, tea.Model) error { return nil }); err != nil { diff --git a/internal/cli/new_last_test.go b/internal/cli/new_last_test.go index c56ff6f..5dd6b52 100644 --- a/internal/cli/new_last_test.go +++ b/internal/cli/new_last_test.go @@ -134,9 +134,9 @@ func TestRunNewReadsCommandAliasBeforeWorkdir(t *testing.T) { func TestLastSeenIDSelectsMaxLastSeenAt(t *testing.T) { base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) cfg := store.Config{Sessions: map[string]store.SessionRecord{ - store.Key("fake", "aaaaaaaa"): {ID: "aaaaaaaa", Agent: "fake", TmuxSession: "uam-fake-aaaaaaaa", LastSeenAt: base}, - store.Key("fake", "bbbbbbbb"): {ID: "bbbbbbbb", Agent: "fake", TmuxSession: "uam-fake-bbbbbbbb", LastSeenAt: base.Add(2 * time.Hour)}, - store.Key("fake", "cccccccc"): {ID: "cccccccc", Agent: "fake", TmuxSession: "uam-fake-cccccccc", LastSeenAt: base.Add(time.Hour)}, + store.Key("fake", "aaaaaaaa"): {ID: "aaaaaaaa", Agent: "fake", SessionName: "uam-fake-aaaaaaaa", LastSeenAt: base}, + store.Key("fake", "bbbbbbbb"): {ID: "bbbbbbbb", Agent: "fake", SessionName: "uam-fake-bbbbbbbb", LastSeenAt: base.Add(2 * time.Hour)}, + store.Key("fake", "cccccccc"): {ID: "cccccccc", Agent: "fake", SessionName: "uam-fake-cccccccc", LastSeenAt: base.Add(time.Hour)}, }} if got := lastSeenID(cfg); got != "bbbbbbbb" { t.Fatalf("lastSeenID = %q, want bbbbbbbb (max last_seen_at)", got) diff --git a/internal/cli/registry_parity_test.go b/internal/cli/registry_parity_test.go index 7052648..19f9f45 100644 --- a/internal/cli/registry_parity_test.go +++ b/internal/cli/registry_parity_test.go @@ -7,8 +7,8 @@ import ( "testing" "github.com/RandomCodeSpace/unified-agent-manager/internal/agents" + "github.com/RandomCodeSpace/unified-agent-manager/internal/session" "github.com/RandomCodeSpace/unified-agent-manager/internal/store" - "github.com/RandomCodeSpace/unified-agent-manager/internal/tmux" ) // F14 — cli.NewService must register exactly the shared adapter set built by @@ -37,7 +37,7 @@ func TestNewServiceRegistryMatchesSharedAdapterSet(t *testing.T) { sort.Strings(got) want := make([]string, 0) - for _, a := range agents.Default(tmux.New("uam")) { + for _, a := range agents.Default(session.NewClient()) { want = append(want, a.Name()) } sort.Strings(want) diff --git a/internal/session/attach.go b/internal/session/attach.go new file mode 100644 index 0000000..f972fb8 --- /dev/null +++ b/internal/session/attach.go @@ -0,0 +1,292 @@ +package session + +import ( + "bufio" + "encoding/binary" + "errors" + "flag" + "fmt" + "io" + "net" + "os" + "os/signal" + "sync" + "syscall" + + "github.com/charmbracelet/x/term" +) + +// detachPrefix is the attach client's escape key (Ctrl+B, tmux's default +// prefix, kept for muscle memory). Prefix then `d` detaches; prefix twice +// sends a literal Ctrl+B to the agent. +const detachPrefix = 0x02 + +// ctrlZ is swallowed by the attach client: letting it through would SIGTSTP +// the agent inside its own detached session, where nothing can ever +// foreground it again — the same trap the old tmux config disarmed by +// binding C-z to a warning. +const ctrlZ = 0x1a + +// RunAttach is the entry point of `uam __attach`: it puts the terminal in raw +// mode and bridges it to a session host — the native replacement for +// `tmux attach`. It returns when the user detaches (Ctrl+B d, or a bare left +// arrow while nothing is typed — see stdinFilter) or the agent exits. +func RunAttach(args []string) error { + fs := flag.NewFlagSet("__attach", flag.ContinueOnError) + dir := fs.String("dir", DefaultDir(), "session runtime directory") + if err := fs.Parse(args); err != nil { + return err + } + if fs.NArg() < 1 { + return errors.New("attach requires a session name") + } + return runAttach(*dir, fs.Arg(0), os.Stdin, os.Stdout) +} + +func runAttach(dir, name string, stdin *os.File, stdout *os.File) error { + if err := ValidateName(name); err != nil { + return err + } + conn, err := net.Dial("unix", SocketPath(dir, name)) + if err != nil { + return fmt.Errorf("session %s is not running: %w", name, err) + } + defer func() { _ = conn.Close() }() + + cols, rows := 0, 0 + if w, h, err := term.GetSize(stdout.Fd()); err == nil { + cols, rows = w, h + } + if err := writeJSONLine(conn, request{Op: opAttach, Cols: cols, Rows: rows}); err != nil { + return fmt.Errorf("attach %s: %w", name, err) + } + br := bufio.NewReader(conn) + var resp response + if err := readJSONLine(br, &resp); err != nil { + return fmt.Errorf("attach %s: %w", name, err) + } + if !resp.OK { + return fmt.Errorf("attach %s: %s", name, resp.Err) + } + + restore := func() {} + if term.IsTerminal(stdin.Fd()) { + state, err := term.MakeRaw(stdin.Fd()) + if err != nil { + return fmt.Errorf("set raw mode: %w", err) + } + var once sync.Once + restore = func() { once.Do(func() { _ = term.Restore(stdin.Fd(), state) }) } + defer restore() + } + + winch := make(chan os.Signal, 1) + signal.Notify(winch, syscall.SIGWINCH) + defer signal.Stop(winch) + go func() { + for range winch { + if w, h, err := term.GetSize(stdout.Fd()); err == nil { + _ = writeFrame(conn, frameResize, resizePayload(w, h)) + } + } + }() + + // stdin → host. Runs in a goroutine because a blocked terminal read + // cannot be interrupted; when the session ends the process exits anyway. + detached := make(chan struct{}) + go func() { + pumpStdin(stdin, conn, backDetachEnabled()) + close(detached) + }() + + // host → terminal (the main loop): ends when the host closes the + // connection (agent exited) or the user detached. + done := make(chan error, 1) + go func() { + _, err := io.Copy(stdout, br) + done <- err + }() + var note string + select { + case <-detached: + _ = writeFrame(conn, frameDetach, nil) + note = "detached" + case <-done: + note = "session ended" + } + restore() + _, _ = fmt.Fprintf(stdout, "\r\n[uam: %s]\r\n", note) + return nil +} + +func resizePayload(cols, rows int) []byte { + // Clamp to uint16 range; the host rejects anything over 1000 anyway. + out := make([]byte, 4) + binary.BigEndian.PutUint16(out[0:2], uint16(max(0, min(cols, 0xffff)))) // #nosec G115 -- clamped + binary.BigEndian.PutUint16(out[2:4], uint16(max(0, min(rows, 0xffff)))) // #nosec G115 -- clamped + return out +} + +// backDetachEnabled reports whether the left-arrow quick detach is on. It is +// the default; UAM_ATTACH_BACK_DETACH=0 restores pure passthrough for agents +// that bind a bare left arrow themselves. +func backDetachEnabled() bool { + return os.Getenv("UAM_ATTACH_BACK_DETACH") != "0" +} + +// stdinFilter is the attach client's input state machine. Besides the detach +// chord and Ctrl+Z swallowing, it implements the Claude-Code-style quick +// detach: pressing the left arrow detaches when the agent's input box is +// (believed) empty. +// +// uam is a byte bridge and cannot see the agent's real input box, so "empty" +// is approximated locally: typedSinceClear flips on anything that could put +// text in the box (printables, tab, history/menu navigation via forwarded +// escape sequences) and resets on the keys that submit or clear it (Enter, +// Esc, Ctrl+C, Ctrl+U). A bare left arrow while clear detaches; inside a +// draft it keeps moving the cursor. Ctrl+B d always detaches regardless. +type stdinFilter struct { + backDetach bool + // pendingPrefix is set after Ctrl+B, waiting for the chord's second key. + pendingPrefix bool + // esc accumulates a partial escape sequence (possibly across reads). + esc []byte + // typedSinceClear approximates "the agent's input box is non-empty". + typedSinceClear bool +} + +// maxEscLen bounds escape-sequence accumulation; anything longer is flushed +// through verbatim rather than parsed. +const maxEscLen = 8 + +// pumpStdin forwards terminal input to the host, filtering the detach chord, +// Ctrl+Z, and (when enabled) the left-arrow quick detach. It returns when the +// user detaches or stdin/conn fails. +func pumpStdin(stdin io.Reader, conn net.Conn, backDetach bool) { + f := &stdinFilter{backDetach: backDetach} + buf := make([]byte, 4096) + for { + n, err := stdin.Read(buf) + if n > 0 { + out, detach := f.filter(buf[:n]) + if len(out) > 0 { + if werr := writeFrame(conn, frameStdin, out); werr != nil { + return + } + } + if detach { + return + } + } + if err != nil { + return + } + } +} + +// filter processes one stdin chunk, returning the bytes to forward and +// whether the user detached. On detach the returned bytes (anything typed +// before the detach key in the same chunk) must still be flushed first. +func (f *stdinFilter) filter(chunk []byte) (out []byte, detach bool) { + out = make([]byte, 0, len(chunk)+1) + for i, b := range chunk { + if f.pendingPrefix { + f.pendingPrefix = false + switch b { + case 'd': + return out, true + case detachPrefix: + out = append(out, detachPrefix) + f.typedSinceClear = true + default: + out = append(out, detachPrefix, b) + f.typedSinceClear = true + } + continue + } + if len(f.esc) > 0 { + var fired bool + out, fired = f.escByte(out, b) + if fired { + return out, true + } + continue + } + switch b { + case detachPrefix: + f.pendingPrefix = true + case ctrlZ: + // Swallowed; see ctrlZ doc. + case 0x1b: + f.esc = append(f.esc, b) + // Terminals write a full key's sequence atomically, so an ESC + // that ends the chunk is a bare Esc press, not a sequence start. + // Forward it immediately — delaying Esc would lag interrupts — + // and treat it as clearing the input box (Claude Code semantics). + if i == len(chunk)-1 { + out = append(out, 0x1b) + f.esc = nil + f.typedSinceClear = false + } + case '\r', '\n', 0x03, 0x15: + // Enter submits; Ctrl+C and Ctrl+U clear the input box. + out = append(out, b) + f.typedSinceClear = false + default: + out = append(out, b) + if b >= 0x20 || b == '\t' { + f.typedSinceClear = true + } + } + } + return out, false +} + +// escByte feeds one byte into a pending escape sequence. It returns the +// updated forward buffer and whether the left-arrow quick detach fired. +func (f *stdinFilter) escByte(out []byte, b byte) ([]byte, bool) { + f.esc = append(f.esc, b) + if !escComplete(f.esc) { + if len(f.esc) > maxEscLen { + out = append(out, f.esc...) + f.esc = nil + f.typedSinceClear = true + } + return out, false + } + seq := f.esc + f.esc = nil + if f.backDetach && !f.typedSinceClear && isLeftArrow(seq) { + return out, true + } + // Any other navigation may recall history or move through a menu, either + // of which can leave text in the input box — be conservative and require + // a fresh submit/clear before the quick detach re-arms. + out = append(out, seq...) + f.typedSinceClear = true + return out, false +} + +// escComplete reports whether esc (starting with ESC, len >= 2) is a full +// sequence: CSI (ESC [ ... final 0x40–0x7e), SS3 (ESC O x), or a two-byte +// alt/meta escape. +func escComplete(esc []byte) bool { + if len(esc) < 2 { + return false + } + switch esc[1] { + case '[': + return len(esc) > 2 && esc[len(esc)-1] >= 0x40 && esc[len(esc)-1] <= 0x7e + case 'O': + return len(esc) == 3 + default: + return true + } +} + +// isLeftArrow matches an unmodified left arrow: CSI D (normal) or SS3 D +// (application cursor mode). Modified arrows (e.g. shift-left, ESC[1;2D) are +// real edits and pass through. +func isLeftArrow(seq []byte) bool { + return string(seq) == "\x1b[D" || string(seq) == "\x1bOD" +} diff --git a/internal/session/attach_filter_test.go b/internal/session/attach_filter_test.go new file mode 100644 index 0000000..249ccfb --- /dev/null +++ b/internal/session/attach_filter_test.go @@ -0,0 +1,136 @@ +package session + +import ( + "bytes" + "testing" +) + +func runFilter(t *testing.T, f *stdinFilter, chunks ...string) (string, bool) { + t.Helper() + var out bytes.Buffer + for i, c := range chunks { + got, detach := f.filter([]byte(c)) + out.Write(got) + if detach { + if i != len(chunks)-1 { + t.Fatalf("detached early on chunk %d", i) + } + return out.String(), true + } + } + return out.String(), false +} + +func TestLeftArrowDetachesWhenNothingTyped(t *testing.T) { + f := &stdinFilter{backDetach: true} + out, detach := runFilter(t, f, "\x1b[D") + if !detach || out != "" { + t.Fatalf("fresh left arrow should detach cleanly, out=%q detach=%v", out, detach) + } +} + +func TestSS3LeftArrowAlsoDetaches(t *testing.T) { + f := &stdinFilter{backDetach: true} + if _, detach := runFilter(t, f, "\x1bOD"); !detach { + t.Fatal("application-cursor-mode left arrow should detach") + } +} + +func TestLeftArrowInsideDraftMovesCursor(t *testing.T) { + f := &stdinFilter{backDetach: true} + out, detach := runFilter(t, f, "abc", "\x1b[D") + if detach { + t.Fatal("left arrow inside a typed draft must not detach") + } + if out != "abc\x1b[D" { + t.Fatalf("draft cursor movement must pass through, out=%q", out) + } +} + +func TestEnterReArmsQuickDetach(t *testing.T) { + f := &stdinFilter{backDetach: true} + if _, detach := runFilter(t, f, "fix the bug\r"); detach { + t.Fatal("typing must not detach") + } + if _, detach := runFilter(t, f, "\x1b[D"); !detach { + t.Fatal("left arrow right after Enter should detach") + } +} + +// History/menu navigation can leave text in the agent's input box that uam +// cannot see; any forwarded escape sequence must disarm the quick detach +// until the next submit/clear. +func TestNavigationDisarmsUntilClear(t *testing.T) { + f := &stdinFilter{backDetach: true} + out, detach := runFilter(t, f, "\x1b[A") // up arrow: may recall history + if detach || out != "\x1b[A" { + t.Fatalf("up arrow must pass through, out=%q detach=%v", out, detach) + } + if _, detach := runFilter(t, f, "\x1b[D"); detach { + t.Fatal("left arrow after navigation must not detach") + } + // Bare Esc clears the input box (Claude Code semantics), is forwarded + // immediately, and re-arms the quick detach. + if out, detach := runFilter(t, f, "\x1b"); detach || out != "\x1b" { + t.Fatalf("bare Esc must pass through without detaching, out=%q detach=%v", out, detach) + } + if _, detach := runFilter(t, f, "\x1b[D"); !detach { + t.Fatal("left arrow after bare Esc should detach") + } +} + +func TestCtrlCAndCtrlUReArm(t *testing.T) { + for _, clear := range []string{"\x03", "\x15"} { + f := &stdinFilter{backDetach: true} + if _, detach := runFilter(t, f, "draft"+clear, "\x1b[D"); !detach { + t.Fatalf("left arrow after %q should detach", clear) + } + } +} + +func TestModifiedLeftArrowPassesThrough(t *testing.T) { + f := &stdinFilter{backDetach: true} + out, detach := runFilter(t, f, "\x1b[1;2D") // shift-left + if detach || out != "\x1b[1;2D" { + t.Fatalf("modified arrow must pass through, out=%q detach=%v", out, detach) + } +} + +func TestQuickDetachDisabledPassesArrowThrough(t *testing.T) { + f := &stdinFilter{backDetach: false} + out, detach := runFilter(t, f, "\x1b[D") + if detach || out != "\x1b[D" { + t.Fatalf("disabled quick detach must forward the arrow, out=%q detach=%v", out, detach) + } +} + +func TestSequenceSplitAcrossReadsStillDetaches(t *testing.T) { + f := &stdinFilter{backDetach: true} + if _, detach := runFilter(t, f, "\x1b[", "D"); !detach { + t.Fatal("left arrow split across reads should still detach") + } +} + +func TestChordStillDetachesWhenDirty(t *testing.T) { + f := &stdinFilter{backDetach: true} + out, detach := runFilter(t, f, "draft", "\x02d") + if !detach || out != "draft" { + t.Fatalf("Ctrl+B d must always detach, out=%q detach=%v", out, detach) + } +} + +func TestChordDoubledSendsLiteralPrefix(t *testing.T) { + f := &stdinFilter{backDetach: true} + out, detach := runFilter(t, f, "\x02\x02") + if detach || out != "\x02" { + t.Fatalf("Ctrl+B Ctrl+B should forward one literal prefix, out=%q", out) + } +} + +func TestCtrlZSwallowed(t *testing.T) { + f := &stdinFilter{backDetach: true} + out, detach := runFilter(t, f, "a\x1ab") + if detach || out != "ab" { + t.Fatalf("Ctrl+Z must be swallowed, out=%q", out) + } +} diff --git a/internal/session/client.go b/internal/session/client.go new file mode 100644 index 0000000..ef76d95 --- /dev/null +++ b/internal/session/client.go @@ -0,0 +1,343 @@ +package session + +import ( + "bufio" + "context" + "errors" + "fmt" + "net" + "os" + "os/exec" + "sort" + "strings" + "syscall" + "time" + + "github.com/RandomCodeSpace/unified-agent-manager/internal/execpath" + "github.com/RandomCodeSpace/unified-agent-manager/internal/log" +) + +// callTimeout is the upper bound on a single host round-trip. It is an upper +// bound, not a floor: a tighter caller deadline still wins. Without it a hung +// host could block a refresh indefinitely — the same contract the old +// tmuxCallTimeout enforced (F17). +const callTimeout = 10 * time.Second + +// createTimeout bounds how long CreateSession waits for a spawned host to +// report ready. Host startup is local fork/exec plus a PTY open, so this is +// generous; hitting it means the host wedged and gets cleaned up. +const createTimeout = 10 * time.Second + +// Client talks to per-session host processes. It is the drop-in replacement +// for the old tmux.Client: same operations, but against uam's own session +// hosts instead of a tmux server. +type Client struct { + // Dir is the runtime directory holding sockets and state files. + Dir string + // Exe overrides the binary used to spawn hosts and attach clients + // (normally the running uam binary itself). Tests point it at the test + // binary. + Exe string +} + +func NewClient() *Client { + return &Client{Dir: DefaultDir()} +} + +// exePath resolves the binary that will run `__host` / `__attach`. +func (c *Client) exePath() (string, error) { + exe := c.Exe + if exe == "" { + var err error + exe, err = os.Executable() + if err != nil { + return "", fmt.Errorf("resolve uam binary: %w", err) + } + } + if err := execpath.ValidateAbsoluteExecutable(exe); err != nil { + return "", fmt.Errorf("invalid uam binary for session host: %w", err) + } + return exe, nil +} + +// CreateSession spawns a detached host running command in cwd. It returns +// once the host reports the agent started (or with the host's startup error), +// mirroring the synchronous contract of `tmux new-session -d`. +func (c *Client) CreateSession(ctx context.Context, name, cwd string, env map[string]string, command []string) error { + if err := ValidateName(name); err != nil { + return fmt.Errorf("refusing to create session: %w", err) + } + if len(command) == 0 { + return errors.New("create session: empty command") + } + exe, err := c.exePath() + if err != nil { + return err + } + if err := EnsureDir(c.Dir); err != nil { + return err + } + args := []string{"__host", "--dir", c.Dir, "--name", name} + if cwd != "" { + args = append(args, "--cwd", cwd) + } + for _, k := range sortedKeys(env) { + args = append(args, "--env", k+"="+env[k]) + } + args = append(args, "--") + args = append(args, command...) + + r, w, err := os.Pipe() + if err != nil { + return fmt.Errorf("create readiness pipe: %w", err) + } + defer func() { _ = r.Close() }() + cmd := exec.Command(exe, args...) // #nosec G204 -- exe is the validated uam binary; args are built above without a shell. + // The host must outlive this process: detach it into its own session so + // TUI exit, terminal close, or Ctrl+C never propagates to running agents. + cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} + cmd.Env = append(os.Environ(), "UAM_HOST_READY_FD=3") + cmd.ExtraFiles = []*os.File{w} + if err := cmd.Start(); err != nil { + _ = w.Close() + return fmt.Errorf("spawn session host: %w", err) + } + _ = w.Close() + // Reap the host whenever it eventually exits so it never lingers as a + // zombie under a long-lived TUI process. + go func() { _ = cmd.Wait() }() + return waitReady(ctx, r, name, cmd.Process.Pid) +} + +func waitReady(ctx context.Context, r *os.File, name string, hostPID int) error { + type result struct { + line string + err error + } + ch := make(chan result, 1) + go func() { + line, err := bufio.NewReader(r).ReadString('\n') + ch <- result{line: strings.TrimSpace(line), err: err} + }() + select { + case res := <-ch: + if res.line == "ok" { + return nil + } + if msg, found := strings.CutPrefix(res.line, "error: "); found { + return fmt.Errorf("create session %s: %s", name, msg) + } + if res.err != nil { + return fmt.Errorf("create session %s: host exited before ready: %w", name, res.err) + } + return fmt.Errorf("create session %s: unexpected host response %q", name, res.line) + case <-time.After(createTimeout): + _ = syscall.Kill(hostPID, syscall.SIGKILL) + return fmt.Errorf("create session %s: host did not become ready", name) + case <-ctx.Done(): + _ = syscall.Kill(hostPID, syscall.SIGKILL) + return ctx.Err() + } +} + +func sortedKeys(m map[string]string) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +// List enumerates live sessions by scanning the runtime directory's state +// files — no subprocess, no socket round-trips. Leftovers from a crashed host +// are swept once both the host and its agent are gone. +func (c *Client) List(_ context.Context) ([]Info, error) { + entries, err := os.ReadDir(c.Dir) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + return nil, fmt.Errorf("scan session dir: %w", err) + } + var out []Info + for _, e := range entries { + name, isState := strings.CutSuffix(e.Name(), ".json") + if !isState || ValidateName(name) != nil { + continue + } + st, err := readState(c.Dir, name) + if err != nil { + log.Warn("skipping unreadable session state", "file", e.Name(), "error", err) + continue + } + if !st.hostAlive() { + if st.childAlive() { + // Host crashed but the agent is still winding down (it gets + // SIGHUP when the PTY master closed). Keep it visible and + // leave the files for the next sweep. + out = append(out, infoFromState(st)) + continue + } + removeSessionFiles(c.Dir, name) + continue + } + out = append(out, infoFromState(st)) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out, nil +} + +func infoFromState(st State) Info { + cwd := procCwd(st.ChildPID) + if cwd == "" { + cwd = st.Cwd + } + return Info{ + Name: st.Name, + CreatedUnix: st.CreatedUnix, + ChildPID: st.ChildPID, + Cwd: cwd, + Alive: st.childAlive(), + } +} + +// Capture returns the rendered tail of the session's terminal, like +// `tmux capture-pane -p -J` did. +func (c *Client) Capture(ctx context.Context, name string, lines int) (string, error) { + if lines <= 0 { + lines = 200 + } + resp, err := c.roundTrip(ctx, name, request{Op: opPeek, Lines: lines}) + if err != nil { + return "", err + } + return resp.Data, nil +} + +// SendLine types text into the session and submits it with a single Enter +// (carriage return). Interior newlines are delivered literally so a +// multi-line prompt lands in the agent's input buffer as one prompt — the +// same contract the tmux SendLine implemented keystroke-by-keystroke (F13). +func (c *Client) SendLine(ctx context.Context, name, text string) error { + payload := strings.TrimRight(text, "\n") + "\r" + _, err := c.roundTrip(ctx, name, request{Op: opSend, Text: payload}) + return err +} + +// Kill terminates the session's agent and waits for the host to confirm the +// session is gone. Killing a session that does not exist is an error, like +// `tmux kill-session` (callers that need idempotence probe HasSession). +func (c *Client) Kill(ctx context.Context, name string) error { + rtErr := func() error { + _, err := c.roundTrip(ctx, name, request{Op: opKill}) + return err + }() + if rtErr == nil { + return nil + } + st, stErr := readState(c.Dir, name) + if stErr != nil { + if errors.Is(stErr, os.ErrNotExist) { + // No state at all: nothing to kill. An error, like tmux + // kill-session on a missing target; callers needing idempotence + // probe HasSession (Service.Stop does). + return fmt.Errorf("kill session %s: %w", name, rtErr) + } + return stErr + } + // State exists but the socket path failed: the host is wedged, crashed, + // or mid-shutdown. Escalate directly and wait for both processes to go. + // The start-time-verified probes matter most here: a recycled PID must + // never be signalled as if it were the session. + if st.hostAlive() { + _ = syscall.Kill(st.HostPID, syscall.SIGTERM) + } else if st.childAlive() { + // Orphaned agent (host crashed): signal its process group directly. + if err := syscall.Kill(-st.ChildPID, syscall.SIGTERM); err != nil { + _ = syscall.Kill(st.ChildPID, syscall.SIGTERM) + } + } + deadline := time.Now().Add(callTimeout) + for time.Now().Before(deadline) { + if !st.childAlive() && !st.hostAlive() { + removeSessionFiles(c.Dir, name) + return nil + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(100 * time.Millisecond): + } + } + return fmt.Errorf("kill session %s: still running", name) +} + +// KillAll terminates every managed session. It replaces `tmux kill-server` +// and is idempotent: an empty (or missing) runtime directory is success. +func (c *Client) KillAll(ctx context.Context) error { + infos, err := c.List(ctx) + if err != nil { + return err + } + var firstErr error + for _, info := range infos { + if err := c.Kill(ctx, info.Name); err != nil && firstErr == nil { + firstErr = err + } + } + return firstErr +} + +// HasSession reports whether a live host exists for name. +func (c *Client) HasSession(_ context.Context, name string) bool { + st, err := readState(c.Dir, name) + return err == nil && st.hostAlive() +} + +// SetSessionLabel records the user-facing label for a live session; the host +// persists it and updates attached terminals' titles. Cosmetic: callers treat +// failures as non-fatal. +func (c *Client) SetSessionLabel(ctx context.Context, name, label string) error { + _, err := c.roundTrip(ctx, name, request{Op: opLabel, Label: label}) + return err +} + +// AttachArgv returns the argv that attaches the current terminal to the +// session — the uam binary's own attach client instead of `tmux attach`. +func (c *Client) AttachArgv(name string) ([]string, error) { + exe, err := c.exePath() + if err != nil { + return nil, err + } + return []string{exe, "__attach", "--dir", c.Dir, name}, nil +} + +func (c *Client) roundTrip(ctx context.Context, name string, req request) (response, error) { + if err := ValidateName(strings.TrimPrefix(name, "=")); err != nil { + return response{}, err + } + name = strings.TrimPrefix(name, "=") + ctx, cancel := context.WithTimeout(ctx, callTimeout) + defer cancel() + var d net.Dialer + conn, err := d.DialContext(ctx, "unix", SocketPath(c.Dir, name)) + if err != nil { + return response{}, fmt.Errorf("session %s: %w", name, err) + } + defer func() { _ = conn.Close() }() + if deadline, ok := ctx.Deadline(); ok { + _ = conn.SetDeadline(deadline) + } + if err := writeJSONLine(conn, req); err != nil { + return response{}, fmt.Errorf("session %s: send %s: %w", name, req.Op, err) + } + var resp response + if err := readJSONLine(bufio.NewReader(conn), &resp); err != nil { + return response{}, fmt.Errorf("session %s: read %s response: %w", name, req.Op, err) + } + if !resp.OK { + return resp, fmt.Errorf("session %s: %s failed: %s", name, req.Op, resp.Err) + } + return resp, nil +} diff --git a/internal/session/host.go b/internal/session/host.go new file mode 100644 index 0000000..e45eef4 --- /dev/null +++ b/internal/session/host.go @@ -0,0 +1,498 @@ +package session + +import ( + "bufio" + "errors" + "flag" + "fmt" + "net" + "os" + "os/exec" + "os/signal" + "strings" + "sync" + "syscall" + "time" + + "github.com/creack/pty" + + "github.com/RandomCodeSpace/unified-agent-manager/internal/log" + "github.com/RandomCodeSpace/unified-agent-manager/internal/store" + "github.com/RandomCodeSpace/unified-agent-manager/internal/vterm" +) + +// Default PTY geometry for a detached session, matching the old +// `tmux new-session -x 200 -y 50` so unattached agents render wide output the +// same way they used to. The first attach resizes to the real terminal. +const ( + defaultCols = 200 + defaultRows = 50 +) + +// historyLines is the scrollback capacity of the host's terminal emulator. +// Deliberately larger than tmux's default 2000-line history costs nothing +// here (plain runes, no attributes) and gives peek deeper context. +const historyLines = 4000 + +// killGrace phases the kill escalation: SIGHUP immediately (what tmux +// kill-session delivered), SIGTERM after one grace period, SIGKILL after two. +const killGrace = 1500 * time.Millisecond + +// attachBufFrames is the per-client broadcast buffer. A client that falls +// this far behind the PTY stream (dead TCP-equivalent: a wedged terminal) is +// disconnected rather than allowed to stall the session. +const attachBufFrames = 512 + +// RunHost is the entry point of the detached per-session host process +// (`uam __host`). It starts the agent command under a PTY, mirrors all output +// into a terminal emulator (for peek/replay), serves the control socket, and +// on agent exit marks the persisted record closed before cleaning up its +// runtime files. It only returns on fatal startup errors or after the agent +// exits. +func RunHost(args []string) error { + fs := flag.NewFlagSet("__host", flag.ContinueOnError) + dir := fs.String("dir", DefaultDir(), "session runtime directory") + name := fs.String("name", "", "session name") + cwd := fs.String("cwd", "", "working directory for the agent") + label := fs.String("label", "", "user-facing session label") + var envs stringList + fs.Var(&envs, "env", "KEY=VALUE environment entry (repeatable)") + if err := fs.Parse(args); err != nil { + return err + } + command := fs.Args() + ready := readyPipe() + err := runHost(*dir, *name, *cwd, *label, envs, command, ready) + if err != nil && ready != nil { + // Surface the startup failure to the waiting parent before exiting. + _, _ = fmt.Fprintf(ready, "error: %v\n", err) + _ = ready.Close() + } + return err +} + +// readyPipe returns the inherited readiness pipe (fd 3) when the host was +// spawned by a uam client, or nil when run by hand. +func readyPipe() *os.File { + if os.Getenv("UAM_HOST_READY_FD") != "3" { + return nil + } + return os.NewFile(3, "ready") +} + +type stringList []string + +func (s *stringList) String() string { return strings.Join(*s, ",") } +func (s *stringList) Set(v string) error { *s = append(*s, v); return nil } + +type host struct { + dir, name string + + mu sync.Mutex + term *vterm.Terminal + ptmx *os.File + label string + state State + clients map[*attachClient]struct{} + + child *exec.Cmd + // exited is closed once the agent process has been reaped; the kill + // escalation stops there. cleaned is closed after shutdown has also + // removed the runtime files, so a Kill reply means the session is fully + // gone — a List immediately after must not see leftovers. + exited chan struct{} + cleaned chan struct{} +} + +type attachClient struct { + conn net.Conn + out chan []byte + once sync.Once +} + +func (c *attachClient) drop() { + c.once.Do(func() { + close(c.out) + _ = c.conn.Close() + }) +} + +func runHost(dir, name, cwd, label string, envs, command []string, ready *os.File) error { + if err := ValidateName(name); err != nil { + return err + } + if len(command) == 0 { + return errors.New("host requires a command") + } + if err := EnsureDir(dir); err != nil { + return err + } + if st, err := readState(dir, name); err == nil && st.hostAlive() { + return fmt.Errorf("session %s already exists (host pid %d)", name, st.HostPID) + } + // Stale leftovers from a crashed host: safe to clear, the pid is gone. + removeSessionFiles(dir, name) + + ln, err := net.Listen("unix", SocketPath(dir, name)) + if err != nil { + return fmt.Errorf("listen %s: %w", SocketPath(dir, name), err) + } + defer func() { _ = ln.Close() }() + + h := &host{ + dir: dir, + name: name, + label: label, + term: vterm.New(defaultCols, defaultRows, historyLines), + clients: map[*attachClient]struct{}{}, + exited: make(chan struct{}), + cleaned: make(chan struct{}), + } + cmd := exec.Command(command[0], command[1:]...) // #nosec G204 -- argv comes from the trusted uam client that spawned this host; no shell is involved. + cmd.Dir = cwd + cmd.Env = append(os.Environ(), "TERM=xterm-256color") + cmd.Env = append(cmd.Env, envs...) + ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Cols: defaultCols, Rows: defaultRows}) + if err != nil { + return fmt.Errorf("start %s: %w", command[0], err) + } + h.ptmx = ptmx + h.child = cmd + h.state = State{ + Name: name, + HostPID: os.Getpid(), + HostStart: procStartTime(os.Getpid()), + ChildPID: cmd.Process.Pid, + ChildStart: procStartTime(cmd.Process.Pid), + CreatedUnix: time.Now().Unix(), + Cwd: cwd, + Label: label, + Command: command, + } + if err := writeState(dir, h.state); err != nil { + h.signalChild(syscall.SIGKILL) + return fmt.Errorf("write session state: %w", err) + } + if ready != nil { + _, _ = fmt.Fprintln(ready, "ok") + _ = ready.Close() + } + + go h.acceptLoop(ln) + go h.signalLoop() + go h.freshenLoop() + + h.pumpPTY() + + // PTY EOF: the agent exited (or the pty was torn down). Reap it. + exitCode := 0 + if waitErr := cmd.Wait(); waitErr != nil { + exitCode = -1 + var exitErr *exec.ExitError + if errors.As(waitErr, &exitErr) { + exitCode = exitErr.ExitCode() + } + } + close(h.exited) + h.shutdown(exitCode) + close(h.cleaned) + // Give pending kill responders a moment to flush their replies before the + // process (and every connection it owns) goes away. + time.Sleep(50 * time.Millisecond) + return nil +} + +// pumpPTY copies agent output into the emulator and to every attached client +// until the PTY reaches EOF (agent exit). +func (h *host) pumpPTY() { + buf := make([]byte, 32*1024) + for { + n, err := h.ptmx.Read(buf) + if n > 0 { + data := make([]byte, n) + copy(data, buf[:n]) + h.mu.Lock() + _, _ = h.term.Write(data) + for cl := range h.clients { + select { + case cl.out <- data: + default: + // Client stopped draining; cut it loose instead of + // blocking the whole session on one wedged terminal. + delete(h.clients, cl) + cl.drop() + } + } + h.mu.Unlock() + } + if err != nil { + return + } + } +} + +// freshenInterval is how often the host bumps its runtime files' timestamps. +// The default runtime dir lives under the shared temp dir, where +// systemd-tmpfiles removes entries untouched for ~10 days; periodic touches +// keep a long-idle session's socket and state file from being aged out (the +// same cleanup famously eats idle tmux sockets). +const freshenInterval = 6 * time.Hour + +// freshenLoop periodically re-stamps the state file and socket mtimes until +// the session shuts down. Best-effort: a failed touch only matters on systems +// that both age temp files and idle a session for days. +func (h *host) freshenLoop() { + ticker := time.NewTicker(freshenInterval) + defer ticker.Stop() + for { + select { + case <-h.exited: + return + case <-ticker.C: + now := time.Now() + for _, path := range []string{statePath(h.dir, h.name), SocketPath(h.dir, h.name)} { + if err := os.Chtimes(path, now, now); err != nil { + log.Debug("freshen runtime file failed", "path", path, "error", err) + } + } + } + } +} + +func (h *host) signalLoop() { + ch := make(chan os.Signal, 1) + signal.Notify(ch, syscall.SIGTERM, syscall.SIGINT, syscall.SIGHUP) + <-ch + // Forward termination to the agent; the normal exit path then runs. + h.terminateChild() +} + +func (h *host) acceptLoop(ln net.Listener) { + for { + conn, err := ln.Accept() + if err != nil { + return + } + go h.handleConn(conn) + } +} + +func (h *host) handleConn(conn net.Conn) { + br := bufio.NewReader(conn) + var req request + if err := readJSONLine(br, &req); err != nil { + _ = conn.Close() + return + } + switch req.Op { + case opPeek: + h.mu.Lock() + data := h.term.Capture(req.Lines) + h.mu.Unlock() + _ = writeJSONLine(conn, response{OK: true, Data: data}) + _ = conn.Close() + case opSend: + h.mu.Lock() + _, err := h.ptmx.Write([]byte(req.Text)) + h.mu.Unlock() + _ = writeJSONLine(conn, errResponse(err)) + _ = conn.Close() + case opResize: + h.applyResize(req.Cols, req.Rows) + _ = writeJSONLine(conn, response{OK: true}) + _ = conn.Close() + case opLabel: + h.setLabel(req.Label) + _ = writeJSONLine(conn, response{OK: true}) + _ = conn.Close() + case opKill: + h.terminateChild() + select { + case <-h.cleaned: + _ = writeJSONLine(conn, response{OK: true}) + case <-time.After(10 * time.Second): + _ = writeJSONLine(conn, response{Err: "session did not exit"}) + } + _ = conn.Close() + case opAttach: + h.handleAttach(conn, br, req) + default: + _ = writeJSONLine(conn, response{Err: fmt.Sprintf("unknown op %q", req.Op)}) + _ = conn.Close() + } +} + +func errResponse(err error) response { + if err != nil { + return response{Err: err.Error()} + } + return response{OK: true} +} + +func (h *host) setLabel(label string) { + h.mu.Lock() + h.label = label + h.state.Label = label + st := h.state + title := []byte(titleSequence(label)) + for cl := range h.clients { + select { + case cl.out <- title: + default: + } + } + h.mu.Unlock() + if err := writeState(h.dir, st); err != nil { + log.Warn("persist session label failed", "session", h.name, "error", err) + } +} + +// titleSequence sets the terminal title via OSC 0 — the native stand-in for +// tmux's set-titles-string showing the user-facing session label. +func titleSequence(label string) string { + clean := strings.Map(func(r rune) rune { + if r < 0x20 || r == 0x7f { + return -1 + } + return r + }, label) + return "\x1b]0;" + clean + "\x07" +} + +func (h *host) applyResize(cols, rows int) { + if cols <= 0 || rows <= 0 || cols > 1000 || rows > 1000 { + return + } + h.mu.Lock() + defer h.mu.Unlock() + h.term.Resize(cols, rows) + _ = pty.Setsize(h.ptmx, &pty.Winsize{Cols: uint16(cols), Rows: uint16(rows)}) // #nosec G115 -- bounds checked above +} + +func (h *host) handleAttach(conn net.Conn, br *bufio.Reader, req request) { + cl := &attachClient{conn: conn, out: make(chan []byte, attachBufFrames)} + h.mu.Lock() + label := h.label + h.mu.Unlock() + // The client is not registered yet, so this connection has no concurrent + // writer: the response line is safe to write directly. + if err := writeJSONLine(conn, response{OK: true, Data: label}); err != nil { + _ = conn.Close() + return + } + // Register and queue the screen replay atomically, so no live broadcast + // can interleave ahead of it. The replay paints the current screen + // immediately; the follow-up resize makes full-screen TUIs repaint + // themselves with real colors/attributes. + h.mu.Lock() + curCols, curRows := h.term.Size() + cl.out <- append([]byte(titleSequence(label)), h.term.Redraw()...) + h.clients[cl] = struct{}{} + h.mu.Unlock() + go h.attachWriter(cl) + if req.Cols > 0 && req.Rows > 0 { + if req.Cols == curCols && req.Rows == curRows { + // Same geometry produces no SIGWINCH; nudge the size so the + // agent's TUI still repaints for the new viewer. + h.applyResize(req.Cols, req.Rows-1) + } + h.applyResize(req.Cols, req.Rows) + } + h.attachReader(cl, br) +} + +func (h *host) attachWriter(cl *attachClient) { + for data := range cl.out { + if _, err := cl.conn.Write(data); err != nil { + h.dropClient(cl) + return + } + } +} + +func (h *host) attachReader(cl *attachClient, br *bufio.Reader) { + defer h.dropClient(cl) + for { + kind, payload, err := readFrame(br) + if err != nil { + return + } + switch kind { + case frameStdin: + h.mu.Lock() + _, werr := h.ptmx.Write(payload) + h.mu.Unlock() + if werr != nil { + return + } + case frameResize: + if len(payload) == 4 { + cols := int(payload[0])<<8 | int(payload[1]) + rows := int(payload[2])<<8 | int(payload[3]) + h.applyResize(cols, rows) + } + case frameDetach: + return + } + } +} + +func (h *host) dropClient(cl *attachClient) { + h.mu.Lock() + delete(h.clients, cl) + h.mu.Unlock() + cl.drop() +} + +// terminateChild escalates HUP → TERM → KILL against the agent's process +// group. SIGHUP first mirrors what tmux kill-session delivered, giving the +// agent a chance to save state. +func (h *host) terminateChild() { + go func() { + for _, sig := range []syscall.Signal{syscall.SIGHUP, syscall.SIGTERM, syscall.SIGKILL} { + h.signalChild(sig) + select { + case <-h.exited: + return + case <-time.After(killGrace): + } + } + }() +} + +func (h *host) signalChild(sig syscall.Signal) { + pid := h.state.ChildPID + if pid <= 0 { + return + } + // The child is its own session leader (pty.Start setsid), so its pid is + // also its process-group id; the negative target signals the whole group. + if err := syscall.Kill(-pid, sig); err != nil { + _ = syscall.Kill(pid, sig) + } +} + +// shutdown runs once the agent has been reaped: flag the persisted record +// closed (the native replacement for the tmux session-closed hook), tell any +// attached clients, and remove the runtime files. +func (h *host) shutdown(exitCode int) { + h.markClosed(exitCode) + h.mu.Lock() + for cl := range h.clients { + delete(h.clients, cl) + cl.drop() + } + h.mu.Unlock() + removeSessionFiles(h.dir, h.name) +} + +func (h *host) markClosed(exitCode int) { + st, err := store.Open(store.DefaultPath()) + if err != nil { + log.Warn("open store to mark session closed failed", "session", h.name, "error", err) + return + } + if err := st.MarkSessionClosed(h.name, exitCode); err != nil { + log.Warn("mark session closed failed", "session", h.name, "error", err) + } +} diff --git a/internal/session/host_inprocess_test.go b/internal/session/host_inprocess_test.go new file mode 100644 index 0000000..1a39ecb --- /dev/null +++ b/internal/session/host_inprocess_test.go @@ -0,0 +1,189 @@ +package session + +import ( + "bufio" + "context" + "net" + "os" + "strings" + "testing" + "time" +) + +// startInProcessHost runs runHost in a goroutine inside the test process so +// the host runtime (PTY pump, control ops, attach machinery, shutdown) is +// exercised under the coverage profiler — the normal test path spawns hosts +// as child processes, which Go coverage cannot observe. +func startInProcessHost(t *testing.T, c *Client, name, command string) chan error { + t.Helper() + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { + done <- runHost(c.Dir, name, t.TempDir(), "label0", []string{"UAM_T=1"}, []string{"/bin/sh", "-c", command}, w) + }() + line, err := bufio.NewReader(r).ReadString('\n') + _ = r.Close() + if err != nil || strings.TrimSpace(line) != "ok" { + t.Fatalf("host not ready: %q %v", line, err) + } + return done +} + +func TestInProcessHostLifecycle(t *testing.T) { + c := newTestClient(t) + ctx := context.Background() + name := "uam-fake-12121212" + done := startInProcessHost(t, c, name, `echo hello-cover; while read l; do echo "rt:$l"; done`) + + waitFor(t, "banner", func() bool { + out, err := c.Capture(ctx, name, 0) // 0 exercises the default-lines branch + return err == nil && strings.Contains(out, "hello-cover") + }) + if !c.HasSession(ctx, name) { + t.Fatal("HasSession should see the in-process host") + } + if err := c.SetSessionLabel(ctx, name, "covered · fake"); err != nil { + t.Fatalf("SetSessionLabel: %v", err) + } + if err := c.SendLine(ctx, name, "ping"); err != nil { + t.Fatalf("SendLine: %v", err) + } + waitFor(t, "echo", func() bool { + out, _ := c.Capture(ctx, name, 50) + return strings.Contains(out, "rt:ping") + }) + + // Attach over the socket: replay, live output, stdin frames, resize, and + // detach all flow through the host's attach machinery. + conn, err := net.Dial("unix", SocketPath(c.Dir, name)) + if err != nil { + t.Fatal(err) + } + defer func() { _ = conn.Close() }() + if err := writeJSONLine(conn, request{Op: opAttach, Cols: 120, Rows: 40}); err != nil { + t.Fatal(err) + } + br := bufio.NewReader(conn) + var resp response + if err := readJSONLine(br, &resp); err != nil || !resp.OK { + t.Fatalf("attach resp: %+v %v", resp, err) + } + if resp.Data != "covered · fake" { + t.Fatalf("attach label = %q", resp.Data) + } + if err := writeFrame(conn, frameStdin, []byte("from-attach\r")); err != nil { + t.Fatal(err) + } + if err := writeFrame(conn, frameResize, resizePayload(100, 30)); err != nil { + t.Fatal(err) + } + waitFor(t, "attach round trip", func() bool { + out, _ := c.Capture(ctx, name, 50) + return strings.Contains(out, "rt:from-attach") + }) + // Live output must reach the attached client (replay or broadcast). + sawOutput := make(chan struct{}) + go func() { + buf := make([]byte, 32*1024) + var got strings.Builder + for { + n, err := br.Read(buf) + if n > 0 { + got.WriteString(string(buf[:n])) + if strings.Contains(got.String(), "rt:from-attach") { + close(sawOutput) + return + } + } + if err != nil { + return + } + } + }() + select { + case <-sawOutput: + case <-time.After(10 * time.Second): + t.Fatal("attached client never saw live output") + } + if err := writeFrame(conn, frameDetach, nil); err != nil { + t.Fatal(err) + } + + // A second connection sending an unknown op exercises the error arm. + conn2, err := net.Dial("unix", SocketPath(c.Dir, name)) + if err != nil { + t.Fatal(err) + } + if err := writeJSONLine(conn2, request{Op: "bogus"}); err != nil { + t.Fatal(err) + } + var resp2 response + if err := readJSONLine(bufio.NewReader(conn2), &resp2); err != nil || resp2.OK { + t.Fatalf("unknown op should fail: %+v %v", resp2, err) + } + _ = conn2.Close() + + if err := c.Kill(ctx, name); err != nil { + t.Fatalf("Kill: %v", err) + } + select { + case err := <-done: + if err != nil { + t.Fatalf("runHost: %v", err) + } + case <-time.After(10 * time.Second): + t.Fatal("host did not exit after kill") + } + if c.HasSession(ctx, name) { + t.Fatal("session should be gone") + } +} + +func TestInProcessHostAgentExitCleansUp(t *testing.T) { + c := newTestClient(t) + name := "uam-fake-34343434" + done := startInProcessHost(t, c, name, "exit 7") + select { + case err := <-done: + if err != nil { + t.Fatalf("runHost: %v", err) + } + case <-time.After(10 * time.Second): + t.Fatal("host did not exit with its agent") + } + if _, err := os.Stat(SocketPath(c.Dir, name)); !os.IsNotExist(err) { + t.Fatal("socket should be removed after agent exit") + } +} + +func TestRunHostRejectsBadInput(t *testing.T) { + t.Setenv("UAM_SESSION_DIR", t.TempDir()) + if err := RunHost([]string{"--name", "bad name", "--", "/bin/true"}); err == nil { + t.Fatal("invalid name must fail") + } + if err := RunHost([]string{"--name", "uam-fake-56565656"}); err == nil { + t.Fatal("missing command must fail") + } + if err := RunHost([]string{"--bogus-flag"}); err == nil { + t.Fatal("bad flags must fail") + } +} + +func TestRunAttachArgErrors(t *testing.T) { + t.Setenv("UAM_SESSION_DIR", t.TempDir()) + if err := RunAttach([]string{}); err == nil { + t.Fatal("missing session name must fail") + } + if err := RunAttach([]string{"bad name"}); err == nil { + t.Fatal("invalid session name must fail") + } + if err := RunAttach([]string{"uam-fake-78787878"}); err == nil { + t.Fatal("attach to nonexistent session must fail") + } + if err := RunAttach([]string{"--bogus"}); err == nil { + t.Fatal("bad flags must fail") + } +} diff --git a/internal/session/liveness_test.go b/internal/session/liveness_test.go new file mode 100644 index 0000000..516c7d3 --- /dev/null +++ b/internal/session/liveness_test.go @@ -0,0 +1,202 @@ +package session + +import ( + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "syscall" + "testing" +) + +func TestDefaultDirResolution(t *testing.T) { + t.Setenv("UAM_SESSION_DIR", "/custom/dir") + if got := DefaultDir(); got != "/custom/dir" { + t.Fatalf("DefaultDir with override = %q", got) + } + t.Setenv("UAM_SESSION_DIR", "") + // XDG_RUNTIME_DIR must NOT be used: logind deletes it on logout while + // detached hosts keep running, which would strand live sessions. + t.Setenv("XDG_RUNTIME_DIR", "/run/user/1000") + want := filepath.Join(os.TempDir(), "uam-"+strconv.Itoa(os.Getuid())) + if got := DefaultDir(); got != want { + t.Fatalf("DefaultDir = %q, want per-uid temp dir %q", got, want) + } +} + +func TestEnsureDirRejectsNonDirectory(t *testing.T) { + path := filepath.Join(t.TempDir(), "occupied") + if err := os.WriteFile(path, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + if err := EnsureDir(path); err == nil { + t.Fatal("EnsureDir over a regular file must fail") + } +} + +func TestEnsureDirAcceptsOwnDirAndRestrictsMode(t *testing.T) { + dir := filepath.Join(t.TempDir(), "runtime") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := EnsureDir(dir); err != nil { + t.Fatalf("EnsureDir: %v", err) + } + info, err := os.Stat(dir) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o700 { + t.Fatalf("dir mode = %o, want 0700", info.Mode().Perm()) + } +} + +func TestProcStartTimeReadsSelf(t *testing.T) { + if _, err := os.Stat("/proc/self/stat"); err != nil { + t.Skip("no /proc on this platform") + } + if got := procStartTime(os.Getpid()); got <= 0 { + t.Fatalf("procStartTime(self) = %d, want > 0", got) + } + if got := procStartTime(0); got != 0 { + t.Fatalf("procStartTime(0) = %d, want 0", got) + } +} + +// A recorded start time that does not match the live process means the PID +// was recycled: the session must read as dead, not as someone else's process. +func TestProcAliveWithStartDetectsPIDReuse(t *testing.T) { + if _, err := os.Stat("/proc/self/stat"); err != nil { + t.Skip("no /proc on this platform") + } + pid := os.Getpid() + real := procStartTime(pid) + if !procAliveWithStart(pid, real) { + t.Fatal("matching start time must read alive") + } + if !procAliveWithStart(pid, 0) { + t.Fatal("zero recorded start must fall back to plain liveness") + } + if procAliveWithStart(pid, real+12345) { + t.Fatal("mismatched start time must read dead (recycled PID)") + } + if procAliveWithStart(-1, real) { + t.Fatal("invalid pid must read dead") + } +} + +// A stale state file whose PIDs were recycled by other processes (alive, but +// with different start times) must be swept by List, not reported live. +func TestListSweepsRecycledPIDState(t *testing.T) { + c := newTestClient(t) + if err := EnsureDir(c.Dir); err != nil { + t.Fatal(err) + } + if _, err := os.Stat("/proc/self/stat"); err != nil { + t.Skip("no /proc on this platform") + } + // PID 1 is always alive; a fabricated start time marks it as "not the + // process this state file recorded". + st := State{Name: "uam-fake-eeee9999", HostPID: 1, HostStart: 99999999999, ChildPID: 1, ChildStart: 99999999999, CreatedUnix: 1} + if err := writeState(c.Dir, st); err != nil { + t.Fatal(err) + } + infos, err := c.List(t.Context()) + if err != nil { + t.Fatalf("List: %v", err) + } + for _, info := range infos { + if info.Name == st.Name { + t.Fatalf("recycled-PID state must not be listed: %+v", info) + } + } + if _, err := os.Stat(statePath(c.Dir, st.Name)); !os.IsNotExist(err) { + t.Fatal("recycled-PID state file should be swept") + } +} + +// procStartTime must survive a comm containing spaces and parens, which +// /proc//stat embeds unescaped. +func TestProcStartTimeParsesHostileComm(t *testing.T) { + rest := "12345 (we ird) name)) R 1 1 1 0 -1 4194560 1 0 0 0 0 0 0 0 20 0 1 0 424242 0 0" + // Reuse the parsing logic indirectly by checking field extraction: after + // the last ')', starttime is the 20th field. + i := strings.LastIndexByte(rest, ')') + fields := strings.Fields(rest[i+1:]) + if len(fields) < 20 || fields[19] != "424242" { + t.Fatalf("stat layout assumption broken: %v", fields) + } +} + +func TestNewClientUsesDefaultDir(t *testing.T) { + t.Setenv("UAM_SESSION_DIR", "/custom/runtime") + if c := NewClient(); c.Dir != "/custom/runtime" { + t.Fatalf("NewClient dir = %q", c.Dir) + } +} + +func TestClientExePathValidation(t *testing.T) { + c := &Client{Dir: t.TempDir(), Exe: "/nonexistent/uam"} + if err := c.CreateSession(t.Context(), "uam-fake-90909090", t.TempDir(), nil, []string{"/bin/true"}); err == nil { + t.Fatal("invalid Exe must fail before spawning") + } + if _, err := c.AttachArgv("uam-fake-90909090"); err == nil { + t.Fatal("invalid Exe must fail AttachArgv") + } +} + +func TestRoundTripRejectsBadName(t *testing.T) { + c := &Client{Dir: t.TempDir()} + if _, err := c.Capture(t.Context(), "not a name", 10); err == nil { + t.Fatal("bad name must be rejected before dialing") + } + if err := c.SendLine(t.Context(), "=uam-fake-abcdef12", "x"); err == nil { + // "=" prefix is stripped (legacy exact-match syntax) and the dial + // then fails on the missing socket — an error either way, but the + // name itself must have been accepted. + t.Log("expected dial error") + } +} + +// Kill must escalate when the control socket is gone but processes remain: +// SIGTERM a live-but-socketless host, and signal the orphaned agent's process +// group directly when the host already died. +func TestKillEscalatesWithoutSocket(t *testing.T) { + c := newTestClient(t) + if err := EnsureDir(c.Dir); err != nil { + t.Fatal(err) + } + + // Case 1: "host" alive (a stand-in process) with no socket. + host := exec.Command("sleep", "60") + if err := host.Start(); err != nil { + t.Fatal(err) + } + go func() { _ = host.Wait() }() + st := State{Name: "uam-fake-a1a1a1a1", HostPID: host.Process.Pid, HostStart: procStartTime(host.Process.Pid), CreatedUnix: 1} + if err := writeState(c.Dir, st); err != nil { + t.Fatal(err) + } + if err := c.Kill(t.Context(), st.Name); err != nil { + t.Fatalf("Kill (wedged host): %v", err) + } + if ProcAlive(host.Process.Pid) && procStartTime(host.Process.Pid) == st.HostStart { + t.Fatal("wedged host should have been terminated") + } + + // Case 2: host dead, orphaned agent (own process group) still running. + child := exec.Command("sleep", "60") + child.SysProcAttr = &syscall.SysProcAttr{Setsid: true} + if err := child.Start(); err != nil { + t.Fatal(err) + } + go func() { _ = child.Wait() }() + st2 := State{Name: "uam-fake-b2b2b2b2", HostPID: 1 << 28, ChildPID: child.Process.Pid, ChildStart: procStartTime(child.Process.Pid), CreatedUnix: 1} + if err := writeState(c.Dir, st2); err != nil { + t.Fatal(err) + } + if err := c.Kill(t.Context(), st2.Name); err != nil { + t.Fatalf("Kill (orphan agent): %v", err) + } +} diff --git a/internal/session/proto.go b/internal/session/proto.go new file mode 100644 index 0000000..7ca3639 --- /dev/null +++ b/internal/session/proto.go @@ -0,0 +1,99 @@ +package session + +import ( + "bufio" + "encoding/binary" + "encoding/json" + "fmt" + "io" +) + +// Wire protocol between the client (uam TUI/CLI) and a session host. +// +// Control ops are one JSON request line answered by one JSON response line on +// a fresh connection. The "attach" op upgrades the connection after its +// response: the host then streams raw PTY output bytes to the client, and the +// client sends framed messages (stdin bytes, resizes, detach) to the host. +// Framing is only needed client→host, where three message kinds share the +// stream; host→client carries exactly one kind of data so it stays raw. + +type request struct { + Op string `json:"op"` + Text string `json:"text,omitempty"` + Lines int `json:"lines,omitempty"` + Cols int `json:"cols,omitempty"` + Rows int `json:"rows,omitempty"` + Label string `json:"label,omitempty"` +} + +type response struct { + OK bool `json:"ok"` + Err string `json:"err,omitempty"` + Data string `json:"data,omitempty"` +} + +const ( + opPeek = "peek" + opSend = "send" + opKill = "kill" + opLabel = "label" + opResize = "resize" + opAttach = "attach" +) + +// Attach stream frame types (client → host). +const ( + frameStdin byte = 0 + frameResize byte = 1 + frameDetach byte = 2 +) + +// maxFrameLen bounds a single client→host frame so a corrupt or hostile +// length prefix cannot make the host allocate unbounded memory. +const maxFrameLen = 1 << 20 + +func writeJSONLine(w io.Writer, v any) error { + data, err := json.Marshal(v) + if err != nil { + return err + } + _, err = w.Write(append(data, '\n')) + return err +} + +func readJSONLine(r *bufio.Reader, v any) error { + line, err := r.ReadBytes('\n') + if err != nil { + return err + } + return json.Unmarshal(line, v) +} + +func writeFrame(w io.Writer, kind byte, payload []byte) error { + hdr := [5]byte{kind} + binary.BigEndian.PutUint32(hdr[1:], uint32(len(payload))) // #nosec G115 -- payload length is bounded by callers + if _, err := w.Write(hdr[:]); err != nil { + return err + } + if len(payload) == 0 { + return nil + } + _, err := w.Write(payload) + return err +} + +func readFrame(r io.Reader) (byte, []byte, error) { + var hdr [5]byte + if _, err := io.ReadFull(r, hdr[:]); err != nil { + return 0, nil, err + } + n := binary.BigEndian.Uint32(hdr[1:]) + if n > maxFrameLen { + return 0, nil, fmt.Errorf("attach frame too large: %d bytes", n) + } + payload := make([]byte, n) + if _, err := io.ReadFull(r, payload); err != nil { + return 0, nil, err + } + return hdr[0], payload, nil +} diff --git a/internal/session/session.go b/internal/session/session.go new file mode 100644 index 0000000..117d9cf --- /dev/null +++ b/internal/session/session.go @@ -0,0 +1,224 @@ +// Package session is uam's native session backend. It replaces the private +// tmux server: every managed agent runs under a small detached "host" process +// (`uam __host`, see host.go) that owns the agent's PTY, renders its output +// through an in-process terminal emulator, and serves peek / reply / attach / +// kill over a per-session Unix socket. Hosts outlive the uam process that +// started them, so sessions keep running when the TUI exits — the same +// lifetime contract the tmux server provided — without requiring tmux to be +// installed. +package session + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "syscall" +) + +// ErrInvalidSessionName is returned when a session name fails the allow-list. +var ErrInvalidSessionName = fmt.Errorf("session name failed allow-list") + +// NameRE is the allow-list for session names uam may create. It matches the +// canonical shape minted by adapter.startSession ("uam--"): a +// lowercase-alphanumeric provider segment and a hex id segment. Names that +// pass are safe to embed in file paths (no separators, no dots). +var NameRE = regexp.MustCompile(`^uam-[a-z0-9]+-[0-9a-f]{1,16}$`) + +// ValidateName rejects session names outside the canonical allow-list. +func ValidateName(name string) error { + if !NameRE.MatchString(name) { + return fmt.Errorf("invalid session name %q: %w", name, ErrInvalidSessionName) + } + return nil +} + +// DefaultDir returns the runtime directory holding per-session sockets and +// state files: $UAM_SESSION_DIR if set, else a per-UID directory under the +// system temp dir (like tmux's /tmp/tmux-). +// +// $XDG_RUNTIME_DIR is deliberately NOT used: systemd-logind deletes it when +// the user's last login session ends, not only on reboot — which would strand +// still-running detached hosts (they survive logout) with no socket or state +// file, and a later "resume" would spawn duplicates. The temp dir survives +// logout and is cleared on reboot, matching the hosts' actual lifetime. Unix +// socket paths must also stay short (the sockaddr_un limit is ~104 bytes), +// which rules out deep home paths. +func DefaultDir() string { + if v := os.Getenv("UAM_SESSION_DIR"); v != "" { + return v + } + return filepath.Join(os.TempDir(), "uam-"+strconv.Itoa(os.Getuid())) +} + +// EnsureDir creates the runtime directory owner-only. The 0700 mode is the +// security boundary: sockets and state files inside inherit protection from +// it, so another local user can neither attach to a session nor inject input. +// Because the default parent is the sticky shared temp dir, the directory is +// also verified to be a real directory (not a symlink) owned by the current +// user — a foreign pre-created /tmp/uam- is refused, like tmux refuses a +// foreign /tmp/tmux-. +func EnsureDir(dir string) error { + if err := os.MkdirAll(dir, 0o700); err != nil { + return fmt.Errorf("create session dir %s: %w", dir, err) + } + info, err := os.Lstat(dir) + if err != nil { + return fmt.Errorf("stat session dir %s: %w", dir, err) + } + if !info.IsDir() { + return fmt.Errorf("session dir %s is not a directory", dir) + } + if st, ok := info.Sys().(*syscall.Stat_t); ok && int(st.Uid) != os.Getuid() { + return fmt.Errorf("session dir %s is owned by uid %d, not the current user", dir, st.Uid) + } + // MkdirAll is a no-op on an existing directory; re-assert the mode so a + // pre-existing world-readable dir cannot silently expose sockets. 0700 is + // required (not 0600): the owner must traverse the directory. + if err := os.Chmod(dir, 0o700); err != nil { // #nosec G302 -- directory needs the execute bit; owner-only. + return fmt.Errorf("restrict session dir %s: %w", dir, err) + } + return nil +} + +// State is the on-disk record a host writes next to its socket. It is the +// native replacement for `tmux list-sessions` output: List scans these files +// to enumerate live sessions without dialing every socket. +type State struct { + Name string `json:"name"` + HostPID int `json:"host_pid"` + // HostStart / ChildStart are the processes' kernel start times + // (/proc//stat field 22, in clock ticks since boot; 0 where + // unavailable). They disambiguate a recycled PID from the original + // process, so a stale state file can never make uam treat — or worse, + // signal — an unrelated process as a session. + HostStart int64 `json:"host_start,omitempty"` + ChildPID int `json:"child_pid"` + ChildStart int64 `json:"child_start,omitempty"` + CreatedUnix int64 `json:"created_unix"` + Cwd string `json:"cwd"` + Label string `json:"label,omitempty"` + Command []string `json:"command"` +} + +// hostAlive / childAlive are the start-time-verified liveness probes for a +// persisted state record. +func (st State) hostAlive() bool { return procAliveWithStart(st.HostPID, st.HostStart) } +func (st State) childAlive() bool { return procAliveWithStart(st.ChildPID, st.ChildStart) } + +// Info is one live session as reported by List. +type Info struct { + Name string + CreatedUnix int64 + ChildPID int + // Cwd is the agent process's current working directory (live from /proc + // when available, else the directory the session started in). + Cwd string + // Alive reports whether the agent process itself is still running. The + // host lingers briefly after the child exits, so this is the liveness + // signal the dashboard's Active/Failed classification keys on. + Alive bool +} + +func statePath(dir, name string) string { return filepath.Join(dir, name+".json") } + +// SocketPath returns the control socket path for a session. +func SocketPath(dir, name string) string { return filepath.Join(dir, name+".sock") } + +func writeState(dir string, st State) error { + data, err := json.Marshal(st) + if err != nil { + return err + } + path := statePath(dir, st.Name) + tmp := path + ".tmp" + if err := os.WriteFile(tmp, data, 0o600); err != nil { + return err + } + return os.Rename(tmp, path) +} + +func readState(dir, name string) (State, error) { + data, err := os.ReadFile(statePath(dir, name)) + if err != nil { + return State{}, err + } + var st State + if err := json.Unmarshal(data, &st); err != nil { + return State{}, fmt.Errorf("parse session state %s: %w", name, err) + } + return st, nil +} + +func removeSessionFiles(dir, name string) { + _ = os.Remove(statePath(dir, name)) + _ = os.Remove(SocketPath(dir, name)) +} + +// ProcAlive reports whether pid is a live process (signal-0 probe). It is the +// native equivalent of the old tmux.PaneAlive. +func ProcAlive(pid int) bool { + if pid <= 0 { + return false + } + return syscall.Kill(pid, 0) == nil +} + +// procAliveWithStart is ProcAlive hardened against PID reuse: when a start +// time was recorded AND the live process's start time is readable, they must +// match. Where /proc is unavailable (e.g. macOS) either side reads as 0 and +// the check degrades to the plain signal-0 probe. +func procAliveWithStart(pid int, start int64) bool { + if !ProcAlive(pid) { + return false + } + if start == 0 { + return true + } + current := procStartTime(pid) + return current == 0 || current == start +} + +// procStartTime returns the kernel start time of pid (clock ticks since boot, +// /proc//stat field 22), or 0 when unavailable. +func procStartTime(pid int) int64 { + if pid <= 0 { + return 0 + } + data, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid)) + if err != nil { + return 0 + } + // comm (field 2) is parenthesized and may itself contain spaces or ')', + // so split after the LAST ')'. starttime is overall field 22, i.e. index + // 19 of the fields that follow comm. + rest := string(data) + if i := strings.LastIndexByte(rest, ')'); i >= 0 { + rest = rest[i+1:] + } + fields := strings.Fields(rest) + if len(fields) < 20 { + return 0 + } + v, err := strconv.ParseInt(fields[19], 10, 64) + if err != nil { + return 0 + } + return v +} + +// procCwd returns the live working directory of pid via /proc (Linux). On +// platforms or failures where that is unavailable it returns "". +func procCwd(pid int) string { + if pid <= 0 { + return "" + } + cwd, err := os.Readlink(fmt.Sprintf("/proc/%d/cwd", pid)) + if err != nil { + return "" + } + return cwd +} diff --git a/internal/session/session_test.go b/internal/session/session_test.go new file mode 100644 index 0000000..ea24acc --- /dev/null +++ b/internal/session/session_test.go @@ -0,0 +1,340 @@ +package session + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/RandomCodeSpace/unified-agent-manager/internal/store" +) + +// TestMain doubles as the host/attach entry point: Client spawns +// os.Executable() with the internal __host/__attach argv, and under `go test` +// that executable is this test binary. Routing here exercises the real +// detached host end to end. +func TestMain(m *testing.M) { + if len(os.Args) > 1 && os.Args[1] == "__host" { + if err := RunHost(os.Args[2:]); err != nil { + os.Exit(1) + } + os.Exit(0) + } + os.Exit(m.Run()) +} + +func newTestClient(t *testing.T) *Client { + t.Helper() + dir := filepath.Join(t.TempDir(), "run") + t.Setenv("UAM_CONFIG_DIR", filepath.Join(t.TempDir(), "cfg")) + exe, err := os.Executable() + if err != nil { + t.Fatal(err) + } + c := &Client{Dir: dir, Exe: exe} + t.Cleanup(func() { _ = c.KillAll(context.Background()) }) + return c +} + +func waitFor(t *testing.T, what string, cond func() bool) { + t.Helper() + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + if cond() { + return + } + time.Sleep(20 * time.Millisecond) + } + t.Fatalf("timed out waiting for %s", what) +} + +func TestValidateName(t *testing.T) { + for _, ok := range []string{"uam-claude-abc12345", "uam-omp-0", "uam-x9-deadbeefcafe0123"} { + if err := ValidateName(ok); err != nil { + t.Fatalf("ValidateName(%q) = %v", ok, err) + } + } + for _, bad := range []string{"", "uam-claude-", "uam-claude-XYZ", "uam--abc", "evil/../path", "uam-a-abc; rm -rf"} { + if err := ValidateName(bad); err == nil { + t.Fatalf("ValidateName(%q) should fail", bad) + } + } +} + +func TestCreateListCaptureSendKill(t *testing.T) { + c := newTestClient(t) + ctx := context.Background() + name := "uam-fake-abc12345" + err := c.CreateSession(ctx, name, t.TempDir(), map[string]string{"UAM_TEST_VAR": "v1"}, []string{"/bin/sh", "-c", `echo "var=$UAM_TEST_VAR"; while read line; do echo "got:$line"; done`}) + if err != nil { + t.Fatalf("CreateSession: %v", err) + } + if !c.HasSession(ctx, name) { + t.Fatal("HasSession should be true after create") + } + + infos, err := c.List(ctx) + if err != nil || len(infos) != 1 { + t.Fatalf("List = %+v, %v", infos, err) + } + if infos[0].Name != name || !infos[0].Alive || infos[0].ChildPID <= 0 { + t.Fatalf("bad info: %+v", infos[0]) + } + + // Environment must reach the agent. + waitFor(t, "env line in capture", func() bool { + out, err := c.Capture(ctx, name, 50) + return err == nil && strings.Contains(out, "var=v1") + }) + + // SendLine delivers text plus Enter; the shell loop echoes it back. + if err := c.SendLine(ctx, name, "ping\n"); err != nil { + t.Fatalf("SendLine: %v", err) + } + waitFor(t, "reply in capture", func() bool { + out, _ := c.Capture(ctx, name, 50) + return strings.Contains(out, "got:ping") + }) + + if err := c.Kill(ctx, name); err != nil { + t.Fatalf("Kill: %v", err) + } + waitFor(t, "session gone", func() bool { return !c.HasSession(ctx, name) }) + if _, err := os.Stat(SocketPath(c.Dir, name)); !os.IsNotExist(err) { + t.Fatalf("socket not cleaned up: %v", err) + } +} + +func TestAgentExitMarksStoreRecordClosed(t *testing.T) { + c := newTestClient(t) + ctx := context.Background() + name := "uam-fake-deadbeef" + + st, err := store.Open(store.DefaultPath()) + if err != nil { + t.Fatal(err) + } + if err := st.Update(func(cfg *store.Config) error { + cfg.PutSession("fake:deadbeef", store.SessionRecord{ID: "deadbeef", Agent: "fake", Name: "n", SessionName: name, Status: store.StatusActive}) + return nil + }); err != nil { + t.Fatal(err) + } + + if err := c.CreateSession(ctx, name, t.TempDir(), nil, []string{"/bin/sh", "-c", "exit 3"}); err != nil { + t.Fatalf("CreateSession: %v", err) + } + waitFor(t, "record marked closed", func() bool { + cfg, err := st.Load() + if err != nil { + return false + } + rec := cfg.Sessions["fake:deadbeef"] + return rec.Status == store.StatusClosedByUser && rec.LastExitCode != nil && *rec.LastExitCode == 3 + }) + waitFor(t, "runtime files removed", func() bool { + _, err := os.Stat(SocketPath(c.Dir, name)) + return os.IsNotExist(err) + }) +} + +func TestCreateSessionReportsStartupFailure(t *testing.T) { + c := newTestClient(t) + err := c.CreateSession(context.Background(), "uam-fake-11112222", t.TempDir(), nil, []string{"/nonexistent/agent-binary"}) + if err == nil || !strings.Contains(err.Error(), "agent-binary") { + t.Fatalf("want startup failure mentioning the command, got %v", err) + } + if c.HasSession(context.Background(), "uam-fake-11112222") { + t.Fatal("failed create must not leave a session behind") + } +} + +func TestCreateSessionRejectsDuplicate(t *testing.T) { + c := newTestClient(t) + ctx := context.Background() + name := "uam-fake-33334444" + if err := c.CreateSession(ctx, name, t.TempDir(), nil, []string{"/bin/sh", "-c", "sleep 60"}); err != nil { + t.Fatalf("CreateSession: %v", err) + } + if err := c.CreateSession(ctx, name, t.TempDir(), nil, []string{"/bin/sh", "-c", "sleep 60"}); err == nil || !strings.Contains(err.Error(), "already exists") { + t.Fatalf("duplicate create should fail, got %v", err) + } +} + +func TestCreateSessionRejectsInvalidName(t *testing.T) { + c := newTestClient(t) + if err := c.CreateSession(context.Background(), "evil name", t.TempDir(), nil, []string{"/bin/sh"}); err == nil { + t.Fatal("invalid name must be rejected") + } +} + +func TestKillMissingSessionErrors(t *testing.T) { + c := newTestClient(t) + if err := c.Kill(context.Background(), "uam-fake-99990000"); err == nil { + t.Fatal("killing a missing session should error") + } + if c.HasSession(context.Background(), "uam-fake-99990000") { + t.Fatal("HasSession on missing session") + } +} + +func TestKillAllIsIdempotent(t *testing.T) { + c := newTestClient(t) + ctx := context.Background() + if err := c.KillAll(ctx); err != nil { + t.Fatalf("KillAll on empty dir: %v", err) + } + for _, name := range []string{"uam-fake-aaaa1111", "uam-fake-bbbb2222"} { + if err := c.CreateSession(ctx, name, t.TempDir(), nil, []string{"/bin/sh", "-c", "sleep 60"}); err != nil { + t.Fatalf("CreateSession %s: %v", name, err) + } + } + if err := c.KillAll(ctx); err != nil { + t.Fatalf("KillAll: %v", err) + } + infos, err := c.List(ctx) + if err != nil || len(infos) != 0 { + t.Fatalf("sessions remain after KillAll: %+v %v", infos, err) + } + if err := c.KillAll(ctx); err != nil { + t.Fatalf("KillAll repeat: %v", err) + } +} + +func TestSetSessionLabelPersistsToState(t *testing.T) { + c := newTestClient(t) + ctx := context.Background() + name := "uam-fake-cccc3333" + if err := c.CreateSession(ctx, name, t.TempDir(), nil, []string{"/bin/sh", "-c", "sleep 60"}); err != nil { + t.Fatalf("CreateSession: %v", err) + } + if err := c.SetSessionLabel(ctx, name, "fixer · fake"); err != nil { + t.Fatalf("SetSessionLabel: %v", err) + } + waitFor(t, "label in state file", func() bool { + st, err := readState(c.Dir, name) + return err == nil && st.Label == "fixer · fake" + }) +} + +func TestListSweepsStaleStateFiles(t *testing.T) { + c := newTestClient(t) + if err := EnsureDir(c.Dir); err != nil { + t.Fatal(err) + } + // A state file whose host and child pids are both long dead. + if err := writeState(c.Dir, State{Name: "uam-fake-dddd4444", HostPID: 1 << 28, ChildPID: 1 << 28, CreatedUnix: 1}); err != nil { + t.Fatal(err) + } + infos, err := c.List(context.Background()) + if err != nil || len(infos) != 0 { + t.Fatalf("stale state should be swept: %+v %v", infos, err) + } + if _, err := os.Stat(statePath(c.Dir, "uam-fake-dddd4444")); !os.IsNotExist(err) { + t.Fatal("stale state file should be removed") + } +} + +func TestAttachArgvUsesOwnBinary(t *testing.T) { + c := newTestClient(t) + argv, err := c.AttachArgv("uam-fake-eeee5555") + if err != nil { + t.Fatalf("AttachArgv: %v", err) + } + if len(argv) < 3 || argv[0] != c.Exe || argv[1] != "__attach" || argv[len(argv)-1] != "uam-fake-eeee5555" { + t.Fatalf("bad attach argv: %v", argv) + } +} + +// Attach end to end through the client side: connect, see the screen replay, +// type a line, detach with the Ctrl+B d chord. +func TestAttachStreamsAndDetaches(t *testing.T) { + c := newTestClient(t) + ctx := context.Background() + name := "uam-fake-ffff6666" + err := c.CreateSession(ctx, name, t.TempDir(), nil, []string{"/bin/sh", "-c", `echo banner; while read line; do echo "typed:$line"; done`}) + if err != nil { + t.Fatalf("CreateSession: %v", err) + } + waitFor(t, "banner", func() bool { + out, _ := c.Capture(ctx, name, 10) + return strings.Contains(out, "banner") + }) + + stdinR, stdinW, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + stdoutR, stdoutW, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { done <- runAttach(c.Dir, name, stdinR, stdoutW) }() + go func() { + _, _ = stdinW.WriteString("hi\r") + // Wait for the round-trip before detaching so output is deterministic. + waitFor(t, "typed echo", func() bool { + out, _ := c.Capture(ctx, name, 10) + return strings.Contains(out, "typed:hi") + }) + _, _ = stdinW.Write([]byte{0x02, 'd'}) // Ctrl+B d + }() + + if err := <-done; err != nil { + t.Fatalf("runAttach: %v", err) + } + _ = stdoutW.Close() + buf := make([]byte, 64*1024) + n, _ := stdoutR.Read(buf) + out := string(buf[:n]) + if !strings.Contains(out, "banner") { + t.Fatalf("attach replay missing banner: %q", out) + } + if !strings.Contains(out, "[uam: detached]") { + t.Fatalf("missing detach notice: %q", out) + } +} + +// The left-arrow quick detach works end to end through the real attach +// client: with nothing typed since attach, a bare left arrow detaches. +func TestAttachLeftArrowDetaches(t *testing.T) { + c := newTestClient(t) + ctx := context.Background() + name := "uam-fake-77778888" + if err := c.CreateSession(ctx, name, t.TempDir(), nil, []string{"/bin/sh", "-c", "echo up; sleep 60"}); err != nil { + t.Fatalf("CreateSession: %v", err) + } + stdinR, stdinW, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + stdoutR, stdoutW, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { done <- runAttach(c.Dir, name, stdinR, stdoutW) }() + if _, err := stdinW.Write([]byte("\x1b[D")); err != nil { + t.Fatal(err) + } + select { + case err := <-done: + if err != nil { + t.Fatalf("runAttach: %v", err) + } + case <-time.After(10 * time.Second): + t.Fatal("left arrow did not detach") + } + _ = stdoutW.Close() + buf := make([]byte, 64*1024) + n, _ := stdoutR.Read(buf) + if !strings.Contains(string(buf[:n]), "[uam: detached]") { + t.Fatalf("missing detach notice: %q", string(buf[:n])) + } + if !c.HasSession(ctx, name) { + t.Fatal("session must keep running after quick detach") + } +} diff --git a/internal/store/integrity_test.go b/internal/store/integrity_test.go index 1452905..eb26420 100644 --- a/internal/store/integrity_test.go +++ b/internal/store/integrity_test.go @@ -24,7 +24,7 @@ func writeV1Config(t *testing.T, path string, sessions map[string]any) { } } -func TestMigrateV1_DeadTmuxSession_BecomesClosedByUser(t *testing.T) { +func TestMigrateV1_DeadSessionName_BecomesClosedByUser(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "sessions.json") writeV1Config(t, path, map[string]any{ @@ -54,7 +54,7 @@ func TestMigrateV1_DeadTmuxSession_BecomesClosedByUser(t *testing.T) { } } -func TestMigrateV1_LiveTmuxSession_StaysActive(t *testing.T) { +func TestMigrateV1_LiveSessionName_StaysActive(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "sessions.json") writeV1Config(t, path, map[string]any{ diff --git a/internal/store/putsession_test.go b/internal/store/putsession_test.go index 82b7d8b..3cb199c 100644 --- a/internal/store/putsession_test.go +++ b/internal/store/putsession_test.go @@ -21,8 +21,8 @@ func TestPutSessionRefusesShortKeyCollisionWithDifferentFullID(t *testing.T) { t.Fatalf("test precondition: expected colliding short keys, got %q vs %q", keyA, keyB) } - recA := SessionRecord{ID: idA, Agent: "claude", TmuxSession: "uam-claude-a"} - recB := SessionRecord{ID: idB, Agent: "claude", TmuxSession: "uam-claude-b"} + recA := SessionRecord{ID: idA, Agent: "claude", SessionName: "uam-claude-a"} + recB := SessionRecord{ID: idB, Agent: "claude", SessionName: "uam-claude-b"} if !cfg.PutSession(keyA, recA) { t.Fatalf("first PutSession must succeed (no existing record)") @@ -60,7 +60,7 @@ func TestShortKeyJoinsLiveSessionToFullUUIDStoredRecord(t *testing.T) { cfg := DefaultConfig() fullID := "abcdef12-aaaa-1111-aaaa-111111111111" key := Key("claude", fullID) - rec := SessionRecord{ID: fullID, Agent: "claude", TmuxSession: "uam-claude-x"} + rec := SessionRecord{ID: fullID, Agent: "claude", SessionName: "uam-claude-x"} if !cfg.PutSession(key, rec) { t.Fatalf("PutSession must succeed") } diff --git a/internal/store/store.go b/internal/store/store.go index 7dbc0f7..9732f62 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -152,20 +152,32 @@ type UISettings struct { } type SessionRecord struct { - ID string `json:"id"` - Agent string `json:"agent"` - CommandAlias string `json:"command_alias,omitempty"` - Name string `json:"name"` - Prompt string `json:"prompt,omitempty"` - Mode Mode `json:"mode"` - Workdir string `json:"workdir"` - TmuxSession string `json:"tmux_session"` - CreatedAt time.Time `json:"created_at"` - LastSeenAt time.Time `json:"last_seen_at"` - Pinned bool `json:"pinned"` - Group string `json:"group"` - SortIndex int `json:"sort_index"` - Status Status `json:"status,omitempty"` + ID string `json:"id"` + Agent string `json:"agent"` + CommandAlias string `json:"command_alias,omitempty"` + Name string `json:"name"` + Prompt string `json:"prompt,omitempty"` + Mode Mode `json:"mode"` + Workdir string `json:"workdir"` + // SessionName is the backend session name ("uam--"). The JSON + // key keeps its historical "tmux_session" spelling so configs written by + // tmux-backed releases load unchanged. + SessionName string `json:"tmux_session"` + CreatedAt time.Time `json:"created_at"` + LastSeenAt time.Time `json:"last_seen_at"` + Pinned bool `json:"pinned"` + Group string `json:"group"` + SortIndex int `json:"sort_index"` + Status Status `json:"status,omitempty"` + // ProviderSessionID is the agent CLI's own session id, recorded when the + // provider lets uam seed it at dispatch (e.g. claude --session-id). A + // resume can then target the exact provider session instead of the + // provider's "most recent" heuristic. + ProviderSessionID string `json:"provider_session_id,omitempty"` + // LastExitCode records the agent process's exit status from the most + // recent close (-1 when it died on a signal). Pointer so records from + // older schemas stay distinguishable from a real exit 0. + LastExitCode *int `json:"last_exit_code,omitempty"` PR *PRRecord `json:"pr,omitempty"` } @@ -178,9 +190,10 @@ type PRRecord struct { type Store struct { path string - // sessionExists, when set, reports whether a tmux session name is still - // live. It is injected by the caller (the store stays tmux-free) and is used - // only to reclassify Statusless v1 records during migration (F07). + // sessionExists, when set, reports whether a backend session name is + // still live. It is injected by the caller (the store stays backend-free) + // and is used only to reclassify Statusless v1 records during migration + // (F07). sessionExists func(string) bool } @@ -194,10 +207,10 @@ func Open(path string) (*Store, error) { return &Store{path: path}, nil } -// SetSessionProbe injects a callback that reports whether a tmux session name is -// still live. Migration uses it to tell a reboot-survivor (live pane -> stays -// Active) apart from a user-stopped session (dead pane -> closed-by-user). When -// unset, migration conservatively keeps the legacy Active behavior (F07). +// SetSessionProbe injects a callback that reports whether a backend session +// name is still live. Migration uses it to tell a reboot-survivor (live -> +// stays Active) apart from a user-stopped session (dead -> closed-by-user). +// When unset, migration conservatively keeps the legacy Active behavior (F07). func (s *Store) SetSessionProbe(exists func(string) bool) { s.sessionExists = exists } func (s *Store) Path() string { return s.path } @@ -341,7 +354,7 @@ func (s *Store) loadNoLock() (Config, error) { // reclassifyV1Closed runs on the RAW pre-normalize config during a v1->v2 // migration. For each Statusless record it asks the injected probe whether the -// tmux pane is still alive: a live pane is a reboot survivor (left Statusless so +// session is still alive: a live one is a reboot survivor (left Statusless so // normalize/migrate backfill it to Active), while a dead pane was a deliberately // stopped session and is marked closed-by-user so it does not auto-resume. With // no probe it is a no-op, preserving the legacy all-Active behavior. @@ -350,7 +363,7 @@ func reclassifyV1Closed(cfg *Config, exists func(string) bool) { return } for k, rec := range cfg.Sessions { - if rec.Status == "" && !exists(rec.TmuxSession) { + if rec.Status == "" && !exists(rec.SessionName) { rec.Status = StatusClosedByUser cfg.Sessions[k] = rec } @@ -358,12 +371,12 @@ func reclassifyV1Closed(cfg *Config, exists func(string) bool) { } // unsafeArgvChars are the shell metacharacters that must never appear in an ID -// or tmux session name. Although uam reaches tmux via argv (not a shell — see -// internal/tmux ShellJoin), these fields are an untrusted-on-disk injection -// surface for the F05/F06/F09 sinks, so we keep classic shell metacharacters -// out as defense in depth. Whitespace and control runes are rejected -// separately. Note `:` is intentionally allowed: it is tmux's target separator -// but never a shell hazard, and real Key-derived values can carry it. +// or backend session name. The native backend execs argv directly (no shell) +// and session names are further allow-listed by session.ValidateName, but +// these fields are untrusted on disk, so classic shell metacharacters stay +// rejected as defense in depth. Whitespace and control runes are rejected +// separately. Note `:` is intentionally allowed: it is never a shell hazard, +// and real Key-derived values can carry it. const unsafeArgvChars = "'\"`;&|$<>(){}[]*?!#\\~/ \t\n\r" // prURLRE mirrors the GitHub PR URL shape recognized by the adapter's @@ -386,14 +399,17 @@ func dropInvalidRecords(cfg *Config) { // validateRecord returns a non-empty reason if the record must be dropped, or // "" if it is safe to keep. It rejects only the values that carry real risk — -// shell metacharacters or control runes in the argv-bound ID/TmuxSession +// shell metacharacters or control runes in the argv-bound ID/SessionName // fields, a non-absolute or control-char Workdir, and a PR URL that does not // match the GitHub PR shape. Empty optional fields are allowed. +// +// The on-disk JSON key for SessionName remains "tmux_session" for backward +// compatibility, which is why the drop reasons below keep that spelling. func validateRecord(rec SessionRecord) string { if isUnsafeArgv(rec.ID) { return "unsafe id" } - if isUnsafeArgv(rec.TmuxSession) { + if isUnsafeArgv(rec.SessionName) { return "unsafe tmux_session" } if rec.Workdir != "" { @@ -410,9 +426,21 @@ func validateRecord(rec SessionRecord) string { if rec.CommandAlias != "" && !isSafeCommandAlias(rec.CommandAlias) { return "unsafe command_alias" } + // The provider session id is passed as a resume argv value; constrain it + // so a hand-edited record cannot smuggle a flag or shell hazard into the + // agent's command line. + if rec.ProviderSessionID != "" && !providerSessionIDRE.MatchString(rec.ProviderSessionID) { + return "unsafe provider_session_id" + } return "" } +// providerSessionIDRE constrains persisted provider session ids to the id +// alphabets the supported providers use — claude/codex UUIDs and opencode +// "ses_..." ids — with no shell metacharacters and no leading dash (a value +// starting with '-' could be parsed as a flag by the agent CLI). +var providerSessionIDRE = regexp.MustCompile(`^[0-9A-Za-z_][0-9A-Za-z_-]{0,63}$`) + func isSafeCommandAlias(alias string) bool { if alias == "" || strings.HasPrefix(alias, "-") { return false @@ -427,7 +455,7 @@ func isSafeCommandAlias(alias string) bool { } // isUnsafeArgv reports whether s contains a shell metacharacter or a control -// rune that has no place in a persisted ID or tmux session name. +// rune that has no place in a persisted ID or session name. func isUnsafeArgv(s string) bool { if strings.ContainsAny(s, unsafeArgvChars) { return true @@ -585,10 +613,34 @@ func (s *Store) copyBackup() error { func (s *Store) moveAside() error { return os.Rename(s.path, s.backupPath()) } +// MarkSessionClosed flags the record whose backend session name matches as +// user-closed and records the agent's exit code. It is what a session host +// calls when its agent exits — the native replacement for the tmux +// session-closed hook driving `uam notify-closed`. Idempotent and a no-op +// when no record matches (e.g. uam already deleted it via `uam rm`). +func (s *Store) MarkSessionClosed(sessionName string, exitCode int) error { + if sessionName == "" { + return nil + } + return s.Update(func(cfg *Config) error { + for key, rec := range cfg.Sessions { + if rec.SessionName != sessionName { + continue + } + rec.Status = StatusClosedByUser + code := exitCode + rec.LastExitCode = &code + cfg.Sessions[key] = rec + return nil + } + return nil + }) +} + func PruneOld(cfg *Config, maxAge time.Duration, exists func(string) bool) { cutoff := time.Now().Add(-maxAge) for key, rec := range cfg.Sessions { - if rec.LastSeenAt.Before(cutoff) && !exists(rec.TmuxSession) { + if rec.LastSeenAt.Before(cutoff) && !exists(rec.SessionName) { delete(cfg.Sessions, key) } } diff --git a/internal/store/store_more_test.go b/internal/store/store_more_test.go index 306b226..234c577 100644 --- a/internal/store/store_more_test.go +++ b/internal/store/store_more_test.go @@ -33,7 +33,7 @@ func TestPathsUpdatePruneAndHelpers(t *testing.T) { t.Fatal("helpers") } if err := s.Update(func(cfg *Config) error { - cfg.Sessions["a:old"] = SessionRecord{ID: "old", Agent: "a", TmuxSession: "missing", LastSeenAt: time.Now().Add(-10 * 24 * time.Hour)} + cfg.Sessions["a:old"] = SessionRecord{ID: "old", Agent: "a", SessionName: "missing", LastSeenAt: time.Now().Add(-10 * 24 * time.Hour)} return nil }); err != nil { t.Fatal(err) diff --git a/internal/store/store_test.go b/internal/store/store_test.go index c707bfb..7bb2f11 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -47,7 +47,7 @@ func TestSaveLoadRoundTrip(t *testing.T) { Name: "fix tests", Mode: ModeYolo, Workdir: "/tmp/repo", - TmuxSession: "uam-claude-12345678", + SessionName: "uam-claude-12345678", CreatedAt: now, LastSeenAt: now, Pinned: true, diff --git a/internal/store/validate_test.go b/internal/store/validate_test.go index b6cb61c..a47f8b0 100644 --- a/internal/store/validate_test.go +++ b/internal/store/validate_test.go @@ -47,7 +47,7 @@ func goodRecord() map[string]any { } } -func TestLoadDropsRecordWithMetacharTmuxSession(t *testing.T) { +func TestLoadDropsRecordWithMetacharSessionName(t *testing.T) { s := writeConfig(t, map[string]any{ "claude:12345678": goodRecord(), "claude:evil1234": map[string]any{ @@ -203,7 +203,7 @@ func TestLoadKeepsCanonicalUUIDRecord(t *testing.T) { if !ok { t.Fatal("canonical UUID-style record was wrongly dropped") } - if rec.ID != "12345678-1234-4234-9234-123456789abc" || rec.TmuxSession != "uam-claude-12345678" { + if rec.ID != "12345678-1234-4234-9234-123456789abc" || rec.SessionName != "uam-claude-12345678" { t.Fatalf("record mutated: %+v", rec) } } @@ -301,3 +301,21 @@ func TestLoadKeepsMigratedRecord(t *testing.T) { t.Fatalf("status = %q, want %q", rec.Status, StatusActive) } } + +// The provider session id is passed as a resume argv value; anything outside +// the UUID alphabet must drop the record on load. +func TestValidateRejectsUnsafeProviderSessionID(t *testing.T) { + rec := SessionRecord{ID: "abc12345", Agent: "claude", SessionName: "uam-claude-abc12345", Workdir: "/tmp"} + for _, good := range []string{"abc12345-dead-beef-cafe-0123456789ab", "ses_2132323b6ffeuRlYHhPcU8DaZ6"} { + rec.ProviderSessionID = good + if reason := validateRecord(rec); reason != "" { + t.Fatalf("provider session id %q should pass, got %q", good, reason) + } + } + for _, bad := range []string{"--continue", "-leadingdash", "x; rm -rf /", "id with space", "$(boom)"} { + rec.ProviderSessionID = bad + if reason := validateRecord(rec); reason == "" { + t.Fatalf("provider session id %q must be rejected", bad) + } + } +} diff --git a/internal/tmux/killserver_test.go b/internal/tmux/killserver_test.go deleted file mode 100644 index 5713b36..0000000 --- a/internal/tmux/killserver_test.go +++ /dev/null @@ -1,68 +0,0 @@ -package tmux - -import ( - "context" - "os" - "path/filepath" - "strings" - "testing" -) - -// F24 — KillServer must tear down the private tmux server by emitting -// `tmux -L kill-server`. -func TestKillServerEmitsKillServer(t *testing.T) { - c, logPath := setupFakeTmuxClient(t) - if err := c.KillServer(context.Background()); err != nil { - t.Fatalf("KillServer: %v", err) - } - data, err := os.ReadFile(logPath) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(string(data), "kill-server") { - t.Fatalf("KillServer did not emit kill-server: %s", data) - } -} - -// F24 — killing an already-dead server must be idempotent: tmux exits non-zero -// with a "no server" message, which KillServer treats as success. -func TestKillServerOnDeadServerIsIdempotent(t *testing.T) { - dir := t.TempDir() - script := filepath.Join(dir, "tmux") - if err := os.WriteFile(script, []byte(`#!/bin/sh -printf '%s\n' "$*" >> "$TMUX_LOG" -echo "no server running on /tmp/tmux-uam" >&2 -exit 1 -`), 0o755); err != nil { - t.Fatal(err) - } - t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) - t.Setenv("TMUX_LOG", filepath.Join(dir, "log")) - c := New("uam") - c.Executable = script - - if err := c.KillServer(context.Background()); err != nil { - t.Fatalf("KillServer on a dead server must be idempotent, got: %v", err) - } -} - -// F24 — a genuine failure (not a missing server) must still propagate. -func TestKillServerPropagatesGenuineError(t *testing.T) { - dir := t.TempDir() - script := filepath.Join(dir, "tmux") - if err := os.WriteFile(script, []byte(`#!/bin/sh -printf '%s\n' "$*" >> "$TMUX_LOG" -echo "permission denied" >&2 -exit 1 -`), 0o755); err != nil { - t.Fatal(err) - } - t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) - t.Setenv("TMUX_LOG", filepath.Join(dir, "log")) - c := New("uam") - c.Executable = script - - if err := c.KillServer(context.Background()); err == nil { - t.Fatal("KillServer must propagate a genuine (non-missing-server) error") - } -} diff --git a/internal/tmux/listcache_test.go b/internal/tmux/listcache_test.go deleted file mode 100644 index da3f67e..0000000 --- a/internal/tmux/listcache_test.go +++ /dev/null @@ -1,101 +0,0 @@ -package tmux - -import ( - "context" - "os" - "path/filepath" - "strings" - "testing" - "time" -) - -func newCountingListClient(t *testing.T) (*Client, string) { - t.Helper() - dir := t.TempDir() - tmuxPath := filepath.Join(dir, "tmux") - if err := os.WriteFile(tmuxPath, []byte(`#!/bin/sh -printf '%s\n' "$*" >> "$TMUX_LOG" -case "$*" in - *"list-sessions"*) echo "uam-fake-abc12345|1710000000|0|1|/tmp/repo|fakeagent" ;; -esac -exit 0 -`), 0o755); err != nil { - t.Fatal(err) - } - t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) - logPath := filepath.Join(dir, "tmux.log") - t.Setenv("TMUX_LOG", logPath) - c := New("uam") - c.Executable = tmuxPath - return c, logPath -} - -func countListSessions(t *testing.T, logPath string) int { - t.Helper() - b, _ := os.ReadFile(logPath) - n := 0 - for _, line := range strings.Split(string(b), "\n") { - if strings.Contains(line, "list-sessions") { - n++ - } - } - return n -} - -// F60 — multiple List calls within the cache TTL (one per adapter per refresh -// tick) must collapse to a single list-sessions shell-out. -func TestListIsTTLCachedWithinWindow(t *testing.T) { - c, logPath := newCountingListClient(t) - clock := time.Unix(1710000000, 0) - c.now = func() time.Time { return clock } - - for i := 0; i < 5; i++ { - if _, err := c.List(context.Background()); err != nil { - t.Fatalf("List %d: %v", i, err) - } - } - if got := countListSessions(t, logPath); got != 1 { - t.Fatalf("List calls within TTL must collapse to one shell-out, got %d", got) - } -} - -// F60 — once the TTL elapses, List must shell out again to pick up new sessions. -func TestListRefreshesAfterTTL(t *testing.T) { - c, logPath := newCountingListClient(t) - clock := time.Unix(1710000000, 0) - c.now = func() time.Time { return clock } - - if _, err := c.List(context.Background()); err != nil { - t.Fatalf("List 1: %v", err) - } - clock = clock.Add(listCacheTTL + time.Millisecond) - c.now = func() time.Time { return clock } - if _, err := c.List(context.Background()); err != nil { - t.Fatalf("List 2: %v", err) - } - if got := countListSessions(t, logPath); got != 2 { - t.Fatalf("List past TTL must re-shell-out, got %d", got) - } -} - -// F60 — a caller that mutates the returned slice must not corrupt the cache for -// the next caller within the TTL window. -func TestListReturnsCopyCallersCannotMutateCache(t *testing.T) { - c, _ := newCountingListClient(t) - clock := time.Unix(1710000000, 0) - c.now = func() time.Time { return clock } - - first, err := c.List(context.Background()) - if err != nil || len(first) != 1 { - t.Fatalf("List 1: len=%d err=%v", len(first), err) - } - first[0].Name = "mutated" - - second, err := c.List(context.Background()) - if err != nil || len(second) != 1 { - t.Fatalf("List 2: len=%d err=%v", len(second), err) - } - if second[0].Name != "uam-fake-abc12345" { - t.Fatalf("cached slice was mutated by a prior caller: %q", second[0].Name) - } -} diff --git a/internal/tmux/parse.go b/internal/tmux/parse.go deleted file mode 100644 index e3573e3..0000000 --- a/internal/tmux/parse.go +++ /dev/null @@ -1,85 +0,0 @@ -package tmux - -import ( - "errors" - "fmt" - "strconv" - "strings" - - "github.com/RandomCodeSpace/unified-agent-manager/internal/log" -) - -// ErrMalformedSessionLines is the sentinel returned by ParseListSessions when -// one or more lines could not be parsed. The parsed subset is still returned so -// a single bad line (e.g. a cwd containing '|') never blanks the whole list. -var ErrMalformedSessionLines = errors.New("one or more tmux session lines were malformed") - -type SessionInfo struct { - Name string - CreatedUnix int64 - Attached bool - PanePID int - CurrentPath string - CurrentCommand string -} - -const ListFormat = "#{session_name}|#{session_created}|#{session_attached}|#{pane_pid}|#{pane_current_path}|#{pane_current_command}" - -func ParseSessionLine(line string) (SessionInfo, error) { - // Right-anchored split: the first four fields and the trailing command are - // fixed-position, so pane_current_path (field 5) keeps any embedded '|'. - // SplitN caps the left split at five pieces, leaving path|command in head[4]. - head := strings.SplitN(line, "|", 5) - if len(head) != 5 { - return SessionInfo{}, fmt.Errorf("expected 6 fields, got %d", len(head)) - } - cut := strings.LastIndex(head[4], "|") - if cut < 0 { - return SessionInfo{}, fmt.Errorf("expected 6 fields, got %d", len(head)) - } - path, command := head[4][:cut], head[4][cut+1:] - created, err := strconv.ParseInt(strings.TrimSpace(head[1]), 10, 64) - if err != nil { - return SessionInfo{}, fmt.Errorf("parse created: %w", err) - } - attachedInt, err := strconv.Atoi(strings.TrimSpace(head[2])) - if err != nil { - return SessionInfo{}, fmt.Errorf("parse attached: %w", err) - } - pid, err := strconv.Atoi(strings.TrimSpace(head[3])) - if err != nil { - return SessionInfo{}, fmt.Errorf("parse pane pid: %w", err) - } - return SessionInfo{ - Name: strings.TrimSpace(head[0]), - CreatedUnix: created, - Attached: attachedInt != 0, - PanePID: pid, - CurrentPath: strings.TrimSpace(path), - CurrentCommand: strings.TrimSpace(command), - }, nil -} - -func ParseListSessions(output string) ([]SessionInfo, error) { - var sessions []SessionInfo - malformed := 0 - for _, line := range strings.Split(output, "\n") { - line = strings.TrimSpace(line) - if line == "" { - continue - } - info, err := ParseSessionLine(line) - if err != nil { - // Skip-and-log: one unparseable line must not blank the whole list. - // The healthy subset is still returned with a sentinel error (F11). - log.Warn("skipping malformed tmux session line", "line", line, "error", err) - malformed++ - continue - } - sessions = append(sessions, info) - } - if malformed > 0 { - return sessions, fmt.Errorf("%w: skipped %d line(s)", ErrMalformedSessionLines, malformed) - } - return sessions, nil -} diff --git a/internal/tmux/parse_test.go b/internal/tmux/parse_test.go deleted file mode 100644 index 38d1fdb..0000000 --- a/internal/tmux/parse_test.go +++ /dev/null @@ -1,89 +0,0 @@ -package tmux - -import ( - "strings" - "testing" -) - -func TestParseSessionLine(t *testing.T) { - line := "uam-claude-abc12345|1710000000|0|4242|/tmp/repo|claude" - got, err := ParseSessionLine(line) - if err != nil { - t.Fatalf("ParseSessionLine: %v", err) - } - if got.Name != "uam-claude-abc12345" || got.CreatedUnix != 1710000000 || got.Attached || got.PanePID != 4242 || got.CurrentPath != "/tmp/repo" || got.CurrentCommand != "claude" { - t.Fatalf("unexpected parse: %+v", got) - } -} - -func TestParseListSessionsSkipsBlankLines(t *testing.T) { - input := "uam-a|1|1|2|/a|bash\n\n uam-b|3|0|4|/b|codex \n" - got, err := ParseListSessions(input) - if err != nil { - t.Fatalf("ParseListSessions: %v", err) - } - if len(got) != 2 || !got[0].Attached || got[1].Name != "uam-b" { - t.Fatalf("unexpected sessions: %+v", got) - } -} - -func TestParseSessionLineRejectsMalformed(t *testing.T) { - if _, err := ParseSessionLine("too|few"); err == nil { - t.Fatal("expected error") - } -} - -// F11 — a cwd that legally contains '|' must not break parsing. The split is -// right-anchored: the first four fields and the trailing command are fixed, so -// the path keeps its embedded separators. -func TestParseSessionLine_PipeInPath(t *testing.T) { - line := "uam-claude-abc12345|1710000000|0|4242|/tmp/weird|dir|claude" - got, err := ParseSessionLine(line) - if err != nil { - t.Fatalf("ParseSessionLine: %v", err) - } - if got.CurrentPath != "/tmp/weird|dir" { - t.Fatalf("path lost embedded separator: %q", got.CurrentPath) - } - if got.CurrentCommand != "claude" || got.Name != "uam-claude-abc12345" || got.PanePID != 4242 { - t.Fatalf("round-trip mismatch: %+v", got) - } -} - -// F11 — one unparseable line (e.g. a non-numeric pid) must not discard the -// whole batch; healthy sessions survive and a sentinel error is returned. -func TestParseListSessions_PipeInPathKeepsHealthySessions(t *testing.T) { - input := strings.Join([]string{ - "uam-a|1|0|10|/home/a|bash", - "uam-b|2|0|notapid|/home/b|codex", // malformed: pid not numeric - "uam-c|3|0|30|/srv/proj|with|pipe|claude", - }, "\n") - got, err := ParseListSessions(input) - if err == nil { - t.Fatal("expected a sentinel error reporting the skipped malformed line") - } - if len(got) != 2 { - t.Fatalf("healthy sessions should survive, got %d: %+v", len(got), got) - } - if got[0].Name != "uam-a" || got[1].Name != "uam-c" { - t.Fatalf("unexpected survivors: %+v", got) - } - if got[1].CurrentPath != "/srv/proj|with|pipe" { - t.Fatalf("path with multiple pipes lost data: %q", got[1].CurrentPath) - } -} - -// F11 — CRLF line endings and unicode paths must round-trip cleanly. -func TestParseListSessions_CRLFAndUnicode(t *testing.T) { - input := "uam-a|1|0|10|/home/üser/проект|bash\r\nuam-b|2|0|20|/tmp|codex\r\n" - got, err := ParseListSessions(input) - if err != nil { - t.Fatalf("ParseListSessions: %v", err) - } - if len(got) != 2 { - t.Fatalf("expected 2 sessions, got %d: %+v", len(got), got) - } - if got[0].CurrentPath != "/home/üser/проект" { - t.Fatalf("unicode path mangled: %q", got[0].CurrentPath) - } -} diff --git a/internal/tmux/timeout_test.go b/internal/tmux/timeout_test.go deleted file mode 100644 index 929b660..0000000 --- a/internal/tmux/timeout_test.go +++ /dev/null @@ -1,63 +0,0 @@ -package tmux - -import ( - "context" - "os" - "path/filepath" - "testing" - "time" -) - -// F17 — an external tmux call must be bounded by an internal timeout so a hung -// tmux process cannot wedge a refresh indefinitely. The fake tmux sleeps far -// longer than the timeout; the call must return well before the sleep finishes. -func TestRunHonorsContextDeadline(t *testing.T) { - dir := t.TempDir() - script := filepath.Join(dir, "tmux") - if err := os.WriteFile(script, []byte("#!/bin/sh\nsleep 60\n"), 0o755); err != nil { - t.Fatal(err) - } - t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) - c := New("uam") - c.Executable = script - - done := make(chan struct{}, 1) - start := time.Now() - go func() { - // Capture is a representative external read; any run-backed call works. - _, _ = c.Capture(context.Background(), "uam-x", 10) - done <- struct{}{} - }() - // Generous upper bound: well under the 60s sleep but above tmuxCallTimeout. - select { - case <-done: - if elapsed := time.Since(start); elapsed > tmuxCallTimeout+5*time.Second { - t.Fatalf("run returned but took too long: %v", elapsed) - } - case <-time.After(tmuxCallTimeout + 5*time.Second): - t.Fatalf("run blocked past its internal timeout (%v)", tmuxCallTimeout) - } -} - -// F17 — a caller-supplied deadline tighter than the internal timeout must still -// be honored (the internal timeout is an upper bound, not a floor). -func TestRunHonorsTighterCallerDeadline(t *testing.T) { - dir := t.TempDir() - script := filepath.Join(dir, "tmux") - if err := os.WriteFile(script, []byte("#!/bin/sh\nsleep 60\n"), 0o755); err != nil { - t.Fatal(err) - } - t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) - c := New("uam") - c.Executable = script - - ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) - defer cancel() - start := time.Now() - if _, err := c.Capture(ctx, "uam-x", 10); err == nil { - t.Fatal("expected error from a tighter caller deadline") - } - if elapsed := time.Since(start); elapsed > 5*time.Second { - t.Fatalf("tighter caller deadline not honored: %v", elapsed) - } -} diff --git a/internal/tmux/tmux.go b/internal/tmux/tmux.go deleted file mode 100644 index 7276052..0000000 --- a/internal/tmux/tmux.go +++ /dev/null @@ -1,512 +0,0 @@ -package tmux - -import ( - "context" - "errors" - "fmt" - "os" - "os/exec" - "path/filepath" - "regexp" - "sort" - "strings" - "sync" - "syscall" - "time" - - "github.com/RandomCodeSpace/unified-agent-manager/internal/execpath" - "github.com/RandomCodeSpace/unified-agent-manager/internal/log" -) - -// ErrInvalidSessionName is returned when a session name fails the allow-list. -var ErrInvalidSessionName = fmt.Errorf("session name failed allow-list") - -// tmuxCallTimeout is the upper bound on a single tmux invocation. It is an -// upper bound, not a floor: a tighter caller deadline still wins. Without it a -// hung tmux (stuck server, lost pty) would block a refresh indefinitely (F17). -const tmuxCallTimeout = 10 * time.Second - -// tmuxWaitDelay is the short grace period after the context is cancelled before -// the child's I/O pipes are force-closed. Without it CombinedOutput keeps -// blocking on a pipe a grandchild inherited, so the deadline never unblocks the -// caller (F17). Kept short so a cancelled call returns promptly. -const tmuxWaitDelay = 2 * time.Second - -// listCacheTTL is how long a List result is reused before re-querying tmux. A -// single refresh tick calls List once per enabled adapter against the same -// shared Client; without a cache that is one whole-server list-sessions per -// adapter per tick. The TTL is short enough that a cached result is always from -// the current tick, so freshness is unaffected, while N adapter scans collapse -// to one shell-out (F60). -const listCacheTTL = 250 * time.Millisecond - -const tmuxSetOption = "set-option" - -// sessionNameRE is the allow-list for tmux session names uam may create. It -// matches the canonical shape minted by adapter.startSession -// ("uam--"): a lowercase-alphanumeric provider segment and a -// hex id segment. The pattern admits no shell metacharacters, so a name that -// passes can be embedded in tmux argv without risk. -var sessionNameRE = regexp.MustCompile(`^uam-[a-z0-9]+-[0-9a-f]{1,16}$`) - -type Client struct { - Socket string - Executable string - - configMu sync.Mutex - configDone bool - - // now is the clock used to expire the List cache; overridable in tests. - now func() time.Time - // listMu guards the cached List result and the time it was taken. The cache - // collapses the per-adapter list-sessions storm within a single refresh tick - // into one shell-out (F60). - listMu sync.Mutex - listCache []SessionInfo - listCachedAt time.Time - listCacheOK bool -} - -func New(socket string) *Client { - if socket == "" { - socket = "uam" - } - return &Client{Socket: socket, now: time.Now} -} - -func (c *Client) baseArgs(args ...string) []string { - out := []string{"-L", c.Socket} - return append(out, args...) -} - -func (c *Client) run(ctx context.Context, args ...string) (string, error) { - exe, err := c.ExecutablePath() - if err != nil { - return "", err - } - // Bound every tmux invocation so a hung process can't wedge a refresh. This - // is an upper bound only — a tighter caller deadline still applies (F17). - // Interactive attach does not flow through run (cli execs the argv directly), - // so this never caps a foreground session. - ctx, cancel := context.WithTimeout(ctx, tmuxCallTimeout) - defer cancel() - // The tmux path is resolved from fixed system directories or injected as an - // absolute test path. tmux's own args are passed via argv (no shell). Where - // an arg is itself a /bin/sh command string (the new-session command built - // by ShellJoin), every value is POSIX single-quote escaped by shellQuote, so - // $(), ``, $VAR, and word-splitting cannot fire inside it. - cmd := exec.CommandContext(ctx, exe, c.baseArgs(args...)...) // #nosec G204 - // Reap a hung tmux promptly once the deadline fires: WaitDelay caps how long - // CombinedOutput waits for the (possibly inherited) output pipe before force- - // closing it, so the timeout actually unblocks the caller (F17). - cmd.WaitDelay = tmuxWaitDelay - out, err := cmd.CombinedOutput() - if err != nil { - return string(out), fmt.Errorf("tmux %s: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(string(out))) - } - return string(out), nil -} - -func (c *Client) ExecutablePath() (string, error) { - if v := os.Getenv("UAM_TMUX_BIN"); v != "" { - if err := execpath.ValidateAbsoluteExecutable(v); err != nil { - return "", fmt.Errorf("invalid UAM_TMUX_BIN: %w", err) - } - return v, nil - } - if c.Executable == "" { - return execpath.Resolve("tmux") - } - if err := execpath.ValidateAbsoluteExecutable(c.Executable); err != nil { - return "", fmt.Errorf("invalid tmux executable: %w", err) - } - return c.Executable, nil -} - -func (c *Client) CreateSession(ctx context.Context, name, cwd string, env map[string]string, command []string) error { - if !sessionNameRE.MatchString(name) { - return fmt.Errorf("refusing to create session: invalid name %q: %w", name, ErrInvalidSessionName) - } - args := []string{"new-session", "-d", "-s", name, "-x", "200", "-y", "50"} - if cwd != "" { - args = append(args, "-c", cwd) - } - args = append(args, ShellJoin(commandWithEnv(env, command))) - _, err := c.run(ctx, args...) - return err -} - -// SetSessionLabel records the user-facing label for a live session so tmux's -// status line, terminal title, and window list show the name the user gave it -// rather than the canonical uam-- session name (which must stay -// machine-parseable — see CreateSession / sessionNameRE). It writes the label -// to the @uam_label session option (applyServerConfig points status-left and -// set-titles-string at it, falling back to #S when unset) and renames the -// session's window to the short name. Cosmetic: callers treat failures as -// non-fatal. -func (c *Client) SetSessionLabel(ctx context.Context, session, label, window string) error { - if _, err := c.run(ctx, tmuxSetOption, "-t", session, "@uam_label", label); err != nil { - return fmt.Errorf("set @uam_label for %s: %w", session, err) - } - if window != "" { - if _, err := c.run(ctx, "rename-window", "-t", session, window); err != nil { - return fmt.Errorf("rename-window for %s: %w", session, err) - } - } - return nil -} - -func commandWithEnv(env map[string]string, command []string) []string { - if len(env) == 0 { - return command - } - keys := make([]string, 0, len(env)) - for k := range env { - keys = append(keys, k) - } - sort.Strings(keys) - out := make([]string, 0, 1+len(keys)+len(command)) - out = append(out, "env") - for _, k := range keys { - out = append(out, k+"="+env[k]) - } - out = append(out, command...) - return out -} - -func (c *Client) List(ctx context.Context) ([]SessionInfo, error) { - if cached, ok := c.cachedList(); ok { - return cached, nil - } - out, err := c.run(ctx, "list-sessions", "-F", ListFormat) - if err != nil { - // tmux exits non-zero when the private server has no sessions. Match the - // known no-server phrasings only; a genuine failure must still propagate. - if isNoServerErr(err) { - c.storeList(nil) - return nil, nil - } - return nil, err - } - // Server is up — apply uam-friendly settings (latches once it succeeds). - _ = c.EnsureServerConfig(ctx) - // A malformed line (e.g. a cwd containing '|') yields the parsed subset plus - // ErrMalformedSessionLines; ParseListSessions already logged it. Returning - // the subset keeps the healthy sessions visible instead of blanking the - // whole list, so the sentinel is intentionally not propagated (F11). - sessions, err := ParseListSessions(out) - if errors.Is(err, ErrMalformedSessionLines) { - err = nil - } - if err != nil { - return sessions, err - } - c.storeList(sessions) - return cloneSessionInfos(sessions), nil -} - -// cachedList returns a defensive copy of the cached List result when it is -// still within listCacheTTL, collapsing the per-adapter list-sessions storm in -// one refresh tick into a single shell-out (F60). -func (c *Client) cachedList() ([]SessionInfo, bool) { - clock := c.now - if clock == nil { - clock = time.Now - } - c.listMu.Lock() - defer c.listMu.Unlock() - if !c.listCacheOK || clock().Sub(c.listCachedAt) >= listCacheTTL { - return nil, false - } - return cloneSessionInfos(c.listCache), true -} - -func (c *Client) storeList(sessions []SessionInfo) { - clock := c.now - if clock == nil { - clock = time.Now - } - c.listMu.Lock() - defer c.listMu.Unlock() - c.listCache = cloneSessionInfos(sessions) - c.listCachedAt = clock() - c.listCacheOK = true -} - -// cloneSessionInfos returns a shallow copy so a caller mutating the returned -// slice cannot corrupt the cache (SessionInfo holds only value fields). -func cloneSessionInfos(in []SessionInfo) []SessionInfo { - if in == nil { - return nil - } - out := make([]SessionInfo, len(in)) - copy(out, in) - return out -} - -func (c *Client) Capture(ctx context.Context, target string, lines int) (string, error) { - if lines <= 0 { - lines = 200 - } - out, err := c.run(ctx, "capture-pane", "-p", "-t", target, "-S", fmt.Sprintf("-%d", lines), "-J") - return out, err -} - -func (c *Client) SendKeysLiteral(ctx context.Context, target, text string) error { - _, err := c.run(ctx, "send-keys", "-t", target, "-l", "--", text) - return err -} - -func (c *Client) SendEnter(ctx context.Context, target string) error { - _, err := c.run(ctx, "send-keys", "-t", target, "Enter") - return err -} - -// SendLine types text into the target pane and submits it with a single Enter. -// -// tmux's `send-keys -l` interprets an embedded newline as Enter, so passing a -// multi-line prompt as one literal made the agent submit it line-by-line (F13). -// Instead we trim a trailing newline, then send each interior line as its own -// literal keystroke separated by a literal "\n" keystroke — no interior Enter -// events — and submit once at the end. A single-line prompt takes the original -// one-literal-plus-one-Enter path byte-for-byte. -func (c *Client) SendLine(ctx context.Context, target, text string) error { - text = strings.TrimRight(text, "\n") - lines := strings.Split(text, "\n") - for i, line := range lines { - if i > 0 { - // Send the line separator as its own literal keystroke so it lands - // in the input buffer instead of submitting the partial prompt. - if err := c.SendKeysLiteral(ctx, target, "\n"); err != nil { - return err - } - } - if err := c.SendKeysLiteral(ctx, target, line); err != nil { - return err - } - } - return c.SendEnter(ctx, target) -} - -func (c *Client) Kill(ctx context.Context, target string) error { - _, err := c.run(ctx, "kill-session", "-t", target) - return err -} - -// KillServer tears down the entire private tmux server (and every session it -// holds) via `tmux -L kill-server`. It is idempotent: a server that is -// already down makes tmux exit non-zero with a no-server message, which is -// treated as success so `uam kill-all` is safe to run repeatedly. A genuine -// failure still propagates (F24). -func (c *Client) KillServer(ctx context.Context) error { - if _, err := c.run(ctx, "kill-server"); err != nil { - if isNoServerErr(err) { - return nil - } - return err - } - return nil -} - -// isNoServerErr reports whether err is tmux's "the private server has no -// running instance" message. Different tmux versions phrase it differently; -// 3.4 reports the missing socket as "(No such file or directory)". -func isNoServerErr(err error) bool { - if err == nil { - return false - } - msg := err.Error() - return strings.Contains(msg, "no server running") || - strings.Contains(msg, "failed to connect") || - strings.Contains(msg, "No such file or directory") -} - -// EnsureServerConfig applies session-friendly defaults to the private tmux -// server: enable mouse mode so wheel events scroll tmux pane history instead -// of leaking to full-screen prompts as history navigation, keep OSC 52 -// clipboard sync enabled for tmux-side copies, and swallow Ctrl+Z so -// it can't suspend the agent in the foreground pane. -// -// The configuration is applied at most once SUCCESSFULLY. The first dispatch -// runs before the server exists, so set-option fails; latching that failure -// (the old sync.Once behaviour) meant the config never applied for the life of -// the process (F25). Instead we retry until a call succeeds, then latch. -func (c *Client) EnsureServerConfig(ctx context.Context) error { - c.configMu.Lock() - defer c.configMu.Unlock() - if c.configDone { - return nil - } - if err := c.applyServerConfig(ctx); err != nil { - return err - } - c.configDone = true - return nil -} - -func (c *Client) applyServerConfig(ctx context.Context) error { - if _, err := c.run(ctx, tmuxSetOption, "-g", "mouse", "on"); err != nil { - return fmt.Errorf("set mouse on: %w", err) - } - // tmux 3.4 ships no default WheelUpPane/WheelDownPane binding (only the - // status-line wheel), so `mouse on` alone makes tmux capture wheel events - // without scrolling anything — they leak to the agent prompt as history - // navigation. Restore the classic pane-wheel binding: enter copy-mode and - // scroll history, forwarding the wheel to the app only when it has grabbed - // the mouse or the pane is already in copy-mode. (`send-keys -M` is a no-op - // for an app that never enabled mouse reporting, so it can't leak to the - // prompt.) Best-effort: a tmux too old for the syntax must not brick - // dispatch or its latch — log, not fail. - for _, b := range [][]string{ - {"bind-key", "-T", "root", "WheelUpPane", "if-shell", "-F", - "#{||:#{pane_in_mode},#{mouse_any_flag}}", "send-keys -M", "copy-mode -e"}, - {"bind-key", "-T", "root", "WheelDownPane", "send-keys", "-M"}, - } { - if out, err := c.run(ctx, b...); err != nil { - log.Warn("set tmux wheel binding failed", "binding", strings.Join(b, " "), "error", err, "output", strings.TrimSpace(out)) - } - } - // Forward any tmux-side copy to the host terminal's clipboard via OSC 52 - // so the user can paste outside the session with the usual shortcut. - if _, err := c.run(ctx, tmuxSetOption, "-g", "set-clipboard", "on"); err != nil { - return fmt.Errorf("set set-clipboard on: %w", err) - } - if _, err := c.run(ctx, "bind-key", "-n", "C-z", "display-message", "Ctrl+Z is disabled in uam sessions; use Ctrl+b d to detach"); err != nil { - return fmt.Errorf("bind C-z: %w", err) - } - // Surface the user-facing session name (written per-session to @uam_label by - // SetSessionLabel) in the status line, terminal title, and window list, - // instead of the canonical uam-- name that stays as #S for uam's - // own parsing. automatic-rename/allow-rename are disabled so the running - // agent can't clobber the window name. These are cosmetic, so a tmux that - // rejects one must not block the rest of the config or its latch — log, not - // fail. - for _, opt := range [][2]string{ - {"automatic-rename", "off"}, - {"allow-rename", "off"}, - {"status-left-length", "40"}, - {"status-left", " #{?@uam_label,#{@uam_label},#S} "}, - {"set-titles", "on"}, - {"set-titles-string", "#{?@uam_label,#{@uam_label},#S}"}, - } { - if out, err := c.run(ctx, tmuxSetOption, "-g", opt[0], opt[1]); err != nil { - log.Warn("set tmux display option failed", "option", opt[0], "error", err, "output", strings.TrimSpace(out)) - } - } - // Hook install is best-effort. If we can't resolve a safe binary path, - // the rest of uam still works — only the exit-in-session signal is lost, - // and the user can recover via Ctrl+X or `uam rm`. We log (not return) the - // failure so a missing hook is diagnosable without bricking dispatch (F56). - if cmd := sessionClosedHookCommand(); cmd != "" { - if out, err := c.run(ctx, "set-hook", "-g", "session-closed", cmd); err != nil { - log.Warn("installing session-closed hook failed", "error", err, "output", strings.TrimSpace(out)) - } - } - return nil -} - -// sessionClosedHookCommand returns the tmux command to install as the -// session-closed hook, or empty string if the uam binary path isn't safe -// to embed (path contains characters that would break shell quoting). -// -// The hook fires whenever a session is destroyed — both when the user types -// `exit` inside the agent and when uam itself calls kill-session. In either -// case the record gets flagged closed_by_user; uam-initiated paths that -// follow up by deleting the record (Ctrl+X / `uam rm`) make the flag moot. -func sessionClosedHookCommand() string { - exe, err := os.Executable() - if err != nil { - return "" - } - if err := execpath.ValidateAbsoluteExecutable(exe); err != nil { - return "" - } - return hookCommandForExe(exe) -} - -// hookCommandForExe builds the session-closed hook command for a given binary -// path, or returns empty string when the path isn't safe to embed. It is split -// out from sessionClosedHookCommand so the rejection branch can be table-tested -// directly without faking os.Executable (F51). The real-file check -// (ValidateAbsoluteExecutable) stays in the caller; this seam only enforces the -// quoting-safety rules that govern whether a path can be embedded. -func hookCommandForExe(exe string) string { - // An absolute path is required: the hook is a /bin/sh command string and a - // relative path would resolve against tmux's cwd, not uam's install dir. - if !filepath.IsAbs(exe) { - return "" - } - // Reject paths with shell metacharacters we'd otherwise need to escape. - // Real uam installs land in standard bin directories without these, - // and bailing out is safer than risking a malformed hook. - if strings.ContainsAny(exe, "\"'\\$`") { - return "" - } - // run-shell receives a /bin/sh command string. tmux expands - // #{hook_session_name} INTO that string before sh parses it, so the single - // quotes here do NOT neutralize a hostile name on their own — a name - // containing a quote would break out. Safety comes from CreateSession's - // allow-list (sessionNameRE), which guarantees every name we ever create is - // [a-z0-9-] only; the quoting then merely keeps a benign name as one argv - // token. - return fmt.Sprintf(`run-shell "%s notify-closed '#{hook_session_name}'"`, exe) -} - -func (c *Client) HasSession(ctx context.Context, target string) bool { - _, err := c.run(ctx, "has-session", "-t", target) - return err == nil -} - -func (c *Client) AttachArgv(target string) ([]string, error) { - exe, err := c.ExecutablePath() - if err != nil { - return nil, err - } - return append([]string{exe}, c.baseArgs("attach", "-t", target)...), nil -} - -func (c *Client) AttachArgs(target string) []string { return c.baseArgs("attach", "-t", target) } - -func PaneAlive(pid int) bool { - if pid <= 0 { - return false - } - return syscall.Kill(pid, 0) == nil -} - -func ShellJoin(args []string) string { - quoted := make([]string, 0, len(args)) - for _, arg := range args { - quoted = append(quoted, shellQuote(arg)) - } - return strings.Join(quoted, " ") -} - -func shellQuote(s string) string { - if s == "" { - return "''" - } - if strings.IndexFunc(s, func(r rune) bool { - return !isShellSafeRune(r) - }) == -1 { - return s - } - // POSIX single-quote escaping: wrap in single quotes and rewrite any - // embedded single quote as the close-reopen idiom '\''. Inside single - // quotes /bin/sh performs no expansion, so $(), ``, $VAR, and newlines - // all reach the command literally. - return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" -} - -func isShellSafeRune(r rune) bool { - if r == '_' || r == '-' || r == '.' || r == '/' || r == ':' || r == '=' || r == '+' { - return true - } - if r >= '0' && r <= '9' { - return true - } - if r >= 'A' && r <= 'Z' { - return true - } - return r >= 'a' && r <= 'z' -} diff --git a/internal/tmux/tmux_test.go b/internal/tmux/tmux_test.go deleted file mode 100644 index 9042d16..0000000 --- a/internal/tmux/tmux_test.go +++ /dev/null @@ -1,618 +0,0 @@ -package tmux - -import ( - "bytes" - "context" - "log/slog" - "os" - "os/exec" - "path/filepath" - "strings" - "testing" - - "github.com/RandomCodeSpace/unified-agent-manager/internal/log" -) - -func TestClientCommandsWithFakeTmux(t *testing.T) { - c, logPath := setupFakeTmuxClient(t) - if c.Socket != "uam" { - t.Fatal(c.Socket) - } - assertCreateSessionCommand(t, c, logPath) - assertClientReadCommands(t, c) - assertClientWriteCommands(t, c) - assertClientHelpers(t, c) -} - -func setupFakeTmuxClient(t *testing.T) (*Client, string) { - t.Helper() - dir := t.TempDir() - script := filepath.Join(dir, "tmux") - if err := os.WriteFile(script, []byte(`#!/bin/sh -printf '%s\n' "$*" >> "$TMUX_LOG" -case "$*" in - *"list-sessions"*) echo "uam-a|1|0|1|/tmp|bash" ;; - *"capture-pane"*) echo "pane text" ;; - *"has-session"*) exit 0 ;; -esac -exit 0 -`), 0o755); err != nil { - t.Fatal(err) - } - t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) - logPath := filepath.Join(dir, "log") - t.Setenv("TMUX_LOG", logPath) - c := New("uam") - c.Executable = script - return c, logPath -} - -func assertCreateSessionCommand(t *testing.T, c *Client, logPath string) { - t.Helper() - if err := c.CreateSession(context.Background(), "uam-a-deadbeef", "/tmp", map[string]string{"A": "B"}, []string{"cmd", "arg with space"}); err != nil { - t.Fatal(err) - } - logData, err := os.ReadFile(logPath) - if err != nil { - t.Fatal(err) - } - logText := string(logData) - if strings.Contains(logText, " -e ") { - t.Fatalf("CreateSession should not rely on tmux new-session -e because older tmux rejects it: %s", logText) - } - if !strings.Contains(logText, "env A=B cmd 'arg with space'") { - t.Fatalf("CreateSession should prefix the shell command with env assignments: %s", logText) - } -} - -func assertClientReadCommands(t *testing.T, c *Client) { - t.Helper() - list, err := c.List(context.Background()) - if err != nil || len(list) != 1 { - t.Fatalf("list=%v err=%v", list, err) - } - cap, err := c.Capture(context.Background(), "uam-a", 0) - if err != nil || !strings.Contains(cap, "pane text") { - t.Fatalf("cap=%q err=%v", cap, err) - } -} - -func assertClientWriteCommands(t *testing.T, c *Client) { - t.Helper() - for _, action := range []func() error{ - func() error { return c.SendLine(context.Background(), "uam-a", "hello") }, - func() error { return c.Kill(context.Background(), "uam-a") }, - } { - if err := action(); err != nil { - t.Fatal(err) - } - } -} - -func assertClientHelpers(t *testing.T, c *Client) { - t.Helper() - if !c.HasSession(context.Background(), "uam-a") { - t.Fatal("expected session") - } - argv, err := c.AttachArgv("uam-a") - if err != nil { - t.Fatalf("AttachArgv: %v", err) - } - if len(argv) != 6 || argv[0] != c.Executable || argv[1] != "-L" { - t.Fatalf("attach argv: %v", argv) - } - if got := c.AttachArgs("uam-a"); len(got) != 5 || got[0] != "-L" { - t.Fatalf("attach args: %v", got) - } - if !PaneAlive(os.Getpid()) { - t.Fatal("current process should be alive") - } - if PaneAlive(-1) { - t.Fatal("negative pid should not be alive") - } - joined := ShellJoin([]string{"abc", "two words"}) - if !strings.Contains(joined, "two words") { - t.Fatalf("join=%s", joined) - } -} - -func TestEnsureServerConfigInstallsSessionClosedHook(t *testing.T) { - c, logPath := setupFakeTmuxClient(t) - if err := c.EnsureServerConfig(context.Background()); err != nil { - t.Fatalf("EnsureServerConfig: %v", err) - } - data, err := os.ReadFile(logPath) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(string(data), "set-hook -g session-closed") { - t.Fatalf("session-closed hook not installed: %s", data) - } - if !strings.Contains(string(data), "notify-closed") { - t.Fatalf("hook command should reference notify-closed: %s", data) - } - // #{hook_session_name} must reach tmux verbatim so it can substitute - // the dying session's name at fire time. - if !strings.Contains(string(data), "hook_session_name") { - t.Fatalf("hook command must pass through tmux format variable: %s", data) - } -} - -// SetSessionLabel must write the user-facing label to the @uam_label session -// option and rename the window, so tmux's display shows the user's name while -// the canonical uam-- session name (#S) is left untouched. -func TestSetSessionLabelWritesLabelAndRenamesWindow(t *testing.T) { - c, logPath := setupFakeTmuxClient(t) - if err := c.SetSessionLabel(context.Background(), "uam-opencode-deadbeef", "tracker · opencode", "tracker"); err != nil { - t.Fatalf("SetSessionLabel: %v", err) - } - data, err := os.ReadFile(logPath) - if err != nil { - t.Fatal(err) - } - logText := string(data) - if !strings.Contains(logText, "set-option -t uam-opencode-deadbeef @uam_label tracker · opencode") { - t.Fatalf("expected @uam_label set-option: %s", logText) - } - if !strings.Contains(logText, "rename-window -t uam-opencode-deadbeef tracker") { - t.Fatalf("expected rename-window: %s", logText) - } -} - -// The private-server config must point tmux's status line and terminal title -// at @uam_label (with a #S fallback) and disable automatic-rename so the agent -// can't clobber the user-facing window name. -func TestEnsureServerConfigSetsDisplayName(t *testing.T) { - c, logPath := setupFakeTmuxClient(t) - if err := c.EnsureServerConfig(context.Background()); err != nil { - t.Fatalf("EnsureServerConfig: %v", err) - } - data, err := os.ReadFile(logPath) - if err != nil { - t.Fatal(err) - } - logText := string(data) - for _, want := range []string{ - "set-option -g automatic-rename off", - "set-option -g status-left", - "@uam_label", - "set-option -g set-titles-string", - } { - if !strings.Contains(logText, want) { - t.Fatalf("EnsureServerConfig missing %q: %s", want, logText) - } - } -} - -// The private-server config must let wheel events scroll tmux pane history -// instead of leaking to full-screen agent prompts as history navigation, while -// keeping tmux-side OSC 52 clipboard sync enabled. tmux 3.4 ships no default -// WheelUpPane/WheelDownPane binding (only the status-line wheel), so `mouse on` -// alone captures wheel events without scrolling anything; the pane wheel must be -// bound explicitly to drive copy-mode scrollback. -func TestEnsureServerConfigEnablesMousePaneScrollback(t *testing.T) { - c, logPath := setupFakeTmuxClient(t) - if err := c.EnsureServerConfig(context.Background()); err != nil { - t.Fatalf("EnsureServerConfig: %v", err) - } - data, err := os.ReadFile(logPath) - if err != nil { - t.Fatal(err) - } - logText := string(data) - for _, want := range []string{ - "set-option -g mouse on", - "set-option -g set-clipboard on", - // Wheel over a pane enters copy-mode and scrolls history (forwarding to - // the app only when it grabbed the mouse or the pane is already in copy - // mode), instead of leaking to the agent prompt as input navigation. - "bind-key -T root WheelUpPane", - "copy-mode -e", - "bind-key -T root WheelDownPane send-keys -M", - } { - if !strings.Contains(logText, want) { - t.Fatalf("EnsureServerConfig missing %q: %s", want, logText) - } - } - for _, unwanted := range []string{"MouseDrag1Pane", "MouseDown3Pane", "copy-pipe-and-cancel"} { - if strings.Contains(logText, unwanted) { - t.Fatalf("EnsureServerConfig should not install custom mouse clipboard binding %q: %s", unwanted, logText) - } - } -} - -// F25 — EnsureServerConfig must not latch a failure. On first dispatch the -// server doesn't exist yet, so set-option fails; the next call (after the -// server is up) must retry and succeed rather than caching the earlier error. -func TestEnsureServerConfigRetriesAfterServerlessFailure(t *testing.T) { - dir := t.TempDir() - script := filepath.Join(dir, "tmux") - // set-option fails until a sentinel file exists (simulating "server up"). - if err := os.WriteFile(script, []byte(`#!/bin/sh -printf '%s\n' "$*" >> "$TMUX_LOG" -case "$*" in - *"set-option"*) - if [ ! -f "$TMUX_UP" ]; then - echo "no server running on /tmp/tmux" >&2 - exit 1 - fi - ;; -esac -exit 0 -`), 0o755); err != nil { - t.Fatal(err) - } - t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) - t.Setenv("TMUX_LOG", filepath.Join(dir, "log")) - upFile := filepath.Join(dir, "up") - t.Setenv("TMUX_UP", upFile) - - c := New("uam") - c.Executable = script - - if err := c.EnsureServerConfig(context.Background()); err == nil { - t.Fatal("expected serverless EnsureServerConfig to fail") - } - // Server comes up. - if err := os.WriteFile(upFile, []byte("x"), 0o644); err != nil { - t.Fatal(err) - } - if err := c.EnsureServerConfig(context.Background()); err != nil { - t.Fatalf("EnsureServerConfig must retry and succeed once the server is up, got: %v", err) - } - // A third call after success must be a no-op (success-latched): the log - // length must not grow because applyServerConfig is not re-run. - before, _ := os.ReadFile(filepath.Join(dir, "log")) - if err := c.EnsureServerConfig(context.Background()); err != nil { - t.Fatalf("post-success EnsureServerConfig: %v", err) - } - after, _ := os.ReadFile(filepath.Join(dir, "log")) - if len(after) != len(before) { - t.Fatalf("EnsureServerConfig must not re-apply after a successful latch:\nbefore=%s\nafter=%s", before, after) - } -} - -// F56 — a failing session-closed hook install must be logged (best-effort, -// non-fatal) rather than silently discarded. -func TestApplyServerConfigLogsHookInstallFailure(t *testing.T) { - if sessionClosedHookCommand() == "" { - t.Skip("test binary path rejected as unsafe — no hook command to install") - } - dir := t.TempDir() - script := filepath.Join(dir, "tmux") - // Everything succeeds except set-hook, which fails. - if err := os.WriteFile(script, []byte(`#!/bin/sh -printf '%s\n' "$*" >> "$TMUX_LOG" -case "$*" in - *"set-hook"*) echo "hook boom" >&2; exit 1 ;; -esac -exit 0 -`), 0o755); err != nil { - t.Fatal(err) - } - t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) - t.Setenv("TMUX_LOG", filepath.Join(dir, "log")) - c := New("uam") - c.Executable = script - - var buf bytes.Buffer - prev := log.SetLogger(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))) - defer log.SetLogger(prev) - - // Hook failure is non-fatal: EnsureServerConfig still returns nil. - if err := c.EnsureServerConfig(context.Background()); err != nil { - t.Fatalf("hook install failure must stay non-fatal, got: %v", err) - } - if !strings.Contains(buf.String(), "session-closed hook") { - t.Fatalf("hook install failure should be logged, got: %q", buf.String()) - } -} - -// F51 — hookCommandForExe is the testable seam: it takes the binary path -// explicitly so the rejection branch (shell metacharacters, relative paths) -// can be table-tested without faking os.Executable. The old test could only -// exercise the real test-binary path and had to t.Skip the rejection branch. -func TestHookCommandForExe(t *testing.T) { - cases := []struct { - name string - exe string - wantEmpty bool - }{ - {"clean absolute path", "/usr/local/bin/uam", false}, - {"relative path rejected", "uam", true}, - {"dot-relative path rejected", "./uam", true}, - {"double-quote rejected", `/usr/local/bin/u"am`, true}, - {"single-quote rejected", "/usr/local/bin/u'am", true}, - {"backslash rejected", `/usr/local/bin/u\am`, true}, - {"dollar rejected", "/usr/local/bin/u$am", true}, - {"backtick rejected", "/usr/local/bin/u`am`", true}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - cmd := hookCommandForExe(tc.exe) - if tc.wantEmpty { - if cmd != "" { - t.Fatalf("hookCommandForExe(%q) = %q, want empty (rejected)", tc.exe, cmd) - } - return - } - if cmd == "" { - t.Fatalf("hookCommandForExe(%q) was rejected, want a command", tc.exe) - } - if !strings.Contains(cmd, "run-shell") { - t.Fatalf("hook command must use run-shell: %q", cmd) - } - if !strings.Contains(cmd, "notify-closed") { - t.Fatalf("hook command must reference notify-closed: %q", cmd) - } - if !strings.Contains(cmd, "'#{hook_session_name}'") { - t.Fatalf("session name must be single-quoted for the inner shell: %q", cmd) - } - if !strings.Contains(cmd, tc.exe) { - t.Fatalf("hook command must embed the binary path: %q", cmd) - } - }) - } -} - -// TestSessionClosedHookCommandUsesRealBinary verifies the wrapper resolves the -// running binary and delegates to hookCommandForExe. The deterministic -// rejection-branch coverage lives in TestHookCommandForExe above. -func TestSessionClosedHookCommandUsesRealBinary(t *testing.T) { - exe, err := os.Executable() - if err != nil { - t.Skipf("os.Executable unavailable: %v", err) - } - if got, want := sessionClosedHookCommand(), hookCommandForExe(exe); got != want { - t.Fatalf("sessionClosedHookCommand() = %q, want %q (from real binary path)", got, want) - } -} - -// TestShellQuoteIsInertUnderSh proves that values flowing through ShellJoin -// are passed literally to /bin/sh and cannot trigger command substitution, -// variable expansion, or word-splitting. /bin/sh is the faithful sink that -// tmux's `new-session ` ultimately feeds, so exercising sh directly -// keeps the test CI/air-gap portable (no real tmux required). -func TestShellQuoteIsInertUnderSh(t *testing.T) { - if _, err := os.Stat("/bin/sh"); err != nil { - t.Skipf("/bin/sh unavailable: %v", err) - } - dangerous := []struct { - name string - value string - }{ - {"command substitution", "$(touch SENTINEL)"}, - {"backtick substitution", "`touch SENTINEL`"}, - {"variable expansion", "$HOME"}, - {"embedded single quote", "a'b"}, - {"embedded newline", "line1\nline2"}, - } - for _, tc := range dangerous { - t.Run(tc.name, func(t *testing.T) { - dir := t.TempDir() - sentinel := filepath.Join(dir, "SENTINEL") - // Mirror the real call site: env-prefixed command joined for sh. - joined := ShellJoin(commandWithEnv(map[string]string{"X": tc.value}, []string{"printf", "%s", tc.value})) - // Run with cwd=dir so a relative `touch SENTINEL` (if substitution - // fired) would land where we check for it. - cmd := exec.Command("/bin/sh", "-c", joined) - cmd.Dir = dir - out, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("sh -c %q failed: %v (out=%q)", joined, err, out) - } - if _, statErr := os.Stat(sentinel); statErr == nil { - t.Fatalf("sentinel created — value was NOT inert: joined=%q", joined) - } - // The literal value must survive into argv (the env=VALUE prefix - // also contains it). The first printf token echoes it back verbatim. - if !strings.Contains(string(out), tc.value) { - t.Fatalf("value not passed literally: want substring %q in out=%q (joined=%q)", tc.value, out, joined) - } - }) - } -} - -func TestShellQuoteFormByInput(t *testing.T) { - cases := []struct { - in string - want string - }{ - {"", "''"}, - {"safe-token.v1", "safe-token.v1"}, - {"a'b", `'a'\''b'`}, - } - for _, tc := range cases { - if got := shellQuote(tc.in); got != tc.want { - t.Fatalf("shellQuote(%q) = %q, want %q", tc.in, got, tc.want) - } - } -} - -func TestCreateSessionRejectsUnsafeNames(t *testing.T) { - for _, name := range []string{ - "uam-claude-deadbeef'; touch x", - "uam-claude-$(touch x)", - "uam-claude-deadbeef; rm -rf /", - "`touch x`", - } { - c, logPath := setupFakeTmuxClient(t) - err := c.CreateSession(context.Background(), name, "/tmp", nil, []string{"cmd"}) - if err == nil { - t.Fatalf("CreateSession accepted unsafe name %q", name) - } - data, readErr := os.ReadFile(logPath) - if readErr != nil && !os.IsNotExist(readErr) { - t.Fatal(readErr) - } - if strings.Contains(string(data), name) { - t.Fatalf("fake tmux was invoked with unsafe name %q: %s", name, data) - } - } -} - -func TestCreateSessionAcceptsValidUamNames(t *testing.T) { - // Canonical shape produced by tmux_adapter.go: uam--. - c, logPath := setupFakeTmuxClient(t) - if err := c.CreateSession(context.Background(), "uam-claude-deadbeef", "/tmp", nil, []string{"cmd"}); err != nil { - t.Fatalf("CreateSession rejected a valid name: %v", err) - } - data, err := os.ReadFile(logPath) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(string(data), "uam-claude-deadbeef") { - t.Fatalf("valid session not created: %s", data) - } -} - -// newSendKeysRecorder returns a Client whose fake tmux records, for each -// invocation, one marker line "INVOKE " where is the -// keystroke kind ("literal" for send-keys -l, "enter" for send-keys Enter, -// "other" otherwise). Using $@ positionally is newline-safe: an embedded -// newline in the literal text cannot inflate the invocation count. -func newSendKeysRecorder(t *testing.T) (*Client, string) { - t.Helper() - dir := t.TempDir() - script := filepath.Join(dir, "tmux") - if err := os.WriteFile(script, []byte(`#!/bin/sh -# Drop "-L " so $1 is the subcommand. -shift 2 -kind=other -case " $* " in - *" -l --"*) kind=literal ;; - *" Enter"*) kind=enter ;; -esac -# Record exactly one marker line per invocation (newline-safe count). -printf 'INVOKE %s\n' "$kind" >> "$TMUX_LOG" -exit 0 -`), 0o755); err != nil { - t.Fatal(err) - } - logPath := filepath.Join(dir, "log") - t.Setenv("TMUX_LOG", logPath) - c := New("uam") - c.Executable = script - return c, logPath -} - -// F13 characterization — a single-line prompt must keep the byte-identical -// sequence: one literal send-keys carrying the text, then exactly one Enter. -func TestSendLineSingleLineSequence(t *testing.T) { - c, logPath := setupFakeTmuxClient(t) - if err := c.SendLine(context.Background(), "uam-a", "hello world"); err != nil { - t.Fatalf("SendLine: %v", err) - } - data, _ := os.ReadFile(logPath) - logText := string(data) - if !strings.Contains(logText, "send-keys -t uam-a -l -- hello world") { - t.Fatalf("single-line literal sequence changed: %s", logText) - } - if strings.Count(logText, "Enter") != 1 { - t.Fatalf("single-line prompt must emit exactly one Enter: %s", logText) - } -} - -// F13 — a multi-line prompt must submit ONCE: exactly one Enter keystroke (the -// final submit) and no interior Enter events, regardless of how many literal -// segments are sent. -func TestSendLineDoesNotEmitLiteralEmbeddedNewline(t *testing.T) { - c, logPath := newSendKeysRecorder(t) - if err := c.SendLine(context.Background(), "uam-a", "line1\nline2\nline3"); err != nil { - t.Fatalf("SendLine: %v", err) - } - data, _ := os.ReadFile(logPath) - enters := strings.Count(string(data), "INVOKE enter") - if enters != 1 { - t.Fatalf("multi-line prompt must emit exactly one trailing Enter, got %d: %s", enters, data) - } - // More than one literal invocation proves the newlines were split into - // separate keystrokes rather than bundled into one submit-on-newline blob. - if literals := strings.Count(string(data), "INVOKE literal"); literals < 3 { - t.Fatalf("interior newlines must be split into separate literal keystrokes, got %d: %s", literals, data) - } -} - -// F13 — a trailing newline is trimmed so a normal "text\n" stays a single -// submit rather than emitting a spurious extra Enter or an empty trailing -// segment. -func TestSendLineTrimsTrailingNewline(t *testing.T) { - c, logPath := newSendKeysRecorder(t) - if err := c.SendLine(context.Background(), "uam-a", "solo\n"); err != nil { - t.Fatalf("SendLine: %v", err) - } - data, _ := os.ReadFile(logPath) - if enters := strings.Count(string(data), "INVOKE enter"); enters != 1 { - t.Fatalf("trailing-newline prompt must emit exactly one Enter, got %d: %s", enters, data) - } - if literals := strings.Count(string(data), "INVOKE literal"); literals != 1 { - t.Fatalf("trailing-newline prompt should send one literal segment, got %d: %s", literals, data) - } -} - -// newScriptedClient builds a Client whose fake tmux runs the given /bin/sh body. -func newScriptedClient(t *testing.T, body string) *Client { - t.Helper() - dir := t.TempDir() - script := filepath.Join(dir, "tmux") - if err := os.WriteFile(script, []byte("#!/bin/sh\n"+body), 0o755); err != nil { - t.Fatal(err) - } - c := New("uam") - c.Executable = script - return c -} - -// F10 — tmux 3.4 emits "(No such file or directory)" instead of the legacy -// "no server running" string when listing sessions against a fresh socket. -// List must treat that as an empty server, not an error. -func TestListReturnsEmptyOnFreshSocket(t *testing.T) { - c := newScriptedClient(t, `case "$*" in - *"list-sessions"*) echo "error connecting to /tmp/tmux-1000/uam (No such file or directory)" >&2; exit 1 ;; -esac -exit 0 -`) - list, err := c.List(context.Background()) - if err != nil { - t.Fatalf("fresh socket should yield no error, got %v", err) - } - if len(list) != 0 { - t.Fatalf("fresh socket should yield no sessions, got %v", list) - } -} - -// F10 trap — a genuine list-sessions failure (not a missing server) must still -// propagate as an error rather than being swallowed as an empty list. -func TestListPropagatesGenuineError(t *testing.T) { - c := newScriptedClient(t, `case "$*" in - *"list-sessions"*) echo "lost server: protocol version mismatch" >&2; exit 1 ;; -esac -exit 0 -`) - if _, err := c.List(context.Background()); err == nil { - t.Fatal("genuine list-sessions failure must propagate as an error") - } -} - -func TestExecutablePathRejectsUnsafeOverrides(t *testing.T) { - c := New("uam") - c.Executable = "tmux" - if _, err := c.ExecutablePath(); err == nil { - t.Fatal("relative client executable should be rejected") - } - - nonExecutable := filepath.Join(t.TempDir(), "tmux") - if err := os.WriteFile(nonExecutable, []byte("x"), 0o644); err != nil { - t.Fatal(err) - } - c.Executable = nonExecutable - if _, err := c.ExecutablePath(); err == nil { - t.Fatal("non-executable client executable should be rejected") - } - - t.Setenv("UAM_TMUX_BIN", "tmux") - c.Executable = "" - if _, err := c.ExecutablePath(); err == nil { - t.Fatal("relative UAM_TMUX_BIN should be rejected") - } -} diff --git a/internal/vterm/vterm.go b/internal/vterm/vterm.go new file mode 100644 index 0000000..19ea23b --- /dev/null +++ b/internal/vterm/vterm.go @@ -0,0 +1,771 @@ +// Package vterm is a minimal in-process VT100/xterm screen emulator. It +// replaces tmux's pane rendering for the native session backend: the session +// host feeds raw PTY output into a Terminal, and Capture returns the rendered +// plain-text tail the way `tmux capture-pane -p -J` used to. It models only +// what peek/capture needs — a character grid, scrollback history, cursor +// motion, erase/insert/delete, scroll regions, and the alternate screen. +// Colors and attributes (SGR) are parsed and discarded: capture output is +// plain text by contract, exactly like the old capture-pane path. +package vterm + +import ( + "strconv" + "strings" + "unicode/utf8" + + "github.com/mattn/go-runewidth" +) + +// Terminal is a virtual terminal. It is not goroutine-safe; the session host +// serializes access behind its own lock. +type Terminal struct { + cols, rows int + + main *screen + alt *screen + // onAlt reports whether the alternate screen (DEC 1049/47/1047) is active. + // Full-screen agent TUIs run on the alt screen; line-oriented output and + // scrollback history accumulate on the main screen only. + onAlt bool + + // history holds lines scrolled off the top of the main screen, oldest + // first, capped at maxHistory. + history []bufLine + maxHistory int + + // Parser state. Escape sequences and UTF-8 runes can split across Write + // calls, so both the sequence buffer and a partial-rune buffer persist. + state parseState + seq []byte + partial []byte +} + +// bufLine is one captured line: its text and whether it is the soft-wrap +// continuation of the previous line (joined by Capture, like capture-pane -J). +type bufLine struct { + text string + wrapped bool +} + +type parseState int + +const ( + stGround parseState = iota + stEsc // after ESC + stCSI // after ESC [ + stOSC // after ESC ] — consumed until BEL or ST + stDCS // after ESC P / X / ^ / _ — consumed until ST + stCharset // after ESC ( ) * + — one designator byte follows +) + +// New returns a Terminal with the given grid size and scrollback capacity. +func New(cols, rows, maxHistory int) *Terminal { + if cols < 1 { + cols = 1 + } + if rows < 1 { + rows = 1 + } + if maxHistory < 0 { + maxHistory = 0 + } + return &Terminal{ + cols: cols, + rows: rows, + main: newScreen(cols, rows), + alt: newScreen(cols, rows), + maxHistory: maxHistory, + } +} + +func (t *Terminal) Size() (cols, rows int) { return t.cols, t.rows } + +func (t *Terminal) active() *screen { + if t.onAlt { + return t.alt + } + return t.main +} + +// Write feeds raw PTY output into the emulator. It never fails; implementing +// io.Writer keeps the host's reader loop a plain io pipeline. +func (t *Terminal) Write(p []byte) (int, error) { + data := p + if len(t.partial) > 0 { + data = append(t.partial, p...) //nolint:gocritic // intentional new slice + t.partial = nil + } + for len(data) > 0 { + r, size := utf8.DecodeRune(data) + if r == utf8.RuneError && size == 1 { + if !utf8.FullRune(data) && len(data) < utf8.UTFMax { + // Incomplete trailing sequence — keep it for the next Write. + t.partial = append(t.partial, data...) + break + } + // Genuinely invalid byte: drop it rather than corrupt the grid. + data = data[1:] + continue + } + t.step(r) + data = data[size:] + } + return len(p), nil +} + +func (t *Terminal) step(r rune) { + switch t.state { + case stGround: + t.stepGround(r) + case stEsc: + t.stepEsc(r) + case stCSI: + t.stepCSI(r) + case stOSC: + t.stepOSC(r) + case stDCS: + t.stepDCS(r) + case stCharset: + t.state = stGround // discard the designator byte + } +} + +func (t *Terminal) stepGround(r rune) { + s := t.active() + switch r { + case 0x1b: + t.state = stEsc + case '\r': + s.x = 0 + s.pendingWrap = false + case '\n', 0x0b, 0x0c: + t.lineFeed(false) + case '\b': + if s.x > 0 { + s.x-- + } + s.pendingWrap = false + case '\t': + s.x = min((s.x/8+1)*8, t.cols-1) + s.pendingWrap = false + case 0x07, 0x00, 0x0e, 0x0f: + // BEL and shift-in/out: ignored. + default: + if r >= 0x20 { + t.print(r) + } + } +} + +func (t *Terminal) stepEsc(r rune) { + t.state = stGround + s := t.active() + switch r { + case '[': + t.state = stCSI + t.seq = t.seq[:0] + case ']': + t.state = stOSC + t.seq = t.seq[:0] + case 'P', 'X', '^', '_': + t.state = stDCS + case '(', ')', '*', '+': + t.state = stCharset + case '7': + s.savedX, s.savedY = s.x, s.y + case '8': + s.x, s.y = min(s.savedX, t.cols-1), min(s.savedY, t.rows-1) + s.pendingWrap = false + case 'D': + t.lineFeed(false) + case 'E': + s.x = 0 + t.lineFeed(false) + case 'M': + t.reverseIndex() + case 'c': + t.reset() + case '=', '>': + // Keypad modes: ignored. + } +} + +func (t *Terminal) stepCSI(r rune) { + // Parameter / intermediate bytes accumulate; a final byte 0x40–0x7e + // dispatches. + if r >= 0x40 && r <= 0x7e { + t.state = stGround + t.dispatchCSI(string(t.seq), byte(r)) + return + } + if r >= 0x20 && r <= 0x3f && len(t.seq) < 64 { + t.seq = append(t.seq, byte(r)) + return + } + if r == 0x1b || r > 0x7e { + // Malformed sequence; bail to ground (re-handle ESC). + t.state = stGround + if r == 0x1b { + t.state = stEsc + } + } +} + +func (t *Terminal) stepOSC(r rune) { + if r == 0x07 { + t.state = stGround + return + } + if r == 0x1b { + // Likely ST (ESC \). Consume the backslash via stEsc's default arm. + t.state = stEsc + return + } + // Title and clipboard payloads are irrelevant to capture; discard. +} + +func (t *Terminal) stepDCS(r rune) { + if r == 0x1b { + t.state = stEsc + } +} + +func (t *Terminal) dispatchCSI(params string, final byte) { + private := strings.HasPrefix(params, "?") + params = strings.TrimLeft(params, "?<=>") + // Strip intermediate bytes (e.g. the space in "CSI Ps SP q"). + if i := strings.IndexFunc(params, func(r rune) bool { return r < '0' || r > ';' }); i >= 0 { + params = params[:i] + } + n := csiParams(params) + arg := func(i, def int) int { + if i < len(n) && n[i] > 0 { + return n[i] + } + return def + } + s := t.active() + switch final { + case 'A': + s.moveY(-arg(0, 1)) + case 'B', 'e': + s.moveY(arg(0, 1)) + case 'C', 'a': + s.moveX(arg(0, 1)) + case 'D': + s.moveX(-arg(0, 1)) + case 'E': + s.x = 0 + s.moveY(arg(0, 1)) + case 'F': + s.x = 0 + s.moveY(-arg(0, 1)) + case 'G', '`': + s.x = clamp(arg(0, 1)-1, 0, t.cols-1) + s.pendingWrap = false + case 'd': + s.y = clamp(arg(0, 1)-1, 0, t.rows-1) + s.pendingWrap = false + case 'H', 'f': + s.y = clamp(arg(0, 1)-1, 0, t.rows-1) + s.x = clamp(arg(1, 1)-1, 0, t.cols-1) + s.pendingWrap = false + case 'J': + t.eraseDisplay(arg(0, 0) /* default 0 even when params empty */) + case 'K': + s.eraseLine(argDefault(n, 0, 0), t.cols) + case 'L': + s.insertLines(arg(0, 1)) + case 'M': + s.deleteLines(arg(0, 1)) + case '@': + s.insertChars(arg(0, 1), t.cols) + case 'P': + s.deleteChars(arg(0, 1), t.cols) + case 'X': + s.eraseChars(arg(0, 1), t.cols) + case 'S': + for i := 0; i < arg(0, 1); i++ { + t.scrollUp() + } + case 'T': + for i := 0; i < arg(0, 1); i++ { + t.scrollDownRegion() + } + case 'r': + top := clamp(arg(0, 1)-1, 0, t.rows-1) + bot := clamp(arg(1, t.rows)-1, 0, t.rows-1) + if top < bot { + s.top, s.bottom = top, bot + } else { + s.top, s.bottom = 0, t.rows-1 + } + s.x, s.y = 0, s.top + s.pendingWrap = false + case 'h': + if private { + t.setPrivateMode(n, true) + } + case 'l': + if private { + t.setPrivateMode(n, false) + } + case 's': + s.savedX, s.savedY = s.x, s.y + case 'u': + s.x, s.y = min(s.savedX, t.cols-1), min(s.savedY, t.rows-1) + s.pendingWrap = false + case 'm', 'n', 'q', 't', 'g', 'c': + // SGR, reports, cursor style, window ops, tab clears: no grid effect. + } +} + +// argDefault is arg() but distinguishing "no parameter" from explicit 0 is +// unnecessary for our erase handling; it simply mirrors arg with a 0 default. +func argDefault(n []int, i, def int) int { + if i < len(n) { + return n[i] + } + return def +} + +func csiParams(s string) []int { + if s == "" { + return nil + } + parts := strings.Split(s, ";") + out := make([]int, 0, len(parts)) + for _, p := range parts { + v := 0 + for _, c := range p { + if c < '0' || c > '9' || v > 1<<20 { + break + } + v = v*10 + int(c-'0') + } + out = append(out, v) + } + return out +} + +func (t *Terminal) setPrivateMode(params []int, on bool) { + for _, p := range params { + switch p { + case 47, 1047, 1049: + t.switchAlt(on) + case 25, 2004, 1000, 1002, 1003, 1004, 1005, 1006, 7: + // Cursor visibility, bracketed paste, mouse reporting, autowrap: + // no effect on captured text. + } + } +} + +func (t *Terminal) switchAlt(on bool) { + if on == t.onAlt { + return + } + if on { + s := t.main + s.savedX, s.savedY = s.x, s.y + t.alt.clearAll() + t.alt.x, t.alt.y = 0, 0 + t.onAlt = true + return + } + t.onAlt = false + s := t.main + s.x, s.y = min(s.savedX, t.cols-1), min(s.savedY, t.rows-1) + s.pendingWrap = false +} + +func (t *Terminal) print(r rune) { + w := runewidth.RuneWidth(r) + if w <= 0 { + return // combining marks / zero-width: skip, capture is best-effort + } + s := t.active() + if s.pendingWrap { + s.x = 0 + t.lineFeed(true) + s.pendingWrap = false + } + if s.x+w > t.cols { + // A wide rune that does not fit hard-wraps early. + s.x = 0 + t.lineFeed(true) + } + s.cells[s.y][s.x] = r + if w == 2 && s.x+1 < t.cols { + s.cells[s.y][s.x+1] = 0 + } + s.x += w + if s.x >= t.cols { + s.x = t.cols - 1 + s.pendingWrap = true + } +} + +// lineFeed moves the cursor down one row, scrolling at the bottom of the +// scroll region. wrapped marks the entered row as a soft-wrap continuation. +func (t *Terminal) lineFeed(wrapped bool) { + s := t.active() + if s.y == s.bottom { + t.scrollUp() + s.wrapped[s.y] = wrapped + return + } + if s.y < t.rows-1 { + s.y++ + s.wrapped[s.y] = wrapped + } +} + +// scrollUp shifts the scroll region up one row. On the main screen with a +// full-height region the departing top row enters scrollback history. +func (t *Terminal) scrollUp() { + s := t.active() + if !t.onAlt && s.top == 0 && s.bottom == t.rows-1 && t.maxHistory > 0 { + t.pushHistory(bufLine{text: rowText(s.cells[0]), wrapped: s.wrapped[0]}) + } + top, bot := s.top, s.bottom + rec := s.cells[top] + copy(s.cells[top:bot], s.cells[top+1:bot+1]) + copy(s.wrapped[top:bot], s.wrapped[top+1:bot+1]) + s.cells[bot] = rec + clearRow(s.cells[bot]) + s.wrapped[bot] = false +} + +func (t *Terminal) scrollDownRegion() { + s := t.active() + top, bot := s.top, s.bottom + rec := s.cells[bot] + copy(s.cells[top+1:bot+1], s.cells[top:bot]) + copy(s.wrapped[top+1:bot+1], s.wrapped[top:bot]) + s.cells[top] = rec + clearRow(s.cells[top]) + s.wrapped[top] = false +} + +func (t *Terminal) reverseIndex() { + s := t.active() + if s.y == s.top { + t.scrollDownRegion() + return + } + if s.y > 0 { + s.y-- + } +} + +func (t *Terminal) pushHistory(l bufLine) { + t.history = append(t.history, l) + if len(t.history) > t.maxHistory { + // Trim in chunks so a busy session does not memmove on every line. + drop := len(t.history) - t.maxHistory + t.history = append(t.history[:0], t.history[drop:]...) + } +} + +func (t *Terminal) eraseDisplay(mode int) { + s := t.active() + switch mode { + case 0: + s.eraseLine(0, t.cols) + for y := s.y + 1; y < t.rows; y++ { + clearRow(s.cells[y]) + s.wrapped[y] = false + } + case 1: + s.eraseLine(1, t.cols) + for y := 0; y < s.y; y++ { + clearRow(s.cells[y]) + s.wrapped[y] = false + } + case 2, 3: + s.clearAll() + } +} + +func (t *Terminal) reset() { + t.onAlt = false + t.main = newScreen(t.cols, t.rows) + t.alt = newScreen(t.cols, t.rows) + t.state = stGround +} + +// Resize changes the grid size, preserving as much content as fits. Scroll +// regions reset to full height (matching xterm). Rows dropped from the top of +// the main screen move into history. +func (t *Terminal) Resize(cols, rows int) { + if cols < 1 { + cols = 1 + } + if rows < 1 { + rows = 1 + } + if cols == t.cols && rows == t.rows { + return + } + for _, s := range []*screen{t.main, t.alt} { + s.resize(cols, rows, t.cols, t.rows, s == t.main && t.maxHistory > 0, t) + } + t.cols, t.rows = cols, rows +} + +// Capture renders the last maxLines lines of the terminal as plain text: +// scrollback history plus the active screen, with soft-wrapped lines joined +// (the capture-pane -J contract) and trailing blank lines trimmed. The result +// ends with a newline when non-empty. +func (t *Terminal) Capture(maxLines int) string { + if maxLines <= 0 { + maxLines = 200 + } + s := t.active() + lines := make([]bufLine, 0, len(t.history)+t.rows) + if !t.onAlt || t.maxHistory > 0 { + lines = append(lines, t.history...) + } + last := t.rows - 1 + for ; last >= 0; last-- { + if rowText(s.cells[last]) != "" { + break + } + } + for y := 0; y <= last; y++ { + lines = append(lines, bufLine{text: rowText(s.cells[y]), wrapped: s.wrapped[y]}) + } + joined := make([]string, 0, len(lines)) + for _, l := range lines { + if l.wrapped && len(joined) > 0 { + joined[len(joined)-1] += l.text + continue + } + joined = append(joined, l.text) + } + if len(joined) > maxLines { + joined = joined[len(joined)-maxLines:] + } + if len(joined) == 0 { + return "" + } + return strings.Join(joined, "\n") + "\n" +} + +// Redraw returns an ANSI byte sequence that repaints the current screen on a +// fresh terminal: clear, draw every row, and park the cursor. The session host +// sends it to a newly attached client so the user sees the live screen +// immediately (a follow-up resize nudge makes full-screen TUIs repaint with +// their own colors). +func (t *Terminal) Redraw() []byte { + s := t.active() + var b strings.Builder + b.WriteString("\x1b[2J\x1b[H") + last := t.rows - 1 + for ; last >= 0; last-- { + if rowText(s.cells[last]) != "" { + break + } + } + for y := 0; y <= last; y++ { + if y > 0 { + b.WriteString("\r\n") + } + b.WriteString(rowText(s.cells[y])) + } + b.WriteString("\x1b[" + strconv.Itoa(s.y+1) + ";" + strconv.Itoa(s.x+1) + "H") + return []byte(b.String()) +} + +// screen is one character grid (main or alternate). +type screen struct { + cells [][]rune + wrapped []bool + x, y int + // pendingWrap defers the wrap after writing the last column (DECAWM + // semantics): the next printable wraps, but CR/cursor motion cancels it. + pendingWrap bool + top, bottom int + savedX, savedY int +} + +func newScreen(cols, rows int) *screen { + s := &screen{cells: make([][]rune, rows), wrapped: make([]bool, rows), bottom: rows - 1} + for i := range s.cells { + s.cells[i] = make([]rune, cols) + } + return s +} + +func (s *screen) clearAll() { + for y := range s.cells { + clearRow(s.cells[y]) + s.wrapped[y] = false + } +} + +func (s *screen) moveX(d int) { + s.x = clamp(s.x+d, 0, len(s.cells[0])-1) + s.pendingWrap = false +} + +func (s *screen) moveY(d int) { + s.y = clamp(s.y+d, 0, len(s.cells)-1) + s.pendingWrap = false +} + +func (s *screen) eraseLine(mode, cols int) { + row := s.cells[s.y] + switch mode { + case 0: + for x := s.x; x < cols; x++ { + row[x] = 0 + } + case 1: + for x := 0; x <= s.x && x < cols; x++ { + row[x] = 0 + } + case 2: + clearRow(row) + } +} + +func (s *screen) insertLines(n int) { + if s.y < s.top || s.y > s.bottom { + return + } + for i := 0; i < n; i++ { + rec := s.cells[s.bottom] + copy(s.cells[s.y+1:s.bottom+1], s.cells[s.y:s.bottom]) + copy(s.wrapped[s.y+1:s.bottom+1], s.wrapped[s.y:s.bottom]) + s.cells[s.y] = rec + clearRow(s.cells[s.y]) + s.wrapped[s.y] = false + } +} + +func (s *screen) deleteLines(n int) { + if s.y < s.top || s.y > s.bottom { + return + } + for i := 0; i < n; i++ { + rec := s.cells[s.y] + copy(s.cells[s.y:s.bottom], s.cells[s.y+1:s.bottom+1]) + copy(s.wrapped[s.y:s.bottom], s.wrapped[s.y+1:s.bottom+1]) + s.cells[s.bottom] = rec + clearRow(s.cells[s.bottom]) + s.wrapped[s.bottom] = false + } +} + +func (s *screen) insertChars(n, cols int) { + row := s.cells[s.y] + for i := 0; i < n; i++ { + copy(row[s.x+1:cols], row[s.x:cols-1]) + row[s.x] = 0 + } +} + +func (s *screen) deleteChars(n, cols int) { + row := s.cells[s.y] + for i := 0; i < n; i++ { + copy(row[s.x:cols-1], row[s.x+1:cols]) + row[cols-1] = 0 + } +} + +func (s *screen) eraseChars(n, cols int) { + row := s.cells[s.y] + for x := s.x; x < s.x+n && x < cols; x++ { + row[x] = 0 + } +} + +func (s *screen) resize(cols, rows, oldCols, oldRows int, pushTop bool, t *Terminal) { + // Shrinking rows drops from the top; the dropped main-screen rows are + // preserved as history so capture never loses them. + if rows < oldRows { + drop := oldRows - rows + // Keep the cursor visible: drop blank rows from the bottom first. + for drop > 0 && oldRows-1 > s.y && rowText(s.cells[oldRows-1]) == "" { + s.cells = s.cells[:oldRows-1] + s.wrapped = s.wrapped[:oldRows-1] + oldRows-- + drop-- + } + if drop > 0 { + if pushTop { + for i := 0; i < drop; i++ { + t.pushHistory(bufLine{text: rowText(s.cells[i]), wrapped: s.wrapped[i]}) + } + } + s.cells = s.cells[drop:] + s.wrapped = s.wrapped[drop:] + s.y = max(0, s.y-drop) + } + } + for len(s.cells) < rows { + s.cells = append(s.cells, make([]rune, cols)) + s.wrapped = append(s.wrapped, false) + } + for i := range s.cells { + row := s.cells[i] + if len(row) < cols { + grown := make([]rune, cols) + copy(grown, row) + s.cells[i] = grown + } else if len(row) > cols { + s.cells[i] = row[:cols] + } + } + s.top, s.bottom = 0, rows-1 + s.x = clamp(s.x, 0, cols-1) + s.y = clamp(s.y, 0, rows-1) + s.pendingWrap = false + s.savedX = clamp(s.savedX, 0, cols-1) + s.savedY = clamp(s.savedY, 0, rows-1) +} + +func clearRow(row []rune) { + for i := range row { + row[i] = 0 + } +} + +// rowText renders a grid row as a string: zero cells inside the line become +// spaces, trailing blanks are trimmed. +func rowText(row []rune) string { + last := len(row) - 1 + for ; last >= 0; last-- { + if row[last] != 0 && row[last] != ' ' { + break + } + } + if last < 0 { + return "" + } + var b strings.Builder + for x := 0; x <= last; x++ { + r := row[x] + if r == 0 { + // A zero cell is either an erased cell or a wide-rune + // continuation; the continuation contributes no extra column. + if x > 0 && runewidth.RuneWidth(row[x-1]) == 2 { + continue + } + r = ' ' + } + b.WriteRune(r) + } + return b.String() +} + +func clamp(v, lo, hi int) int { + if v < lo { + return lo + } + if v > hi { + return hi + } + return v +} diff --git a/internal/vterm/vterm_test.go b/internal/vterm/vterm_test.go new file mode 100644 index 0000000..24665fc --- /dev/null +++ b/internal/vterm/vterm_test.go @@ -0,0 +1,350 @@ +package vterm + +import ( + "strings" + "testing" +) + +func feed(t *testing.T, term *Terminal, s string) { + t.Helper() + if _, err := term.Write([]byte(s)); err != nil { + t.Fatalf("Write: %v", err) + } +} + +func TestPlainLinesCapture(t *testing.T) { + term := New(80, 24, 100) + feed(t, term, "hello\r\nworld\r\n") + if got, want := term.Capture(200), "hello\nworld\n"; got != want { + t.Fatalf("Capture = %q, want %q", got, want) + } +} + +func TestCaptureTailLimitsLines(t *testing.T) { + term := New(80, 4, 100) + feed(t, term, "a\r\nb\r\nc\r\nd\r\ne\r\n") + if got, want := term.Capture(2), "d\ne\n"; got != want { + t.Fatalf("Capture(2) = %q, want %q", got, want) + } +} + +func TestScrolledLinesEnterHistory(t *testing.T) { + term := New(80, 3, 100) + feed(t, term, "one\r\ntwo\r\nthree\r\nfour\r\nfive") + got := term.Capture(200) + want := "one\ntwo\nthree\nfour\nfive\n" + if got != want { + t.Fatalf("Capture = %q, want %q", got, want) + } +} + +func TestHistoryIsCapped(t *testing.T) { + term := New(80, 2, 3) + for i := 0; i < 20; i++ { + feed(t, term, "line\r\n") + } + lines := strings.Count(term.Capture(0), "\n") + // 3 history lines + at most 2 screen rows. + if lines > 5 { + t.Fatalf("history not capped: %d lines", lines) + } +} + +func TestCarriageReturnOverwrites(t *testing.T) { + term := New(80, 24, 0) + feed(t, term, "Progress 10%\rProgress 99%") + if got := term.Capture(200); got != "Progress 99%\n" { + t.Fatalf("Capture = %q", got) + } +} + +func TestSGRAndOSCAreStripped(t *testing.T) { + term := New(80, 24, 0) + feed(t, term, "\x1b]0;window title\x07\x1b[1;32mgreen\x1b[0m plain") + if got := term.Capture(200); got != "green plain\n" { + t.Fatalf("Capture = %q", got) + } +} + +func TestCursorAddressingAndEraseLine(t *testing.T) { + term := New(20, 5, 0) + feed(t, term, "aaaaa\r\nbbbbb\r\nccccc") + // Move to row 2 col 3, erase to end of line, write. + feed(t, term, "\x1b[2;3HXX\x1b[K") + got := term.Capture(200) + want := "aaaaa\nbbXX\nccccc\n" + if got != want { + t.Fatalf("Capture = %q, want %q", got, want) + } +} + +func TestEraseDisplayClears(t *testing.T) { + term := New(20, 5, 0) + feed(t, term, "junk junk junk") + feed(t, term, "\x1b[2J\x1b[Hfresh") + if got := term.Capture(200); got != "fresh\n" { + t.Fatalf("Capture = %q", got) + } +} + +func TestSoftWrapJoinsOnCapture(t *testing.T) { + term := New(10, 4, 10) + feed(t, term, strings.Repeat("x", 25)) + // 25 x's wrap onto three rows; capture joins them back into one line + // (the capture-pane -J contract). + if got := term.Capture(200); got != strings.Repeat("x", 25)+"\n" { + t.Fatalf("Capture = %q", got) + } +} + +func TestAlternateScreenSwitchAndRestore(t *testing.T) { + term := New(40, 5, 50) + feed(t, term, "main screen line\r\n") + feed(t, term, "\x1b[?1049h") // enter alt + feed(t, term, "TUI CONTENT") + if got := term.Capture(200); !strings.Contains(got, "TUI CONTENT") || strings.Contains(got, "main screen") { + t.Fatalf("alt capture = %q", got) + } + feed(t, term, "\x1b[?1049l") // leave alt + if got := term.Capture(200); !strings.Contains(got, "main screen line") || strings.Contains(got, "TUI CONTENT") { + t.Fatalf("main capture after alt = %q", got) + } +} + +func TestScrollRegionScrollsOnlyRegion(t *testing.T) { + term := New(20, 4, 10) + feed(t, term, "top\r\nA\r\nB\r\nbottom") + // Region rows 2-3; cursor to region bottom; LF scrolls only the region. + feed(t, term, "\x1b[2;3r\x1b[3;1H\nC") + got := term.Capture(200) + if !strings.Contains(got, "top") || !strings.Contains(got, "bottom") { + t.Fatalf("rows outside region must not scroll: %q", got) + } + if !strings.Contains(got, "B") || !strings.Contains(got, "C") || strings.Contains(got, "A\n") { + t.Fatalf("region should have scrolled A out: %q", got) + } +} + +func TestWideRunes(t *testing.T) { + term := New(10, 3, 0) + feed(t, term, "日本語") + if got := term.Capture(200); got != "日本語\n" { + t.Fatalf("Capture = %q", got) + } +} + +func TestUTF8SplitAcrossWrites(t *testing.T) { + term := New(10, 3, 0) + b := []byte("héllo") + for _, c := range b { + feed(t, term, string([]byte{c})) + } + if got := term.Capture(200); got != "héllo\n" { + t.Fatalf("Capture = %q", got) + } +} + +func TestEscapeSequenceSplitAcrossWrites(t *testing.T) { + term := New(20, 3, 0) + feed(t, term, "red:\x1b[3") + feed(t, term, "1mX\x1b[0m") + if got := term.Capture(200); got != "red:X\n" { + t.Fatalf("Capture = %q", got) + } +} + +func TestInsertDeleteLinesAndChars(t *testing.T) { + term := New(20, 4, 0) + feed(t, term, "one\r\ntwo\r\nthree") + feed(t, term, "\x1b[1;1H\x1b[1L") // insert a line at top + feed(t, term, "zero") + if got := term.Capture(200); got != "zero\none\ntwo\nthree\n" { + t.Fatalf("after IL: %q", got) + } + feed(t, term, "\x1b[1;1H\x1b[1M") // delete top line again + if got := term.Capture(200); got != "one\ntwo\nthree\n" { + t.Fatalf("after DL: %q", got) + } + feed(t, term, "\x1b[1;1H\x1b[2P") // delete "on" from "one" + if got := term.Capture(200); got != "e\ntwo\nthree\n" { + t.Fatalf("after DCH: %q", got) + } +} + +func TestResizePreservesContent(t *testing.T) { + term := New(40, 10, 50) + feed(t, term, "keep me\r\nand me") + term.Resize(20, 5) + if got := term.Capture(200); got != "keep me\nand me\n" { + t.Fatalf("after shrink: %q", got) + } + term.Resize(80, 30) + feed(t, term, " still works") + if got := term.Capture(200); got != "keep me\nand me still works\n" { + t.Fatalf("after grow: %q", got) + } +} + +func TestRowsDroppedByResizeEnterHistory(t *testing.T) { + term := New(20, 5, 50) + feed(t, term, "a\r\nb\r\nc\r\nd\r\ne") + term.Resize(20, 2) + got := term.Capture(200) + if got != "a\nb\nc\nd\ne\n" { + t.Fatalf("resize must not lose lines: %q", got) + } +} + +func TestRedrawPaintsScreenAndCursor(t *testing.T) { + term := New(20, 5, 0) + feed(t, term, "row1\r\nrow2") + out := string(term.Redraw()) + if !strings.HasPrefix(out, "\x1b[2J\x1b[H") { + t.Fatalf("redraw must clear first: %q", out) + } + if !strings.Contains(out, "row1\r\nrow2") { + t.Fatalf("redraw missing content: %q", out) + } + if !strings.HasSuffix(out, "\x1b[2;5H") { + t.Fatalf("redraw must park cursor after row2: %q", out) + } +} + +func TestCaptureDefaultsAndEmpty(t *testing.T) { + term := New(80, 24, 10) + if got := term.Capture(0); got != "" { + t.Fatalf("empty terminal capture = %q", got) + } +} + +func TestBackspaceAndTab(t *testing.T) { + term := New(20, 3, 0) + feed(t, term, "ab\bC\tD") + // "ab", BS over b, C, tab to col 8, D. + if got := term.Capture(200); got != "aC D\n" { + t.Fatalf("Capture = %q", got) + } +} + +func TestNewClampsDegenerateSizes(t *testing.T) { + term := New(0, -1, -5) + cols, rows := term.Size() + if cols != 1 || rows != 1 { + t.Fatalf("Size = %d,%d, want clamped 1,1", cols, rows) + } + feed(t, term, "x") + if got := term.Capture(10); got != "x\n" { + t.Fatalf("Capture = %q", got) + } +} + +func TestCursorSaveRestoreAndIndexEscapes(t *testing.T) { + term := New(20, 5, 0) + feed(t, term, "abc\x1b7XY\x1b8Z") // save at col 4, write XY, restore, Z overwrites X + if got := term.Capture(10); got != "abcZY\n" { + t.Fatalf("ESC 7/8: %q", got) + } + term = New(20, 5, 0) + feed(t, term, "top\x1bEnext") + if got := term.Capture(10); got != "top\nnext\n" { + t.Fatalf("ESC E: %q", got) + } + // ESC M at the top scrolls the region down (reverse index). + term = New(20, 3, 0) + feed(t, term, "one\r\ntwo\x1b[1;1H\x1bMzero") + if got := term.Capture(10); got != "zero\none\ntwo\n" { + t.Fatalf("ESC M: %q", got) + } + // CSI S / T scroll the screen up and down. + term = New(20, 3, 10) + feed(t, term, "a\r\nb\r\nc\x1b[1S\x1b[1T") + if got := term.Capture(10); !strings.Contains(got, "b\nc\n") { + t.Fatalf("CSI S/T: %q", got) + } +} + +func TestResetClearsEverything(t *testing.T) { + term := New(20, 3, 10) + feed(t, term, "junk\r\nmore\x1bcfresh") + if got := term.Capture(10); !strings.HasSuffix(got, "fresh\n") { + t.Fatalf("ESC c: %q", got) + } +} + +func TestRelativeCursorMoves(t *testing.T) { + term := New(20, 5, 0) + // Write, move up/left/down/right and overwrite deterministically. + feed(t, term, "aaaa\r\nbbbb\x1b[1A\x1b[2DX\x1b[1B\x1b[1CY") + got := term.Capture(10) + if !strings.Contains(got, "aX") || !strings.Contains(got, "Y") { + t.Fatalf("relative moves: %q", got) + } + // CSI E/F: next/previous line to column 1. + term = New(20, 5, 0) + feed(t, term, "one\x1b[1Etwo\x1b[1Fzero") + if got := term.Capture(10); got != "zero\ntwo\n" { + t.Fatalf("CSI E/F: %q", got) + } +} + +func TestInsertCharsAndEraseVariants(t *testing.T) { + term := New(20, 4, 0) + feed(t, term, "abcdef\x1b[1;3H\x1b[2@") // insert 2 blanks at col 3 + if got := term.Capture(10); got != "ab cdef\n" { + t.Fatalf("ICH: %q", got) + } + feed(t, term, "\x1b[1;3H\x1b[2X") // erase 2 chars in place + if got := term.Capture(10); got != "ab cdef\n" { + t.Fatalf("ECH: %q", got) + } + // EL mode 1: erase from start of line through cursor. + term = New(20, 4, 0) + feed(t, term, "abcdef\x1b[1;3H\x1b[1K") + if got := term.Capture(10); got != " def\n" { + t.Fatalf("EL1: %q", got) + } + // ED mode 1: erase from start of display through cursor. + term = New(20, 4, 0) + feed(t, term, "top\r\nmid\r\nbot\x1b[2;2H\x1b[1J") + if got := term.Capture(10); !strings.Contains(got, "bot") || strings.Contains(got, "top") { + t.Fatalf("ED1: %q", got) + } +} + +func TestLegacyAltScreenAndDCS(t *testing.T) { + term := New(20, 4, 10) + feed(t, term, "main\x1b[?47halt47\x1b[?47l") + if got := term.Capture(10); !strings.Contains(got, "main") || strings.Contains(got, "alt47") { + t.Fatalf("?47 alt screen: %q", got) + } + // DCS payloads are consumed without touching the grid; ESC \ terminates. + feed(t, term, "\x1bPsome dcs payload\x1b\\after") + if got := term.Capture(10); !strings.Contains(got, "after") || strings.Contains(got, "payload") { + t.Fatalf("DCS: %q", got) + } + // OSC terminated by ST (ESC \) instead of BEL. + feed(t, term, "\x1b]0;title\x1b\\!") + if got := term.Capture(10); !strings.Contains(got, "after!") { + t.Fatalf("OSC ST: %q", got) + } +} + +func TestMalformedCSIRecovers(t *testing.T) { + term := New(20, 3, 0) + // An oversized parameter string overflows the buffer and is abandoned; + // an ESC inside a CSI restarts sequence parsing. + feed(t, term, "\x1b["+strings.Repeat("1;", 40)+"mok") + feed(t, term, "\x1b[12\x1b[31mred") + if got := term.Capture(10); !strings.Contains(got, "ok") || !strings.Contains(got, "red") { + t.Fatalf("malformed CSI: %q", got) + } +} + +func TestScrollRegionReverseWrapAndKeypadIgnored(t *testing.T) { + term := New(20, 4, 0) + // Keypad mode escapes and charset designators are consumed silently. + feed(t, term, "\x1b=\x1b>\x1b(Bvisible") + if got := term.Capture(10); got != "visible\n" { + t.Fatalf("ignored escapes: %q", got) + } +} diff --git a/main_test.go b/main_test.go index 73d450b..e0f177e 100644 --- a/main_test.go +++ b/main_test.go @@ -11,10 +11,25 @@ import ( tea "github.com/charmbracelet/bubbletea" + "github.com/RandomCodeSpace/unified-agent-manager/internal/cli" + "github.com/RandomCodeSpace/unified-agent-manager/internal/session" "github.com/RandomCodeSpace/unified-agent-manager/internal/store" "github.com/RandomCodeSpace/unified-agent-manager/internal/version" ) +// TestMain doubles as the session host/attach entry point: the native backend +// spawns os.Executable() with the internal __host/__attach subcommands, and +// under `go test` that executable is this test binary. Routing those argv +// shapes into the real CLI makes the dispatch/peek/stop tests below exercise +// the actual session backend end to end. +func TestMain(m *testing.M) { + if len(os.Args) > 1 && (os.Args[1] == "__host" || os.Args[1] == "__attach") { + cli.Main() + os.Exit(0) + } + os.Exit(m.Run()) +} + func TestRunHelpAndList(t *testing.T) { dir := setupFakeCLIConfig(t, "cfg") if out := runStderr(t, []string{"help"}); !strings.Contains(out, "uam") { @@ -259,17 +274,18 @@ func TestUsageAndNewService(t *testing.T) { func setupFakeCLIEnv(t *testing.T) string { t.Helper() dir := t.TempDir() - writeFileMode(t, filepath.Join(dir, "claude"), "#!/bin/sh\nexit 0\n", 0o755) - tmuxPath := filepath.Join(dir, "tmux") - writeFileMode(t, tmuxPath, `#!/bin/sh -case "$*" in - *"list-sessions"*) exit 0 ;; - *"capture-pane"*) echo "pane" ;; -esac -exit 0 -`, 0o755) - t.Setenv("UAM_TMUX_BIN", tmuxPath) + // A long-lived fake agent: prints a recognizable line for peek asserts and + // then idles so the session stays alive for list/stop/attach paths. + writeFileMode(t, filepath.Join(dir, "claude"), "#!/bin/sh\necho pane\nexec sleep 60\n", 0o755) t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("UAM_CACHE_DIR", filepath.Join(dir, "cache")) + sessDir := filepath.Join(dir, "runtime") + t.Setenv("UAM_SESSION_DIR", sessDir) + t.Cleanup(func() { + c := session.NewClient() + c.Dir = sessDir + _ = c.KillAll(context.Background()) + }) return dir }