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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: ./...
2 changes: 1 addition & 1 deletion .github/workflows/sonar.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 != ''
Expand Down
78 changes: 69 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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.

Expand Down Expand Up @@ -87,9 +92,9 @@ uam ls [--json]
uam peek <id>
uam attach <name-or-id>
uam last
uam stop <id> # kill tmux session, keep record
uam rm <id> # kill tmux session and remove record
uam kill-all # stop the private tmux server and all sessions
uam stop <id> # kill the session, keep record
uam rm <id> # kill the session and remove record
uam kill-all # stop every managed session
uam version
```

Expand All @@ -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 <id>` — 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=<id>`).
- **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:
Expand All @@ -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-<uid>` 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-<uid>`). 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
Expand Down
9 changes: 5 additions & 4 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1,25 +1,26 @@
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 (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
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
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
47 changes: 30 additions & 17 deletions internal/adapter/adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -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 {
Expand All @@ -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
}
Expand Down
133 changes: 133 additions & 0 deletions internal/adapter/adaptertest/backend.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading