Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,12 @@ cmd/uam, main.go entrypoints (main.go = compat shim)
internal/cli argument routing, command dispatch
internal/app Service (business logic) + Bubble Tea Model
internal/adapter AgentAdapter interface, shared TmuxAgent,
Registry, pane state classification (detect.go)
Registry, liveness classification (detect.go)
internal/adapter/<provider> per-agent factories (claude, codex, copilot,
hermes, opencode) — each ~10 lines
hermes, omp, opencode) — each ~10 lines
internal/tmux all tmux shell-out logic
internal/store sessions.json: flock, atomic write, migration
internal/pr optional `gh pr view` status lookup
internal/refresh refresh ticker policy
internal/{log,version,execpath} support packages
```

Expand All @@ -51,6 +50,7 @@ Providers are capability-probed at startup; unavailable CLIs are hidden.
- **`#nosec` annotations** are deliberate and documented inline — preserve
the rationale comment when editing nearby code.
- **Store writes** must stay atomic (temp file + rename) and flock-guarded.
- **Command aliases** are launch-command overrides, not provider names. Preserve the canonical `agent`, persist aliases as `command_alias`, prefer PATH executables, and keep the interactive-shell fallback for profile aliases/functions.
- New providers: add an `internal/adapter/<name>` factory using
`adapter.NewTmuxAgent`, register it in `cli.NewService`.

Expand All @@ -63,4 +63,8 @@ Run before declaring work done:
go vet ./... && gofmt -l . && go test ./...
```

For command alias changes, cover `uam dispatch --alias`, `uam new`'s alias
prompt, TUI `@agent:alias`, `command_alias` persistence, resume reuse, PATH
executable preference, shell fallback, and unsafe alias rejection.

CI also runs `govulncheck`, `gosec`, SonarCloud, and CodeQL on `main`/PRs.
274 changes: 92 additions & 182 deletions PLAN.md

Large diffs are not rendered by default.

26 changes: 17 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,17 @@ Status: **complete MVP across PLAN.md Phases 0–12**.
- Agent adapters for:
- Claude Code: `claude --dangerously-skip-permissions`
- Codex: `codex --sandbox danger-full-access`
- GitHub Copilot CLI: `copilot --autopilot` or `gh copilot --autopilot`
- Hermes Agent: `hermes --tui --yolo`
- Oh My Pi: `omp`
- OpenCode: `opencode --auto-approve`
- GitHub Copilot CLI: `copilot --yolo`
- Hermes Agent: `hermes`
- Oh My Pi: `omp --auto-approve`
- OpenCode: `opencode`
- Persistent metadata at `${XDG_CONFIG_HOME:-~/.config}/uam/sessions.json`
- Atomic JSON writes, flock locking, schema migration backups, corrupt-file self-healing
- TUI grouping by session state: Needs Input, Working, Review, Failed, Completed
- TUI grouping by session record status: Active and Closed
- Peek/reply/attach/stop flows backed by tmux `capture-pane` and `send-keys`
- Pin, rename, group-by-dir toggle, and persisted manual reorder
- PR URL detection from pane output plus optional `gh pr view` status refresh
- Liveness-only pane classification (`Active`/`Failed`, `Alive`/`Exited`) plus PR URL detection from pane output and optional `gh pr view` status refresh
- Optional command aliases for provider launch commands, persisted as `command_alias` and reused on resume
- Shell commands for automation

## Build
Expand All @@ -54,8 +55,9 @@ unavailable providers are hidden from the TUI dispatch selector.
```sh
uam # open TUI
uam new # guided terminal dispatch flow
uam dispatch [--safe] <agent> "prompt"
uam dispatch [--safe] [--alias <command>] <agent> "prompt"
uam dispatch --cwd /path/to/repo claude "fix flaky tests"
uam dispatch --alias ghcp copilot "review this branch"
uam ls [--json]
uam peek <id>
uam attach <id>
Expand All @@ -73,6 +75,7 @@ uam rm <id> # kill tmux session and remove record
| `Enter` / `→` | Attach selected session |
| Type prompt + `Enter` | Dispatch to default agent |
| `@codex prompt` | Dispatch to a specific agent |
| `@copilot:ghcp prompt` | Dispatch Copilot using command alias `ghcp` |
| `Tab` | Cycle default agent |
| `Space` | Toggle peek panel |
| `Ctrl+T` | Pin selected session |
Expand All @@ -86,6 +89,12 @@ uam rm <id> # kill tmux session and remove record

