From fe56df893012152fda8f772f23b6b2d7cb7170a2 Mon Sep 17 00:00:00 2001 From: Levi Neely Date: Thu, 12 Feb 2026 16:01:24 +0100 Subject: [PATCH] Replace quiescence detection with process tree inspection Detect idle state by checking if kiro-cli-chat has tool children rather than relying purely on output quiescence. Fixes premature idle detection during long-running processes like remote builds. --- internal/backend/tmux/helpers.go | 38 ++++++++++++++++++++++++++++++++ internal/backend/tmux/session.go | 30 +++++++++++++++++++++---- 2 files changed, 64 insertions(+), 4 deletions(-) diff --git a/internal/backend/tmux/helpers.go b/internal/backend/tmux/helpers.go index 255542f..9284027 100644 --- a/internal/backend/tmux/helpers.go +++ b/internal/backend/tmux/helpers.go @@ -109,6 +109,44 @@ func getPanePID(target string) (int, error) { return pid, err } +// hasToolChildren checks if kiro-cli-chat has any child processes (tool executions) +func hasToolChildren(panePID int) bool { + // Find kiro-cli-chat PID in the process tree: pane -> bash -> kiro-cli -> kiro-cli-chat + chatPID := findKiroChatPID(panePID) + if chatPID == 0 { + return false + } + // Check if kiro-cli-chat has any children + cmd := exec.Command("pgrep", "-P", fmt.Sprintf("%d", chatPID)) + return cmd.Run() == nil +} + +// 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 + bashPID := findChildByName(panePID, "bash") + if bashPID == 0 { + return 0 + } + kiroPID := findChildByName(bashPID, "kiro-cli") + if kiroPID == 0 { + return 0 + } + return findChildByName(kiroPID, "kiro-cli-chat") +} + +// findChildByName finds a child process with the given name +func findChildByName(parentPID int, name string) int { + cmd := exec.Command("pgrep", "-P", fmt.Sprintf("%d", parentPID), "-x", name) + out, err := cmd.Output() + if err != nil { + return 0 + } + var pid int + fmt.Sscanf(strings.TrimSpace(string(out)), "%d", &pid) + return pid +} + // makeNonBlocking sets a file descriptor to non-blocking mode func makeNonBlocking(fd int) error { return syscall.SetNonblock(fd, true) diff --git a/internal/backend/tmux/session.go b/internal/backend/tmux/session.go index 7453720..d4b042a 100644 --- a/internal/backend/tmux/session.go +++ b/internal/backend/tmux/session.go @@ -336,7 +336,10 @@ func (s *Session) Send(ctx context.Context, prompt string) (string, error) { func (s *Session) waitForComplete(ctx context.Context, prompt string) error { const minContent = 50 - const quiescence = 400 * time.Millisecond + const quiescence = 300 * time.Millisecond + const pollInterval = 100 * time.Millisecond + + var quiesceStart time.Time for { select { @@ -349,10 +352,29 @@ func (s *Session) waitForComplete(ctx context.Context, prompt string) error { return fmt.Errorf("session closed") } s.output.Write(data) + quiesceStart = time.Time{} // Reset quiescence on new data - case <-time.After(quiescence): - if s.output.Len() > minContent { - debug.Log("[session %s] quiescence detected, %d bytes", s.id, s.output.Len()) + case <-time.After(pollInterval): + // Check if we have minimum content + if s.output.Len() < minContent { + continue + } + + // Check process tree: if kiro-cli-chat has tool children, still running + if hasToolChildren(s.pid) { + quiesceStart = time.Time{} // Reset quiescence + continue + } + + // No tool children - start or continue quiescence timer + if quiesceStart.IsZero() { + quiesceStart = time.Now() + continue + } + + // Check if quiescence period has passed + if time.Since(quiesceStart) >= quiescence { + debug.Log("[session %s] idle detected (no tool children, %d bytes)", s.id, s.output.Len()) return nil } }