This repository has been archived on 2026-06-13. You can view files and clone it, but cannot push or open issues or pull requests.
anvillm/internal/backend/tmux/tmux.go

459 lines
14 KiB
Go

// Package tmux provides tmux-based backend implementation for CLI tools
package tmux
import (
"anvillm/internal/backend"
"anvillm/internal/debug"
"anvillm/pkg/logging"
"anvillm/pkg/sandbox"
"context"
"crypto/rand"
"fmt"
"os/exec"
"strings"
"sync"
"time"
)
// TmuxSize defines terminal dimensions
type TmuxSize struct {
Rows uint16
Cols uint16
}
// StateInspector checks if the backend is busy by inspecting the process tree
type StateInspector interface {
// IsBusy checks if the backend has running child processes (tools, etc.)
// Takes the pane PID from tmux
IsBusy(panePID int) bool
}
// ModelResolver modifies a base command to include a model selection flag or argument.
// It receives the base command slice and the model name, and returns the modified command.
// For flag-based CLIs: append(cmd, "--model", model)
// For positional CLIs: replace the relevant positional argument
type ModelResolver func(cmd []string, model string) []string
// ClearHandler handles the /clear command for a specific backend.
// It receives the tmux target and should send the appropriate keys.
// Returns nil on success.
type ClearHandler func(target string) error
// ResumeHandler handles the resume command for a specific backend.
// It receives the tmux target and should send the appropriate keys.
// Returns nil on success.
type ResumeHandler func(target string) error
// CompactHandler handles the /compact command for a specific backend.
// It receives the tmux target and should send the appropriate keys.
// Returns nil on success.
type CompactHandler func(target string) error
// Config holds tmux backend configuration
type Config struct {
Name string
Command []string
Environment map[string]string
TmuxSize TmuxSize
Commands backend.CommandHandler
StateInspector StateInspector // Optional: for process tree inspection
NsSuffix string // Optional: namespace suffix (e.g., "0" for :0)
ModelResolver ModelResolver // Optional: modifies command to include model selection
ClearHandler ClearHandler // Optional: backend-specific /clear handling
ResumeHandler ResumeHandler // Optional: backend-specific resume handling
CompactHandler CompactHandler // Optional: backend-specific /compact handling
}
// Backend implements backend.Backend for tmux-based CLI tools
type Backend struct {
cfg Config
tmuxSession string // Persistent tmux session name (e.g., "anvillm-0")
}
// generateID creates a unique session ID using random bytes
func generateID() string {
b := make([]byte, 4) // 8 hex characters
rand.Read(b)
return fmt.Sprintf("%x", b)
}
// New creates a new tmux-based backend
func New(cfg Config) backend.Backend {
if cfg.TmuxSize.Rows == 0 {
cfg.TmuxSize.Rows = 40
}
if cfg.TmuxSize.Cols == 0 {
cfg.TmuxSize.Cols = 120
}
// Build tmux session name with namespace suffix if provided
// All backends share the same tmux session per namespace
sessionName := "anvillm"
if cfg.NsSuffix != "" {
sessionName = fmt.Sprintf("%s-%s", sessionName, cfg.NsSuffix)
}
return &Backend{
cfg: cfg,
tmuxSession: sessionName,
}
}
// ensureTmuxSession creates the persistent tmux session if it doesn't exist
func (b *Backend) ensureTmuxSession() error {
if sessionExists(b.tmuxSession) {
return nil
}
debug.Log("[backend %s] creating persistent tmux session: %s", b.cfg.Name, b.tmuxSession)
// Keep window 0 alive to hold the session open
// It will be killed when the program exits
return createSession(b.tmuxSession, b.cfg.TmuxSize.Rows, b.cfg.TmuxSize.Cols)
}
// Cleanup kills the persistent tmux session (call on program exit)
func (b *Backend) Cleanup() error {
if sessionExists(b.tmuxSession) {
debug.Log("[backend %s] cleaning up tmux session: %s", b.cfg.Name, b.tmuxSession)
return killSession(b.tmuxSession)
}
return nil
}
func (b *Backend) Name() string {
return b.cfg.Name
}
func (b *Backend) CreateSession(ctx context.Context, opts backend.SessionOptions) (backend.Session, error) {
id := generateID()
windowName := id // Use session ID as window name
debug.Log("[session %s] creating window in tmux session %s (sandbox=%s)", id, b.tmuxSession, opts.Sandbox)
// Build layered sandbox configuration
// Start with global.yaml as base
baseCfg, err := sandbox.Load()
if err != nil {
return nil, fmt.Errorf("failed to load global config: %w", err)
}
// Convert base config to layered format
baseLayer := sandbox.LayeredConfig{
Filesystem: baseCfg.Filesystem,
Network: baseCfg.Network,
Env: baseCfg.Env,
}
layers := []sandbox.LayeredConfig{baseLayer}
backendLayer, err := sandbox.LoadBackend(b.cfg.Name)
if err != nil {
return nil, fmt.Errorf("failed to load backend config %q: %w", b.cfg.Name, err)
}
layers = append(layers, backendLayer)
// Add sandbox layer if specified, otherwise use "default"
sbx := opts.Sandbox
if sbx == "" {
sbx = sandbox.DefaultSandbox
}
sbxLayer, err := sandbox.LoadSandbox(sbx)
if err != nil {
return nil, fmt.Errorf("failed to load sandbox %q: %w", sbx, err)
}
layers = append(layers, sbxLayer)
// Merge layers into final config
general := sandbox.GeneralConfig{
BestEffort: false,
LogLevel: "error",
}
advanced := sandbox.AdvancedConfig{
LDD: false,
AddExec: true,
}
sandboxCfg := sandbox.Merge(general, advanced, layers...)
// 1. Ensure persistent tmux session exists
if err := b.ensureTmuxSession(); err != nil {
return nil, fmt.Errorf("failed to ensure tmux session: %w", err)
}
// 2. Create window in tmux session
if err := createWindow(b.tmuxSession, windowName); err != nil {
return nil, fmt.Errorf("failed to create window: %w", err)
}
// Give shell time to initialize (login scripts, etc.)
time.Sleep(500 * time.Millisecond)
target := windowTarget(b.tmuxSession, windowName)
// 3. Set environment variables for this window
// Set AGENT_ID first
if err := setEnvironment(target, "AGENT_ID", id); err != nil {
killWindow(b.tmuxSession, windowName)
return nil, fmt.Errorf("failed to set AGENT_ID: %w", err)
}
// Set recovery metadata as window options (survives daemon restart)
setWindowOption(target, "ANVILLM_BACKEND", b.cfg.Name)
setWindowOption(target, "ANVILLM_CWD", opts.CWD)
if opts.Sandbox != "" {
setWindowOption(target, "ANVILLM_SANDBOX", opts.Sandbox)
}
if opts.Model != "" {
setWindowOption(target, "ANVILLM_MODEL", opts.Model)
}
for k, v := range b.cfg.Environment {
if err := setEnvironment(target, k, v); err != nil {
killWindow(b.tmuxSession, windowName)
return nil, fmt.Errorf("failed to set environment: %w", err)
}
if err := sendKeys(target, fmt.Sprintf("export %s=%s", k, v), "C-m"); err != nil {
killWindow(b.tmuxSession, windowName)
return nil, fmt.Errorf("failed to export environment: %w", err)
}
}
// 4. Wrap command with landrun (ALWAYS - sandboxing cannot be disabled)
command := b.cfg.Command
// Apply model resolver if a model is specified
if opts.Model != "" && b.cfg.ModelResolver != nil {
command = b.cfg.ModelResolver(command, opts.Model)
}
if !sandbox.IsAvailable() {
if sandboxCfg.General.BestEffort {
logging.Logger().Warn("landrun not available, running UNSANDBOXED (best-effort mode)")
logging.Logger().Warn("no filesystem and network restrictions will be enforced")
logging.Logger().Warn("install landrun: go install github.com/landlock-lsm/landrun@latest")
} else {
killWindow(b.tmuxSession, windowName)
logging.Logger().Error("landrun not available and sandboxing is required")
logging.Logger().Error("install landrun: go install github.com/landlock-lsm/landrun@latest")
logging.Logger().Error("or enable best_effort mode in ~/.config/anvillm/global.yaml (NOT RECOMMENDED)")
return nil, fmt.Errorf("landrun not available")
}
}
command = sandbox.WrapCommand(sandboxCfg, command, opts.CWD)
debug.Log("[session %s] sandboxed command: %v", id, command)
// 5. Build command string for tmux
cmdStr := ""
for i, arg := range command {
if i > 0 {
cmdStr += " "
}
// Quote arguments with spaces
if containsSpace(arg) {
cmdStr += fmt.Sprintf("\"%s\"", arg)
} else {
cmdStr += arg
}
}
// Change to working directory first
if err := sendKeys(target, fmt.Sprintf("cd \"%s\"", opts.CWD), "C-m"); err != nil {
killWindow(b.tmuxSession, windowName)
return nil, fmt.Errorf("failed to change directory: %w", err)
}
// Export AGENT_ID to shell environment
if err := sendKeys(target, fmt.Sprintf("export AGENT_ID=%s", id), "C-m"); err != nil {
killWindow(b.tmuxSession, windowName)
return nil, fmt.Errorf("failed to export AGENT_ID: %w", err)
}
// Send the command
if err := sendKeys(target, cmdStr, "C-m"); err != nil {
killWindow(b.tmuxSession, windowName)
return nil, fmt.Errorf("failed to start command: %w", err)
}
// 6. Get PID (find the backend process, not just the bash shell)
panePID, err := getPanePID(target)
if err != nil {
debug.Log("[session %s] warning: failed to get pane PID: %v", id, err)
panePID = 0
}
// Find the actual backend process (child of bash shell)
pid := 0
if panePID != 0 {
// For kiro-cli, the pane process IS kiro-cli (no bash in between)
// Check if pane is kiro-cli directly
cmd := exec.Command("ps", "-p", fmt.Sprintf("%d", panePID), "-o", "comm=")
if out, err := cmd.Output(); err == nil {
comm := strings.TrimSpace(string(out))
if comm == "kiro-cli" {
pid = panePID
debug.Log("[session %s] pane IS kiro-cli (PID: %d)", id, pid)
}
}
// Otherwise, try to find backend as child of bash
if pid == 0 {
for i := 0; i < 10; i++ {
time.Sleep(200 * time.Millisecond)
pid = FindBackendPID(panePID)
if pid != 0 {
debug.Log("[session %s] found backend PID %d (pane PID: %d)", id, pid, panePID)
break
}
}
}
if pid == 0 {
debug.Log("[session %s] warning: backend process not found for pane PID %d", id, panePID)
}
}
sess := &Session{
id: id,
backendName: b.cfg.Name,
tmuxSession: b.tmuxSession,
windowName: windowName,
cwd: opts.CWD,
sandbox: sbx,
model: opts.Model,
modelResolver: b.cfg.ModelResolver,
clearHandler: b.cfg.ClearHandler,
resumeHandler: b.cfg.ResumeHandler,
compactHandler: b.cfg.CompactHandler,
pid: pid,
state: "idle",
createdAt: time.Now(),
idleSince: time.Now(),
stopCh: make(chan struct{}),
commands: b.cfg.Commands,
stateInspector: b.cfg.StateInspector,
// Store for restart support
backendCommand: b.cfg.Command,
environment: b.cfg.Environment,
originalCommandStr: cmdStr,
}
sess.idleCond = sync.NewCond(&sess.mu)
debug.Log("[session %s] ready (tmux=%s:%s, pid=%d)", sess.ID(), b.tmuxSession, windowName, sess.pid)
return sess, nil
}
func containsSpace(s string) bool {
for _, r := range s {
if r == ' ' || r == '\t' {
return true
}
}
return false
}
// ListOrphanedWindows returns window names in the tmux session that look like session IDs
// (8 hex chars) but are not in the tracked set.
func (b *Backend) ListOrphanedWindows(tracked map[string]bool) []string {
if !sessionExists(b.tmuxSession) {
return nil
}
out, err := tmuxCmd("list-windows", "-t", b.tmuxSession, "-F", "#{window_name}")
if err != nil {
return nil
}
var orphans []string
for _, name := range strings.Split(strings.TrimSpace(out), "\n") {
if name == "" || name == "bash" {
continue
}
// Check if it looks like a session ID (8 hex chars)
if len(name) == 8 && isHex(name) && !tracked[name] {
orphans = append(orphans, name)
}
}
return orphans
}
// RecoverSession reconnects to a running tmux window.
// Returns a fully functional Session and the backend name.
func (b *Backend) RecoverSession(windowName string) (backend.Session, string, error) {
if !windowExists(b.tmuxSession, windowName) {
return nil, "", fmt.Errorf("window %s does not exist", windowName)
}
target := windowTarget(b.tmuxSession, windowName)
// Read recovery metadata from window options
envBackend := getWindowOption(target, "ANVILLM_BACKEND")
envCwd := getWindowOption(target, "ANVILLM_CWD")
envSandbox := getWindowOption(target, "ANVILLM_SANDBOX")
envModel := getWindowOption(target, "ANVILLM_MODEL")
envAlias := getWindowOption(target, "ANVILLM_ALIAS")
envRole := getWindowOption(target, "ANVILLM_ROLE")
// Use stored CWD if available, fallback to pane path
cwd, _ := tmuxCmd("display-message", "-t", target, "-p", "#{pane_current_path}")
cwd = strings.TrimSpace(cwd)
if envCwd != "" {
cwd = envCwd
}
// Get PID
panePID, _ := getPanePID(target)
pid := 0
if panePID != 0 {
cmd := exec.Command("ps", "-p", fmt.Sprintf("%d", panePID), "-o", "comm=")
if out, err := cmd.Output(); err == nil {
if strings.TrimSpace(string(out)) == "kiro-cli" {
pid = panePID
}
}
if pid == 0 {
pid = FindBackendPID(panePID)
}
}
// Use stored backend name, fallback to recovering backend
backendName := envBackend
if backendName == "" {
backendName = b.cfg.Name
}
sess := &Session{
id: windowName,
backendName: backendName,
tmuxSession: b.tmuxSession,
windowName: windowName,
cwd: cwd,
alias: envAlias,
sandbox: envSandbox,
model: envModel,
role: envRole,
pid: pid,
state: "idle",
createdAt: time.Now(),
stopCh: make(chan struct{}),
commands: b.cfg.Commands,
stateInspector: b.cfg.StateInspector,
backendCommand: b.cfg.Command,
environment: b.cfg.Environment,
}
sess.idleCond = sync.NewCond(&sess.mu)
// Detect actual state via process inspection
if sess.stateInspector != nil {
if panePID != 0 && sess.stateInspector.IsBusy(panePID) {
sess.state = "running"
}
}
debug.Log("[session %s] recovered (backend=%s, cwd=%s, pid=%d, state=%s)", windowName, backendName, cwd, pid, sess.state)
return sess, backendName, nil
}
func isHex(s string) bool {
for _, c := range s {
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) {
return false
}
}
return true
}