## Development

Command aliases are launch-command overrides, not provider names. UAM first
prefers a real executable on `PATH` with that alias name; if none exists, it
falls back to an interactive shell so profile aliases/functions can resolve.
Alias-backed sessions persist the chosen command as `command_alias` in
`sessions.json` and use it again on resume.

```sh
go test ./...
make build
Expand All @@ -110,8 +119,7 @@ Core packages:

- `internal/store`: sessions JSON, locking, migration, backups
- `internal/tmux`: all tmux shell-out logic
- `internal/adapter`: shared adapter interfaces, tmux adapter, state detection
- `internal/adapter`: shared adapter interfaces, `TmuxAgent`, liveness-only pane classification, PR URL extraction
- `internal/adapter/{claude,codex,copilot,hermes,omp,opencode}`: provider registrations
- `internal/app`: service layer and Bubble Tea model
- `internal/pr`: optional GitHub PR status lookup
- `internal/refresh`: refresh ticker policy
53 changes: 28 additions & 25 deletions internal/adapter/adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,20 +45,21 @@ type PRRef struct {
}

type Session struct {
ID string
AgentType string
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
ID string
AgentType string
CommandAlias string
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
// 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
Expand All @@ -74,13 +75,14 @@ type PeekResult struct {
type AttachSpec struct{ Argv []string }

type ResumeRequest struct {
ID string
Name string
Prompt string
Cwd string
Mode string
TmuxSession string
CreatedAt time.Time
ID string
Name string
CommandAlias string
Prompt string
Cwd string
Mode string
TmuxSession string
CreatedAt time.Time
}

type ResumableAdapter interface {
Expand All @@ -96,10 +98,11 @@ type HasSessionAdapter interface {
}

type DispatchRequest struct {
Prompt string
Cwd string
Mode string
Name string
Prompt string
Cwd string
Mode string
Name string
CommandAlias string
}

type AgentAdapter interface {
Expand Down
134 changes: 104 additions & 30 deletions internal/adapter/tmux_adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"crypto/rand"
"encoding/hex"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
Expand Down Expand Up @@ -43,6 +44,7 @@
Tmux *tmux.Client
SessionArgs 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
Expand All @@ -57,7 +59,7 @@
if client == nil {
client = tmux.New("uam")
}
return &TmuxAgent{NameValue: name, DisplayNameValue: display, Candidates: candidates, YoloArgs: yoloArgs, Tmux: client, now: time.Now, lastPRScan: map[string]time.Time{}}
return &TmuxAgent{NameValue: name, DisplayNameValue: display, Candidates: candidates, YoloArgs: yoloArgs, Tmux: client, randomReader: rand.Reader, now: time.Now, lastPRScan: map[string]time.Time{}}
}

func (a *TmuxAgent) Name() string { return a.NameValue }
Expand Down Expand Up @@ -91,48 +93,98 @@
if !ok {
return nil, fmt.Errorf("%s unavailable", a.Name())
}
return commandWithModeArgs(cmd, mode, a.YoloArgs), nil
}

func (a *TmuxAgent) commandForRequest(ctx context.Context, req ResumeRequest, extra []string) ([]string, error) {
if req.CommandAlias == "" {
cmd, err := a.commandForMode(req.Mode)
if err != nil {
return nil, err
}
return append(cmd, extra...), nil
}
if err := validateCommandAlias(req.CommandAlias); err != nil {
return nil, err
}
cmd := commandWithModeArgs([]string{req.CommandAlias}, req.Mode, a.YoloArgs)
cmd = append(cmd, extra...)
if path, err := exec.LookPath(req.CommandAlias); err == nil {
cmd[0] = path
return cmd, nil
}
return shellAliasCommand(ctx, cmd)
}

func commandWithModeArgs(cmd []string, mode string, yoloArgs []string) []string {
cmd = append([]string{}, cmd...)
// Safe mode launches the bare command; no flag is the safe default for
// claude/codex. Only non-safe modes append the provider's full-access args.
if mode != "safe" {
cmd = append(cmd, a.YoloArgs...)
cmd = append(cmd, yoloArgs...)
}
return cmd
}

func validateCommandAlias(alias string) error {
if alias == "" || strings.HasPrefix(alias, "-") {
return fmt.Errorf("invalid command alias %q", alias)
}
for _, r := range alias {
if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '_' || r == '-' || r == '.' {
continue
}
return fmt.Errorf("invalid command alias %q", alias)
}
return cmd, nil
return nil
}

func shellAliasCommand(ctx context.Context, cmd []string) ([]string, error) {
shell := os.Getenv("SHELL")
if shell == "" {
shell = "/bin/sh"
}
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 -- shell is the user's configured absolute shell; alias name is validated before reaching this path.

Check failure

Code scanning / gosec

Command injection via taint analysis Error

Command injection via taint analysis
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
}

func (a *TmuxAgent) Dispatch(ctx context.Context, req DispatchRequest) (Session, error) {
id := newID()
return a.startSession(ctx, ResumeRequest{ID: id, Name: req.Name, Prompt: req.Prompt, Cwd: req.Cwd, Mode: req.Mode}, "dispatched")
id, err := newID(a.randomReader)
if err != nil {
return Session{}, fmt.Errorf("generate session id: %w", err)
}
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) {
if req.ID == "" {
req.ID = newID()
id, err := newID(a.randomReader)
if err != nil {
return Session{}, fmt.Errorf("generate session id: %w", err)
}
req.ID = id
}
return a.startSession(ctx, req, "resumed")
}

func (a *TmuxAgent) startSession(ctx context.Context, req ResumeRequest, activity string) (Session, error) {
cmd, err := a.commandForMode(req.Mode)
if err != nil {
return Session{}, err
}
extra := []string{}
if a.SessionArgs != nil {
cmd = append(cmd, a.SessionArgs(req, activity)...)
extra = append(extra, a.SessionArgs(req, activity)...)
}
cwd := req.Cwd
if cwd == "" {
cwd, err = os.Getwd()
if err != nil {
return Session{}, fmt.Errorf("resolve working directory: %w", err)
}
cmd, err := a.commandForRequest(ctx, req, extra)
if err != nil {
return Session{}, err
}
// 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).
if abs, absErr := filepath.Abs(cwd); absErr == nil {
cwd = abs
cwd, err := resolveSessionCwd(req.Cwd)
if err != nil {
return Session{}, err
}
tmuxName := req.TmuxSession
if tmuxName == "" {
Expand All @@ -142,7 +194,7 @@
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 on, swallow
// 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.
Expand Down Expand Up @@ -173,7 +225,26 @@
if created.IsZero() {
created = now
}
return Session{ID: req.ID, AgentType: a.Name(), DisplayName: displayName, Prompt: req.Prompt, Cwd: cwd, TmuxSession: tmuxName, State: Active, ProcAlive: Alive, CreatedAt: created, LastChange: now}, nil
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
}

func resolveSessionCwd(cwd string) (string, error) {
if cwd == "" {
var err error
cwd, err = os.Getwd()
if err != nil {
return "", fmt.Errorf("resolve working directory: %w", err)
}
}
// 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).
if abs, err := filepath.Abs(cwd); err == nil {
cwd = abs
}
return cwd, nil
}

func (a *TmuxAgent) List(ctx context.Context) ([]Session, error) {
Expand Down Expand Up @@ -269,15 +340,18 @@
return "=" + name
}

func newID() string {
func newID(random io.Reader) (string, error) {
if random == nil {
random = rand.Reader
}
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
panic(err)
if _, err := io.ReadFull(random, b); err != nil {
return "", err
}
b[6] = (b[6] & 0x0f) | 0x40
b[8] = (b[8] & 0x3f) | 0x80
h := hex.EncodeToString(b)
return h[0:8] + "-" + h[8:12] + "-" + h[12:16] + "-" + h[16:20] + "-" + h[20:32]
return h[0:8] + "-" + h[8:12] + "-" + h[12:16] + "-" + h[16:20] + "-" + h[20:32], nil
}

// displayNameFromDir derives a default session name from the working
Expand Down
Loading
Loading