fix: Stop now preserves tmux window by tracking backend PID

The Stop() method was destroying the tmux window because it killed the
bash shell (pane PID) instead of just the backend process. This commit
fixes the issue by:

1. Added FindBackendPID() to find the actual backend process (child of bash)
2. Modified session creation to store the backend PID, not the shell PID
3. Updated stopInternal() to kill only the backend process:
   - Send Ctrl+C (handles SIGINT)
   - Send second Ctrl+C if still alive (some CLIs need 2x)
   - Use SIGTERM/SIGKILL on backend PID if unresponsive
   - Never kill the bash shell, preserving the tmux window
4. Fixed Restart() to properly clean up and recreate FIFO

Tested successfully:
- Stop kills backend but preserves window ✓
- Session state transitions to "stopped" ✓
- Restart reuses same window and works ✓
- Can send prompts after restart ✓

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Levi Neely 2026-02-14 10:28:06 +01:00
parent e25d1658dd
commit c445b21206
3 changed files with 105 additions and 42 deletions

View File

@ -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

View File

@ -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"

View File

@ -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{