初始化仓库
This commit is contained in:
@@ -0,0 +1,438 @@
|
||||
package tmux
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/chenhg5/cc-connect/core"
|
||||
)
|
||||
|
||||
type tmuxSession struct {
|
||||
target string // e.g., "mywork:0"
|
||||
sessionID string
|
||||
workDir string
|
||||
promptPat *regexp.Regexp
|
||||
pollInt time.Duration
|
||||
stripInputBlock bool
|
||||
stripPatterns []*regexp.Regexp
|
||||
events chan core.Event
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
alive atomic.Bool
|
||||
closeOnce sync.Once
|
||||
|
||||
mu sync.Mutex
|
||||
pollCancel context.CancelFunc
|
||||
baselineCapture string // full captureScrollback output at the time of the last Send()
|
||||
}
|
||||
|
||||
func newTmuxSession(ctx context.Context, target, sessionID, promptPattern string, pollInt time.Duration, stripInputBlock bool, stripPatternStrs []string, workDir string) (*tmuxSession, error) {
|
||||
sessCtx, cancel := context.WithCancel(ctx)
|
||||
|
||||
var pat *regexp.Regexp
|
||||
if promptPattern != "" {
|
||||
var err error
|
||||
pat, err = regexp.Compile(promptPattern)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, fmt.Errorf("tmux: invalid prompt_pattern %q: %w", promptPattern, err)
|
||||
}
|
||||
}
|
||||
|
||||
var stripPats []*regexp.Regexp
|
||||
for _, s := range stripPatternStrs {
|
||||
re, err := regexp.Compile(s)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, fmt.Errorf("tmux: invalid strip_pattern %q: %w", s, err)
|
||||
}
|
||||
stripPats = append(stripPats, re)
|
||||
}
|
||||
|
||||
s := &tmuxSession{
|
||||
target: target,
|
||||
sessionID: sessionID,
|
||||
workDir: workDir,
|
||||
promptPat: pat,
|
||||
pollInt: pollInt,
|
||||
stripInputBlock: stripInputBlock,
|
||||
stripPatterns: stripPats,
|
||||
events: make(chan core.Event, 128),
|
||||
ctx: sessCtx,
|
||||
cancel: cancel,
|
||||
}
|
||||
s.alive.Store(true)
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *tmuxSession) Send(prompt string, _ []core.ImageAttachment, files []core.FileAttachment) error {
|
||||
if !s.alive.Load() {
|
||||
return fmt.Errorf("tmux: session closed")
|
||||
}
|
||||
|
||||
// Save attached files and append their paths to the prompt
|
||||
if len(files) > 0 {
|
||||
paths := core.SaveFilesToDisk(s.workDir, files)
|
||||
if len(paths) > 0 {
|
||||
prompt = prompt + "\n# files: " + strings.Join(paths, ", ")
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel any running poll from a previous Send
|
||||
s.mu.Lock()
|
||||
if s.pollCancel != nil {
|
||||
s.pollCancel()
|
||||
s.pollCancel = nil
|
||||
}
|
||||
|
||||
// Snapshot the full scrollback (history + visible pane) before sending.
|
||||
// extractResponse diffs against this to find exactly what the agent added,
|
||||
// regardless of whether the TUI rewrites lines in-place or scrolls them.
|
||||
baseline, _ := captureScrollback(s.target)
|
||||
visibleBase, _ := capturePane(s.target) // for poll stability comparison
|
||||
s.baselineCapture = baseline
|
||||
|
||||
pollCtx, pollCancel := context.WithCancel(s.ctx)
|
||||
s.pollCancel = pollCancel
|
||||
s.mu.Unlock()
|
||||
|
||||
if err := sendKeys(s.target, prompt); err != nil {
|
||||
pollCancel()
|
||||
return fmt.Errorf("tmux: send-keys: %w", err)
|
||||
}
|
||||
|
||||
go s.poll(pollCtx, visibleBase)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *tmuxSession) poll(ctx context.Context, baseline string) {
|
||||
ticker := time.NewTicker(s.pollInt)
|
||||
defer ticker.Stop()
|
||||
|
||||
prev := baseline
|
||||
stable := 0
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
current, err := capturePane(s.target)
|
||||
if err != nil {
|
||||
slog.Warn("tmux: capture-pane error", "target", s.target, "err", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if current == prev {
|
||||
stable++
|
||||
} else {
|
||||
stable = 0
|
||||
prev = current
|
||||
}
|
||||
|
||||
// Done: pane stable AND changed from baseline.
|
||||
// fast path — prompt pattern matched; slow path — 5 s idle fallback.
|
||||
if stable >= 2 && current != baseline {
|
||||
trimmed := strings.TrimRight(current, " \t\n")
|
||||
promptOK := s.promptPat == nil || s.promptPat.MatchString(trimmed)
|
||||
idleN := max(10, 5000/int(s.pollInt.Milliseconds()))
|
||||
if promptOK || stable >= idleN {
|
||||
// Guard against the race where Send() cancelled this poll
|
||||
// just as we were about to emit — avoids duplicate responses.
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
if !promptOK {
|
||||
lastLine := trimmed
|
||||
if nl := strings.LastIndex(trimmed, "\n"); nl >= 0 {
|
||||
lastLine = trimmed[nl+1:]
|
||||
}
|
||||
slog.Info("tmux: idle-done (prompt pattern did not match)", "target", s.target, "last_line", lastLine)
|
||||
}
|
||||
response := s.extractResponse()
|
||||
s.safeSend(core.Event{Type: core.EventResult, Content: response, Done: true})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *tmuxSession) safeSend(ev core.Event) {
|
||||
defer func() { _ = recover() }() // channel may be closed on session teardown
|
||||
select {
|
||||
case s.events <- ev:
|
||||
case <-s.ctx.Done():
|
||||
}
|
||||
}
|
||||
|
||||
func (s *tmuxSession) RespondPermission(_ string, _ core.PermissionResult) error {
|
||||
return fmt.Errorf("tmux: permission requests are not supported")
|
||||
}
|
||||
|
||||
func (s *tmuxSession) Events() <-chan core.Event { return s.events }
|
||||
|
||||
func (s *tmuxSession) CurrentSessionID() string { return s.sessionID }
|
||||
|
||||
func (s *tmuxSession) Alive() bool { return s.alive.Load() }
|
||||
|
||||
func (s *tmuxSession) Close() error {
|
||||
s.closeOnce.Do(func() {
|
||||
s.alive.Store(false)
|
||||
s.mu.Lock()
|
||||
if s.pollCancel != nil {
|
||||
s.pollCancel()
|
||||
s.pollCancel = nil
|
||||
}
|
||||
s.mu.Unlock()
|
||||
s.cancel()
|
||||
close(s.events)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── tmux helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
func tmuxSessionExists(name string) bool {
|
||||
return exec.Command("tmux", "has-session", "-t", name).Run() == nil
|
||||
}
|
||||
|
||||
// tmuxWindowExists checks whether a window or pane target (e.g. "sess:win") exists.
|
||||
func tmuxWindowExists(target string) bool {
|
||||
return exec.Command("tmux", "has-session", "-t", target).Run() == nil
|
||||
}
|
||||
|
||||
// createTmuxSession creates a new detached tmux session with the given window name.
|
||||
func createTmuxSession(name, windowName, workDir, shell string) error {
|
||||
args := []string{"new-session", "-d", "-s", name, "-n", windowName}
|
||||
if workDir != "" && workDir != "." {
|
||||
args = append(args, "-c", workDir)
|
||||
}
|
||||
if shell != "" {
|
||||
args = append(args, shell)
|
||||
}
|
||||
out, err := exec.Command("tmux", args...).CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
// Enable focus events so Claude Code doesn't warn about them being off.
|
||||
_ = exec.Command("tmux", "set-option", "-t", name, "-g", "focus-events", "on").Run()
|
||||
return nil
|
||||
}
|
||||
|
||||
// createTmuxWindow adds a new window to an existing session.
|
||||
// Using "session:" (trailing colon) tells tmux to pick the next free index,
|
||||
// avoiding index collisions when multiple windows are created concurrently.
|
||||
func createTmuxWindow(session, windowName, workDir string) error {
|
||||
args := []string{"new-window", "-d", "-t", session + ":", "-n", windowName}
|
||||
if workDir != "" && workDir != "." {
|
||||
args = append(args, "-c", workDir)
|
||||
}
|
||||
out, err := exec.Command("tmux", args...).CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func capturePane(target string) (string, error) {
|
||||
out, err := exec.Command("tmux", "capture-pane", "-t", target, "-p").Output()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return normalizeCapture(string(out)), nil
|
||||
}
|
||||
|
||||
func sendKeys(target, keys string) error {
|
||||
// -l (literal) prevents tmux from interpreting key names (C-c, Enter, Up, …)
|
||||
// embedded in the user's text. Enter is sent as a separate keystroke afterwards.
|
||||
out, err := exec.Command("tmux", "send-keys", "-t", target, "-l", keys).CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
out, err = exec.Command("tmux", "send-keys", "-t", target, "Enter").CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractResponse diffs the current full scrollback against the snapshot taken
|
||||
// in Send() and returns only what the agent added.
|
||||
//
|
||||
// Using extractNew on full captures (history + visible pane) handles both TUI
|
||||
// rendering modes correctly:
|
||||
//
|
||||
// - Append mode (agent scrolls content up): the baseline is a prefix of the
|
||||
// new capture, so the fast-path HasPrefix strips it and returns only the
|
||||
// new lines — no old visible-pane content leaks in.
|
||||
//
|
||||
// - In-place rewrite mode (agent overwrites pane rows then scrolls): the
|
||||
// first divergence is at line N (the first pane row, now containing the
|
||||
// first response line), so extractNew returns everything from that point —
|
||||
// no lines are skipped.
|
||||
func (s *tmuxSession) extractResponse() string {
|
||||
current, err := captureScrollback(s.target)
|
||||
if err != nil {
|
||||
slog.Warn("tmux: captureScrollback failed", "err", err)
|
||||
pane, _ := capturePane(s.target)
|
||||
return s.cleanTUIContent(pane)
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
baseline := s.baselineCapture
|
||||
s.mu.Unlock()
|
||||
|
||||
response := s.cleanTUIContent(extractNew(baseline, current))
|
||||
if response != "" {
|
||||
response = "```\n" + response + "\n```"
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
// captureScrollback captures the full scrollback history plus the visible pane.
|
||||
// Using "-S -" (start of history) instead of a fixed line count avoids the bug
|
||||
// where a long response pushes the capture window past the response start,
|
||||
// causing the first N lines of the response to be silently dropped.
|
||||
func captureScrollback(target string) (string, error) {
|
||||
out, err := exec.Command("tmux", "capture-pane", "-t", target, "-p", "-S", "-").Output()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return normalizeCapture(string(out)), nil
|
||||
}
|
||||
|
||||
// shellQuote wraps a path in single quotes and escapes any embedded single quotes.
|
||||
func shellQuote(s string) string {
|
||||
return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
|
||||
}
|
||||
|
||||
// cleanTUIContent removes Claude Code TUI frame lines from captured output:
|
||||
// - horizontal separator lines made of ─ (U+2500)
|
||||
// - bare prompt lines (❯, >, $, #, %)
|
||||
//
|
||||
// tuiInputBlockRe matches Claude Code's 3-line input area:
|
||||
//
|
||||
// ──────────────── (U+2500 separator line)
|
||||
// ❯ … (U+276F prompt, any trailing chars)
|
||||
// ────────────────
|
||||
//
|
||||
// Uses explicit Unicode codepoints and [^\n]* to be immune to invisible
|
||||
// trailing characters on the prompt line.
|
||||
var tuiInputBlockRe = regexp.MustCompile("(?m)^─+\n❯[^\n]*\n─+")
|
||||
|
||||
func (s *tmuxSession) cleanTUIContent(text string) string {
|
||||
if s.stripInputBlock {
|
||||
text = tuiInputBlockRe.ReplaceAllString(text, "")
|
||||
}
|
||||
if len(s.stripPatterns) == 0 {
|
||||
return strings.TrimRight(text, "\n")
|
||||
}
|
||||
lines := strings.Split(text, "\n")
|
||||
out := lines[:0]
|
||||
for _, line := range lines {
|
||||
drop := false
|
||||
for _, re := range s.stripPatterns {
|
||||
if re.MatchString(line) {
|
||||
drop = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !drop {
|
||||
out = append(out, line)
|
||||
}
|
||||
}
|
||||
return strings.TrimRight(strings.Join(out, "\n"), "\n")
|
||||
}
|
||||
|
||||
// normalizeCapture trims trailing whitespace per line and strips ANSI codes.
|
||||
func normalizeCapture(raw string) string {
|
||||
raw = ansiRe.ReplaceAllString(raw, "")
|
||||
lines := strings.Split(raw, "\n")
|
||||
for i, line := range lines {
|
||||
lines[i] = strings.TrimRight(line, " \t\r")
|
||||
}
|
||||
return strings.TrimRight(strings.Join(lines, "\n"), "\n")
|
||||
}
|
||||
|
||||
// ansiRe matches common ANSI/VT escape sequences.
|
||||
// OSC must come first so "\x1b]" is consumed fully (not as a generic two-char sequence).
|
||||
var ansiRe = regexp.MustCompile(
|
||||
`\x1b\][^\x07\x1b]*\x07` + // OSC: ESC ] ... BEL
|
||||
`|\x1b\[[0-9;]*[a-zA-Z]` + // CSI: ESC [ params letter
|
||||
`|\x1b.`, // Other two-char escape sequences
|
||||
)
|
||||
|
||||
// extractNew returns the response text that appeared in current after the baseline.
|
||||
// It handles three cases:
|
||||
// 1. Linear shell output — current is baseline + new lines (HasPrefix fast path).
|
||||
// 2. TUI redraws (e.g. Claude Code) — terminal overwrites lines in place; find the
|
||||
// longest common line prefix shared by both snapshots, then return the new lines
|
||||
// that follow it in current, stripping the repeated trailing prompt lines.
|
||||
// 3. Terminal scrolled — baseline has partially scrolled off; use a shrinking anchor.
|
||||
func extractNew(baseline, current string) string {
|
||||
if current == baseline {
|
||||
return ""
|
||||
}
|
||||
if baseline == "" {
|
||||
return current
|
||||
}
|
||||
|
||||
// Fast path: linear output, content only grew.
|
||||
if strings.HasPrefix(current, baseline) {
|
||||
return strings.TrimLeft(current[len(baseline):], "\n")
|
||||
}
|
||||
|
||||
baseLines := strings.Split(baseline, "\n")
|
||||
curLines := strings.Split(current, "\n")
|
||||
|
||||
// TUI path: find how many leading lines the two snapshots share (the static
|
||||
// frame/header), then return the new lines that follow in current.
|
||||
commonLen := 0
|
||||
for i := 0; i < len(baseLines) && i < len(curLines); i++ {
|
||||
if baseLines[i] != curLines[i] {
|
||||
break
|
||||
}
|
||||
commonLen = i + 1
|
||||
}
|
||||
if commonLen > 0 && commonLen < len(curLines) {
|
||||
newLines := curLines[commonLen:]
|
||||
// Strip trailing lines that duplicate the baseline's suffix (e.g. the prompt ">").
|
||||
bl := baseLines
|
||||
for len(newLines) > 0 && len(bl) > 0 && newLines[len(newLines)-1] == bl[len(bl)-1] {
|
||||
newLines = newLines[:len(newLines)-1]
|
||||
bl = bl[:len(bl)-1]
|
||||
}
|
||||
result := strings.TrimRight(strings.Join(newLines, "\n"), "\n")
|
||||
if result != "" {
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
// Scroll path: baseline has partially scrolled off the top; try progressively
|
||||
// shorter anchors from the end of baseline to find where new content begins.
|
||||
maxAnchor := 5
|
||||
if len(baseLines) < maxAnchor {
|
||||
maxAnchor = len(baseLines)
|
||||
}
|
||||
for n := maxAnchor; n >= 1; n-- {
|
||||
anchor := strings.Join(baseLines[len(baseLines)-n:], "\n")
|
||||
if idx := strings.Index(current, anchor); idx >= 0 {
|
||||
rest := strings.TrimLeft(current[idx+len(anchor):], "\n")
|
||||
if rest != "" {
|
||||
return rest
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return current
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
package tmux
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"log/slog"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/chenhg5/cc-connect/core"
|
||||
)
|
||||
|
||||
func init() {
|
||||
core.RegisterAgent("tmux", New)
|
||||
}
|
||||
|
||||
// Agent drives a persistent tmux pane as an interactive shell agent.
|
||||
// User messages are sent as keystrokes; output is captured by polling capture-pane.
|
||||
type Agent struct {
|
||||
sessionName string
|
||||
pane string
|
||||
workDir string
|
||||
autoCreate bool
|
||||
shell string
|
||||
initCmd string // command to run once after a new session is created (e.g. "claude")
|
||||
startupWaitMs int // milliseconds to wait after init_command before accepting messages
|
||||
promptPat string
|
||||
pollMs int
|
||||
stripInputBlock bool // strip the ───/❯/─── input area block from output
|
||||
stripPatterns []string // per-line regex patterns to strip from output
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func New(opts map[string]any) (core.Agent, error) {
|
||||
sessionName, _ := opts["session"].(string)
|
||||
if sessionName == "" {
|
||||
return nil, fmt.Errorf("tmux: 'session' option is required (name of the tmux session to attach)")
|
||||
}
|
||||
|
||||
pane, _ := opts["pane"].(string)
|
||||
if pane == "" {
|
||||
pane = "0"
|
||||
}
|
||||
|
||||
workDir, _ := opts["work_dir"].(string)
|
||||
if workDir == "" {
|
||||
workDir = "."
|
||||
}
|
||||
|
||||
autoCreate := true
|
||||
if v, ok := opts["auto_create"].(bool); ok {
|
||||
autoCreate = v
|
||||
}
|
||||
|
||||
shell, _ := opts["shell"].(string)
|
||||
initCmd, _ := opts["init_command"].(string)
|
||||
|
||||
startupWaitMs := 0
|
||||
switch v := opts["startup_wait_ms"].(type) {
|
||||
case int64:
|
||||
startupWaitMs = int(v)
|
||||
case int:
|
||||
startupWaitMs = v
|
||||
case float64:
|
||||
startupWaitMs = int(v)
|
||||
}
|
||||
if startupWaitMs == 0 && initCmd != "" {
|
||||
startupWaitMs = 2000 // default: give the init process 2s to start
|
||||
}
|
||||
|
||||
promptPat, _ := opts["prompt_pattern"].(string)
|
||||
if promptPat == "" {
|
||||
// Matches common shell prompts and Claude Code's ❯ prompt.
|
||||
promptPat = `[❯\$#>%]\s*$`
|
||||
}
|
||||
|
||||
pollMs := 200
|
||||
switch v := opts["poll_interval_ms"].(type) {
|
||||
case int64:
|
||||
if v > 0 {
|
||||
pollMs = int(v)
|
||||
}
|
||||
case int:
|
||||
if v > 0 {
|
||||
pollMs = v
|
||||
}
|
||||
case float64:
|
||||
if v > 0 {
|
||||
pollMs = int(v)
|
||||
}
|
||||
}
|
||||
|
||||
stripInputBlock := true
|
||||
if v, ok := opts["strip_input_block"].(bool); ok {
|
||||
stripInputBlock = v
|
||||
}
|
||||
|
||||
var stripPatterns []string
|
||||
switch v := opts["strip_patterns"].(type) {
|
||||
case []string:
|
||||
stripPatterns = v
|
||||
case []any:
|
||||
for _, item := range v {
|
||||
if s, ok := item.(string); ok {
|
||||
stripPatterns = append(stripPatterns, s)
|
||||
}
|
||||
}
|
||||
case nil:
|
||||
// default: strip Claude Code's mode status line
|
||||
stripPatterns = []string{`⏵⏵.*\(shift\+tab to cycle\)`}
|
||||
}
|
||||
|
||||
if _, err := exec.LookPath("tmux"); err != nil {
|
||||
return nil, fmt.Errorf("tmux: 'tmux' not found in PATH")
|
||||
}
|
||||
|
||||
return &Agent{
|
||||
sessionName: sessionName,
|
||||
pane: pane,
|
||||
workDir: workDir,
|
||||
autoCreate: autoCreate,
|
||||
shell: shell,
|
||||
initCmd: initCmd,
|
||||
startupWaitMs: startupWaitMs,
|
||||
promptPat: promptPat,
|
||||
pollMs: pollMs,
|
||||
stripInputBlock: stripInputBlock,
|
||||
stripPatterns: stripPatterns,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *Agent) Name() string { return "tmux" }
|
||||
|
||||
func (a *Agent) StartSession(ctx context.Context, sessionID string) (core.AgentSession, error) {
|
||||
a.mu.RLock()
|
||||
sessionName := a.sessionName
|
||||
pane := a.pane
|
||||
workDir := a.workDir
|
||||
autoCreate := a.autoCreate
|
||||
shell := a.shell
|
||||
initCmd := a.initCmd
|
||||
startupWaitMs := a.startupWaitMs
|
||||
promptPat := a.promptPat
|
||||
pollMs := a.pollMs
|
||||
stripInputBlock := a.stripInputBlock
|
||||
stripPatterns := a.stripPatterns
|
||||
a.mu.RUnlock()
|
||||
|
||||
// When workDir is a real path (set via SetWorkDir in multi-workspace mode, or
|
||||
// configured directly), use a dedicated tmux window named after the directory so
|
||||
// each workspace gets its own Claude Code instance. Otherwise fall back to the
|
||||
// legacy session:pane target.
|
||||
target, windowName := a.resolveTarget(sessionName, pane, workDir)
|
||||
|
||||
sessionExists := tmuxSessionExists(sessionName)
|
||||
windowExists := sessionExists && tmuxWindowExists(target)
|
||||
|
||||
if !sessionExists {
|
||||
if !autoCreate {
|
||||
return nil, fmt.Errorf("tmux: session %q does not exist and auto_create is disabled", sessionName)
|
||||
}
|
||||
if err := createTmuxSession(sessionName, windowName, workDir, shell); err != nil {
|
||||
return nil, fmt.Errorf("tmux: create session %q: %w", sessionName, err)
|
||||
}
|
||||
slog.Info("tmux: created session", "session", sessionName, "window", windowName, "work_dir", workDir)
|
||||
} else if !windowExists && windowName != pane {
|
||||
// Session exists but this workspace window does not yet — create it.
|
||||
if err := createTmuxWindow(sessionName, windowName, workDir); err != nil {
|
||||
return nil, fmt.Errorf("tmux: create window %q in session %q: %w", windowName, sessionName, err)
|
||||
}
|
||||
slog.Info("tmux: created window", "session", sessionName, "window", windowName, "work_dir", workDir)
|
||||
}
|
||||
|
||||
newPane := !sessionExists || (!windowExists && windowName != pane)
|
||||
if newPane {
|
||||
// Always cd to the workspace directory so the shell (and any init_command)
|
||||
// starts in the right place, regardless of tmux's -c flag behaviour.
|
||||
if workDir != "" && workDir != "." {
|
||||
if err := sendKeys(target, "cd "+shellQuote(workDir)); err != nil {
|
||||
slog.Warn("tmux: cd failed", "work_dir", workDir, "err", err)
|
||||
}
|
||||
}
|
||||
if initCmd != "" {
|
||||
if err := sendKeys(target, initCmd); err != nil {
|
||||
slog.Warn("tmux: init_command failed", "cmd", initCmd, "err", err)
|
||||
} else {
|
||||
slog.Info("tmux: init_command sent", "cmd", initCmd, "target", target)
|
||||
}
|
||||
if startupWaitMs > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(time.Duration(startupWaitMs) * time.Millisecond):
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return newTmuxSession(ctx, target, sessionID, promptPat, time.Duration(pollMs)*time.Millisecond, stripInputBlock, stripPatterns, workDir)
|
||||
}
|
||||
|
||||
// resolveTarget returns the tmux target string and the window name for the given workDir.
|
||||
// When workDir is a real path the window is named with a hash suffix derived from the
|
||||
// full path so that two workDirs sharing the same basename (e.g. /a/app and /b/app)
|
||||
// never collide into the same tmux window. When workDir is "." or empty the legacy
|
||||
// session:pane target is returned and windowName == pane.
|
||||
func (a *Agent) resolveTarget(sessionName, pane, workDir string) (target, windowName string) {
|
||||
if workDir != "" && workDir != "." {
|
||||
windowName = uniqueWindowName(workDir)
|
||||
return sessionName + ":" + windowName, windowName
|
||||
}
|
||||
return sessionName + ":" + pane, pane
|
||||
}
|
||||
|
||||
// uniqueWindowName builds a tmux window name that is unique per workDir path.
|
||||
// It appends a 4-hex-char FNV hash of the full path so that directories with
|
||||
// the same basename do not collide (e.g. /repo/a/app → "app-1a2b",
|
||||
// /repo/b/app → "app-3c4d").
|
||||
func uniqueWindowName(workDir string) string {
|
||||
base := sanitizeWindowName(filepath.Base(workDir))
|
||||
h := fnv.New32a()
|
||||
_, _ = h.Write([]byte(workDir))
|
||||
return fmt.Sprintf("%s-%04x", base, h.Sum32()&0xffff)
|
||||
}
|
||||
|
||||
// sanitizeWindowName makes a string safe to use as a tmux window name.
|
||||
func sanitizeWindowName(name string) string {
|
||||
name = strings.NewReplacer(":", "-", ".", "-", " ", "-").Replace(name)
|
||||
if name == "" {
|
||||
return "default"
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func (a *Agent) ListSessions(_ context.Context) ([]core.AgentSessionInfo, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (a *Agent) Stop() error { return nil }
|
||||
|
||||
// WorkspaceAgentOptions implements core.WorkspaceAgentOptionSnapshotter so the
|
||||
// engine can seed per-workspace agent instances with tmux-specific options
|
||||
// (session name, strip patterns, etc.) in multi-workspace mode.
|
||||
// work_dir is intentionally omitted; the engine sets it.
|
||||
func (a *Agent) WorkspaceAgentOptions() map[string]any {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
return map[string]any{
|
||||
"session": a.sessionName,
|
||||
"pane": a.pane,
|
||||
"auto_create": a.autoCreate,
|
||||
"shell": a.shell,
|
||||
"init_command": a.initCmd,
|
||||
"startup_wait_ms": a.startupWaitMs,
|
||||
"prompt_pattern": a.promptPat,
|
||||
"poll_interval_ms": a.pollMs,
|
||||
"strip_input_block": a.stripInputBlock,
|
||||
"strip_patterns": a.stripPatterns,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) SetWorkDir(dir string) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.workDir = dir
|
||||
slog.Info("tmux: work_dir changed", "work_dir", dir)
|
||||
}
|
||||
|
||||
func (a *Agent) GetWorkDir() string {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
return a.workDir
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package tmux
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestExtractNew(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
baseline string
|
||||
current string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "no change",
|
||||
baseline: "foo\nbar",
|
||||
current: "foo\nbar",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "empty baseline",
|
||||
baseline: "",
|
||||
current: "hello",
|
||||
want: "hello",
|
||||
},
|
||||
{
|
||||
name: "content grew (fast path)",
|
||||
baseline: "foo\nbar",
|
||||
current: "foo\nbar\nbaz",
|
||||
want: "baz",
|
||||
},
|
||||
{
|
||||
name: "new line after prompt",
|
||||
baseline: "user@host:~$ ",
|
||||
current: "user@host:~$ ls\nfile1\nfile2\nuser@host:~$ ",
|
||||
want: "ls\nfile1\nfile2\nuser@host:~$ ",
|
||||
},
|
||||
{
|
||||
name: "anchor overlap",
|
||||
baseline: "line1\nline2\nline3\nline4\nline5",
|
||||
current: "line3\nline4\nline5\nnew1\nnew2",
|
||||
want: "new1\nnew2",
|
||||
},
|
||||
{
|
||||
name: "fully scrolled - return all current",
|
||||
baseline: "old1\nold2\nold3",
|
||||
current: "new1\nnew2\nnew3",
|
||||
want: "new1\nnew2\nnew3",
|
||||
},
|
||||
{
|
||||
name: "TUI redrawn - shared frame, response replaces prompt",
|
||||
baseline: "╭─ Claude ─╮\n\n>",
|
||||
current: "╭─ Claude ─╮\n\nThe answer is 42.\n\n>",
|
||||
want: "The answer is 42.",
|
||||
},
|
||||
{
|
||||
name: "TUI redrawn - multi-line response",
|
||||
baseline: "header\n\n>",
|
||||
current: "header\n\nLine one.\nLine two.\n\n>",
|
||||
want: "Line one.\nLine two.",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := extractNew(tt.baseline, tt.current)
|
||||
if got != tt.want {
|
||||
t.Errorf("extractNew() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeCapture(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "strip trailing spaces per line",
|
||||
raw: "hello \nworld \n",
|
||||
want: "hello\nworld",
|
||||
},
|
||||
{
|
||||
name: "strip ANSI color codes",
|
||||
raw: "\x1b[32mgreen\x1b[0m normal",
|
||||
want: "green normal",
|
||||
},
|
||||
{
|
||||
name: "strip OSC sequence",
|
||||
raw: "\x1b]0;title\x07prompt$ ",
|
||||
want: "prompt$",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := normalizeCapture(tt.raw)
|
||||
if got != tt.want {
|
||||
t.Errorf("normalizeCapture() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAgentValidation(t *testing.T) {
|
||||
// Missing session name should fail
|
||||
_, err := New(map[string]any{})
|
||||
if err == nil {
|
||||
t.Error("expected error when session is empty")
|
||||
}
|
||||
|
||||
// With session name but tmux not in PATH - may fail on systems without tmux,
|
||||
// so we just verify the session check happens before the tmux PATH check.
|
||||
}
|
||||
|
||||
// TestResolveTargetUniquePerWorkDir verifies that two workDirs sharing the same
|
||||
// basename but different parent paths never map to the same tmux window target.
|
||||
func TestResolveTargetUniquePerWorkDir(t *testing.T) {
|
||||
a := &Agent{sessionName: "mywork", pane: "0"}
|
||||
|
||||
target1, win1 := a.resolveTarget("mywork", "0", "/repo/a/app")
|
||||
target2, win2 := a.resolveTarget("mywork", "0", "/repo/b/app")
|
||||
|
||||
if target1 == target2 {
|
||||
t.Errorf("resolveTarget: collision — /repo/a/app and /repo/b/app both produced %q", target1)
|
||||
}
|
||||
if win1 == win2 {
|
||||
t.Errorf("uniqueWindowName: collision — /repo/a/app and /repo/b/app both produced %q", win1)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveTargetStable verifies that the same workDir always yields the same target.
|
||||
func TestResolveTargetStable(t *testing.T) {
|
||||
a := &Agent{sessionName: "mywork", pane: "0"}
|
||||
|
||||
t1, w1 := a.resolveTarget("mywork", "0", "/repo/a/app")
|
||||
t2, w2 := a.resolveTarget("mywork", "0", "/repo/a/app")
|
||||
|
||||
if t1 != t2 || w1 != w2 {
|
||||
t.Errorf("resolveTarget not deterministic: got %q/%q then %q/%q", t1, w1, t2, w2)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewTmuxSessionWorkDir verifies that the workDir is stored in the session so
|
||||
// that file attachments are saved relative to the workspace, not to ".".
|
||||
func TestNewTmuxSessionWorkDir(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
s, err := newTmuxSession(ctx, "sess:win", "sid1", "", 200*time.Millisecond, false, nil, "/tmp/workspace")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
if s.workDir != "/tmp/workspace" {
|
||||
t.Errorf("workDir = %q, want /tmp/workspace", s.workDir)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user