diff --git a/commands.go b/commands.go index ddbfcb3..473a024 100644 --- a/commands.go +++ b/commands.go @@ -845,16 +845,16 @@ func listen() error { target := tmuxTargetByID(windowID, tmuxName) // Send arrow down keys to select option, then Enter for i := 0; i < optionIndex; i++ { - exec.Command(tmuxPath, "send-keys", "-t", target, "Down").Run() + tmuxSendKeys(target, "Down") time.Sleep(50 * time.Millisecond) } - exec.Command(tmuxPath, "send-keys", "-t", target, "Enter").Run() + tmuxSendKeys(target, "Enter") listenLog("[callback] Selected option %d for %s (question %d/%d)", optionIndex, sessionName, questionIndex+1, totalQuestions) // After the last question, send Enter to confirm "Submit answers" if totalQuestions > 0 && questionIndex == totalQuestions-1 { time.Sleep(300 * time.Millisecond) - exec.Command(tmuxPath, "send-keys", "-t", target, "Enter").Run() + tmuxSendKeys(target, "Enter") listenLog("[callback] Auto-submitted answers for %s", sessionName) } } @@ -1450,7 +1450,7 @@ func handleAuth(config *Config, chatID, threadID int64) { } time.Sleep(500 * time.Millisecond) - exec.Command(tmuxPath, "send-keys", "-t", authTmuxSession, claudePath+" --dangerously-skip-permissions", "C-m").Run() + tmuxSendKeys(authTmuxSession, claudePath+" --dangerously-skip-permissions", "C-m") var oauthURL string for i := 0; i < 30; i++ { @@ -1503,9 +1503,9 @@ func handleAuthCode(config *Config, chatID, threadID int64, code string) { sendMessage(config, chatID, threadID, "🔄 Sending code to Claude...") - exec.Command(tmuxPath, "send-keys", "-t", authTmuxSession, "-l", code).Run() + tmuxPasteText(authTmuxSession, code) time.Sleep(200 * time.Millisecond) - exec.Command(tmuxPath, "send-keys", "-t", authTmuxSession, "C-m").Run() + tmuxSendKeys(authTmuxSession, "C-m") for i := 0; i < 10; i++ { time.Sleep(2 * time.Second) @@ -1513,14 +1513,14 @@ func handleAuthCode(config *Config, chatID, threadID int64, code string) { pane := string(out) if strings.Contains(pane, "Yes, I accept") { - exec.Command(tmuxPath, "send-keys", "-t", authTmuxSession, "Down").Run() + tmuxSendKeys(authTmuxSession, "Down") time.Sleep(200 * time.Millisecond) - exec.Command(tmuxPath, "send-keys", "-t", authTmuxSession, "C-m").Run() + tmuxSendKeys(authTmuxSession, "C-m") continue } if strings.Contains(pane, "Press Enter") || strings.Contains(pane, "Enter to confirm") { - exec.Command(tmuxPath, "send-keys", "-t", authTmuxSession, "C-m").Run() + tmuxSendKeys(authTmuxSession, "C-m") continue } diff --git a/tmux.go b/tmux.go index aeebb1c..1f519bd 100644 --- a/tmux.go +++ b/tmux.go @@ -3,6 +3,7 @@ package main import ( "bufio" "bytes" + "context" "fmt" "os" "os/exec" @@ -164,7 +165,7 @@ func createTmuxWindow(windowName string, workDir string, continueSession bool) ( // Send the command to the window via send-keys using window ID time.Sleep(200 * time.Millisecond) - exec.Command(tmuxPath, "send-keys", "-t", windowID, cccCmd, "C-m").Run() + tmuxSendKeys(windowID, cccCmd, "C-m") return windowID, nil } @@ -185,6 +186,10 @@ func runClaudeRaw(continueSession bool) error { } var args []string + // Auto-approve mode: skip permissions when no OTP is configured + if config, err := loadConfig(); err == nil && config.OTPSecret == "" { + args = append(args, "--dangerously-skip-permissions") + } if continueSession { args = append(args, "-c") } @@ -247,32 +252,49 @@ func sendToTmuxFromTelegramWithDelay(target string, windowName string, text stri return sendToTmuxWithDelay(target, text, delay) } -func sendToTmux(target string, text string) error { - // Calculate delay based on text length - // Base: 50ms + 0.5ms per character, capped at 5 seconds - baseDelay := 50 * time.Millisecond - charDelay := time.Duration(len(text)) * 500 * time.Microsecond // 0.5ms per char - delay := baseDelay + charDelay - if delay > 5*time.Second { - delay = 5 * time.Second +// tmuxSendKeys sends control keys to a tmux target with timeout protection. +// Use for all send-keys calls to prevent indefinite hangs (tmux/tmux#1185). +func tmuxSendKeys(target string, args ...string) error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + cmdArgs := append([]string{"send-keys", "-t", target}, args...) + return exec.CommandContext(ctx, tmuxPath, cmdArgs...).Run() +} + +// tmuxPasteText sends text to a tmux target using set-buffer + paste-buffer -p. +// Bracketed paste mode avoids the send-keys -l hang when the PTY buffer is full. +func tmuxPasteText(target string, text string) error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := exec.CommandContext(ctx, tmuxPath, "set-buffer", "-b", "ccc-input", text).Run(); err != nil { + return fmt.Errorf("set-buffer: %w", err) } - return sendToTmuxWithDelay(target, text, delay) + ctx2, cancel2 := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel2() + if err := exec.CommandContext(ctx2, tmuxPath, "paste-buffer", "-b", "ccc-input", "-p", "-t", target).Run(); err != nil { + return fmt.Errorf("paste-buffer: %w", err) + } + return nil +} + +func sendToTmux(target string, text string) error { + return sendToTmuxWithDelay(target, text, 0) } func sendToTmuxWithDelay(target string, text string, delay time.Duration) error { - // Send text literally - cmd := exec.Command(tmuxPath, "send-keys", "-t", target, "-l", text) - if err := cmd.Run(); err != nil { + if err := tmuxPasteText(target, text); err != nil { return err } - // Brief pause for TUI to process pasted text + // Dismiss autocomplete popup that bracketed paste may trigger time.Sleep(100 * time.Millisecond) + tmuxSendKeys(target, "Escape") - // Send Enter twice (Claude Code needs double Enter) - exec.Command(tmuxPath, "send-keys", "-t", target, "C-m").Run() + // Submit with double Enter (Claude Code needs double Enter) + time.Sleep(50 * time.Millisecond) + tmuxSendKeys(target, "Enter") time.Sleep(50 * time.Millisecond) - exec.Command(tmuxPath, "send-keys", "-t", target, "C-m").Run() + tmuxSendKeys(target, "Enter") return nil }