claude-failover/internal/tmux/client.go
Ubuntu 6b109ed1bc fix(dispatcher): send a lone Enter after the task paste to submit it
Multi-line task bodies arrived in Claude Code as "[Pasted text #N +M lines]"
and sat in the input buffer forever — the trailing Enter that SendKeys
appends to the paste is consumed as a newline inside the paste, not as a
submit. Observed live on ccl-auto-11 (secumon) and ccl-auto-12 (secuops):
prompt visible, agent idle.

- tmux.Client grows a SendEnter(session) method. ExecClient runs
  `tmux send-keys -t <sess> Enter` (no preceding text), which Claude's
  TUI accepts as the explicit submit action after a paste.
- Dispatcher: after SendKeys(msg), sleep 500ms for the paste to register,
  then SendEnter. Same sequence a human would perform.
- Five mockTmux implementations updated (quota, dispatcher, switcher,
  lifecycle, watcher tests).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 20:49:59 +00:00

104 lines
3.3 KiB
Go

// Package tmux provides an interface and implementation for controlling tmux sessions.
package tmux
import (
"bytes"
"fmt"
"os/exec"
"strings"
)
// Client defines the operations the daemon needs on tmux sessions.
type Client interface {
// HasSession returns true if the named session exists.
HasSession(name string) bool
// CreateSession creates a new detached session with an optional working directory.
CreateSession(name, workdir string) error
// KillSession destroys the named session.
KillSession(name string) error
// SendKeys sends key strokes to the first window of a session, terminated
// by a single Enter. Note: when keys contains newlines, Claude Code's
// TUI treats the whole block as a paste and the trailing Enter is
// consumed as part of it — call SendEnter afterwards to submit.
SendKeys(session, keys string) error
// SendEnter sends a lone Enter keypress. Used to submit a multi-line
// paste in Claude Code after SendKeys.
SendEnter(session string) error
// CapturePaneTail returns the last n lines from the session's active pane.
CapturePaneTail(session string, lines int) (string, error)
}
// ExecClient implements Client by shelling out to the tmux binary.
type ExecClient struct{}
// NewExecClient returns a ready-to-use ExecClient.
func NewExecClient() *ExecClient {
return &ExecClient{}
}
// run executes a tmux subcommand and returns combined output.
func run(args ...string) (string, error) {
cmd := exec.Command("tmux", args...)
var out bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &out
err := cmd.Run()
return strings.TrimSpace(out.String()), err
}
// HasSession returns true when `tmux has-session` exits 0.
func (c *ExecClient) HasSession(name string) bool {
_, err := run("has-session", "-t", name)
return err == nil
}
// CreateSession creates a new detached tmux session.
// If workdir is non-empty, the session starts in that directory.
func (c *ExecClient) CreateSession(name, workdir string) error {
args := []string{"new-session", "-d", "-s", name}
if workdir != "" {
args = append(args, "-c", workdir)
}
out, err := run(args...)
if err != nil {
return fmt.Errorf("tmux new-session %q: %w (%s)", name, err, out)
}
return nil
}
// KillSession destroys the named session.
func (c *ExecClient) KillSession(name string) error {
out, err := run("kill-session", "-t", name)
if err != nil {
return fmt.Errorf("tmux kill-session %q: %w (%s)", name, err, out)
}
return nil
}
// SendKeys sends key strokes to the first pane of the session.
func (c *ExecClient) SendKeys(session, keys string) error {
out, err := run("send-keys", "-t", session, keys, "Enter")
if err != nil {
return fmt.Errorf("tmux send-keys %q: %w (%s)", session, err, out)
}
return nil
}
// SendEnter sends a lone Enter keypress to the session's first pane.
func (c *ExecClient) SendEnter(session string) error {
out, err := run("send-keys", "-t", session, "Enter")
if err != nil {
return fmt.Errorf("tmux send-keys Enter %q: %w (%s)", session, err, out)
}
return nil
}
// CapturePaneTail captures the last n lines from the session's active pane.
func (c *ExecClient) CapturePaneTail(session string, lines int) (string, error) {
out, err := run("capture-pane", "-p", "-t", session,
"-S", fmt.Sprintf("-%d", lines))
if err != nil {
return "", fmt.Errorf("tmux capture-pane %q: %w (%s)", session, err, out)
}
return out, nil
}