diff --git a/internal/backend/tmux/helpers.go b/internal/backend/tmux/helpers.go index d972c0b..3e4a0dd 100644 --- a/internal/backend/tmux/helpers.go +++ b/internal/backend/tmux/helpers.go @@ -117,6 +117,26 @@ func getPanePID(target string) (int, error) { return pid, err } +// FindBackendPID finds the backend process PID (direct child of bash) +// This works for both claude and kiro-cli backends +func FindBackendPID(panePID int) int { + // Process tree: panePID (bash) -> backend (claude, kiro-cli, etc.) + // Get first child process of the bash shell + cmd := exec.Command("pgrep", "-P", fmt.Sprintf("%d", panePID)) + out, err := cmd.Output() + if err != nil { + return 0 + } + // pgrep returns one PID per line; take the first one + lines := strings.Split(strings.TrimSpace(string(out)), "\n") + if len(lines) == 0 || lines[0] == "" { + return 0 + } + var pid int + fmt.Sscanf(lines[0], "%d", &pid) + return pid +} + // FindKiroChatPID traverses the process tree to find kiro-cli-chat func FindKiroChatPID(panePID int) int { // pane (kiro-cli-term) -> bash -> kiro-cli -> kiro-cli-chat diff --git a/internal/backend/tmux/session.go b/internal/backend/tmux/session.go index 32ca149..f7c69b1 100644 --- a/internal/backend/tmux/session.go +++ b/internal/backend/tmux/session.go @@ -522,45 +522,51 @@ func (s *Session) stopInternal(ctx context.Context) error { s.mu.Unlock() // 1. Send Ctrl+C to interrupt any running operation + // This sends SIGINT to the foreground process group (the backend process) if err := sendKeys(target, "C-c"); err != nil { debug.Log("[session %s] failed to send Ctrl+C: %v", sessionID, err) return fmt.Errorf("failed to interrupt: %w", err) } - // 2. Wait briefly for graceful shutdown - time.Sleep(100 * time.Millisecond) + // 2. Wait for backend to respond to SIGINT and exit gracefully + time.Sleep(500 * time.Millisecond) - // 3. Send Ctrl+D (EOF) to terminate process gracefully - if err := sendKeys(target, "C-d"); err != nil { - debug.Log("[session %s] failed to send Ctrl+D: %v", sessionID, err) - return fmt.Errorf("failed to send EOF: %w", err) - } - - // 4. Wait for process to exit - time.Sleep(200 * time.Millisecond) - - // 5. Verify process is gone (check PID) + // 3. Check if backend process (pid) is still alive and force-kill if necessary + // Note: pid is the backend process (claude/kiro-cli), NOT the bash shell if pid != 0 { - // If still alive, send SIGTERM - if err := syscall.Kill(pid, syscall.SIGTERM); err == nil { - debug.Log("[session %s] sent SIGTERM to PID %d", sessionID, pid) - // Process exists, wait for it to die - time.Sleep(100 * time.Millisecond) + // Check if process is still alive + if err := syscall.Kill(pid, 0); err == nil { + // Process still exists, send another Ctrl+C (some CLIs need 2 Ctrl+C to exit) + debug.Log("[session %s] backend still alive after first Ctrl+C, sending second", sessionID) + if err := sendKeys(target, "C-c"); err != nil { + debug.Log("[session %s] failed to send second Ctrl+C: %v", sessionID, err) + } + time.Sleep(500 * time.Millisecond) - // If STILL alive, send SIGKILL + // Check if still alive after second Ctrl+C if err := syscall.Kill(pid, 0); err == nil { - debug.Log("[session %s] process still alive, sending SIGKILL to PID %d", sessionID, pid) - if err := syscall.Kill(pid, syscall.SIGKILL); err != nil { - debug.Log("[session %s] SIGKILL failed: %v", sessionID, err) + // Still alive, send SIGTERM + debug.Log("[session %s] backend still alive, sending SIGTERM to PID %d", sessionID, pid) + if err := syscall.Kill(pid, syscall.SIGTERM); err != nil { + debug.Log("[session %s] SIGTERM failed: %v", sessionID, err) + } + time.Sleep(300 * time.Millisecond) + + // Last resort: SIGKILL + if err := syscall.Kill(pid, 0); err == nil { + debug.Log("[session %s] backend still alive, sending SIGKILL to PID %d", sessionID, pid) + if err := syscall.Kill(pid, syscall.SIGKILL); err != nil { + debug.Log("[session %s] SIGKILL failed: %v", sessionID, err) + } + time.Sleep(100 * time.Millisecond) } - time.Sleep(50 * time.Millisecond) } } else { - debug.Log("[session %s] PID %d already dead: %v", sessionID, pid, err) + debug.Log("[session %s] backend exited after Ctrl+C", sessionID) } } - // 6. Verify tmux window still exists + // 4. Verify tmux window still exists if !windowExists(tmuxSession, windowName) { debug.Log("[session %s] tmux window was unexpectedly destroyed", sessionID) s.mu.Lock() @@ -569,7 +575,7 @@ func (s *Session) stopInternal(ctx context.Context) error { return fmt.Errorf("tmux window was unexpectedly destroyed") } - // 7. Update state + // 5. Update state s.mu.Lock() s.state = "stopped" s.pid = 0 @@ -578,7 +584,8 @@ func (s *Session) stopInternal(ctx context.Context) error { debug.Log("[session %s] stopped successfully", sessionID) - // Note: tmux window, FIFO, and reader goroutine remain active + // Note: Shell, tmux window, FIFO, and reader goroutine remain active + // This allows the session to be restarted without recreating the window // The reader stays in a wait loop until either Restart() or Close() return nil } @@ -668,15 +675,28 @@ func (s *Session) Restart(ctx context.Context) error { } s.mu.Unlock() + // 3. Close existing pipe-pane + if err := closePipePane(target); err != nil { + debug.Log("[session %s] warning: failed to close pipe-pane: %v", s.id, err) + } + + // 4. Remove old FIFO file + os.Remove(fifoPath) + // Small delay to ensure cleanup time.Sleep(100 * time.Millisecond) - // 3. Re-setup pipe-pane for output capture + // 5. Recreate FIFO + if err := syscall.Mkfifo(fifoPath, 0600); err != nil && !os.IsExist(err) { + return fmt.Errorf("failed to create FIFO: %w", err) + } + + // 6. Re-setup pipe-pane for output capture if err := setupPipePane(target, fifoPath); err != nil { return fmt.Errorf("failed to re-setup pipe-pane: %w", err) } - // 4. Open FIFO for reading (this blocks until writer connects) + // 7. Open FIFO for reading (this blocks until writer connects) fifoOpenCh := make(chan *os.File, 1) fifoErrCh := make(chan error, 1) go func() { @@ -688,7 +708,7 @@ func (s *Session) Restart(ctx context.Context) error { fifoOpenCh <- f }() - // 5. Change to working directory (may have changed in shell) + // 8. Change to working directory (may have changed in shell) if err := sendKeys(target, fmt.Sprintf("cd \"%s\"", cwd), "C-m"); err != nil { return fmt.Errorf("failed to change directory: %w", err) } @@ -696,19 +716,19 @@ func (s *Session) Restart(ctx context.Context) error { // Small delay for cd to complete time.Sleep(100 * time.Millisecond) - // 5.5. Restore environment variables + // 9. Restore environment variables for k, v := range environment { if err := setEnvironment(target, k, v); err != nil { return fmt.Errorf("failed to set environment: %w", err) } } - // 6. Send the original command to tmux + // 10. Send the original command to tmux if err := sendKeys(target, cmdStr, "C-m"); err != nil { return fmt.Errorf("failed to start command: %w", err) } - // 7. Wait for FIFO to open + // 11. Wait for FIFO to open var fifo *os.File select { case fifo = <-fifoOpenCh: @@ -719,12 +739,23 @@ func (s *Session) Restart(ctx context.Context) error { return fmt.Errorf("timeout opening FIFO") } - // 8. Get new PID + // 12. Get new PID (find the backend process, not just the bash shell) time.Sleep(500 * time.Millisecond) - pid, err := getPanePID(target) + panePID, err := getPanePID(target) if err != nil { - debug.Log("[session %s] warning: failed to get PID: %v", s.id, err) - pid = 0 + debug.Log("[session %s] warning: failed to get pane PID: %v", s.id, err) + panePID = 0 + } + + // Find the actual backend process (child of bash shell) + pid := 0 + if panePID != 0 { + pid = FindBackendPID(panePID) + if pid == 0 { + debug.Log("[session %s] warning: backend process not found for pane PID %d", s.id, panePID) + } else { + debug.Log("[session %s] found backend PID %d (pane PID: %d)", s.id, pid, panePID) + } } s.mu.Lock() @@ -737,10 +768,10 @@ func (s *Session) Restart(ctx context.Context) error { s.readerGeneration++ // Increment to invalidate old reader s.mu.Unlock() - // 9. Start new reader goroutine + // 13. Start new reader goroutine go s.reader() - // 10. Wait for ready (reuse existing waitForReady logic) + // 14. Wait for ready (reuse existing waitForReady logic) if err := s.waitForReady(ctx, 30*time.Second); err != nil { s.mu.Lock() s.state = "error" diff --git a/internal/backend/tmux/tmux.go b/internal/backend/tmux/tmux.go index 7aeb3ac..f5d31c9 100644 --- a/internal/backend/tmux/tmux.go +++ b/internal/backend/tmux/tmux.go @@ -230,11 +230,23 @@ func (b *Backend) CreateSession(ctx context.Context, cwd string) (backend.Sessio return nil, fmt.Errorf("timeout opening FIFO") } - // 9. Get PID - pid, err := getPanePID(target) + // 9. 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 PID: %v", id, err) - pid = 0 + 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 { + time.Sleep(200 * time.Millisecond) // Brief delay for backend to start + pid = FindBackendPID(panePID) + if pid == 0 { + debug.Log("[session %s] warning: backend process not found for pane PID %d", id, panePID) + } else { + debug.Log("[session %s] found backend PID %d (pane PID: %d)", id, pid, panePID) + } } sess := &Session{