439 lines
13 KiB
Go
439 lines
13 KiB
Go
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
|
||
}
|