Skip to content

Commit 50dd8c9

Browse files
authored
fix: replace tmux pattern matching with session file detection for kanban workspace status
fix: replace tmux pattern matching with session file detection for kanban workspace status
2 parents 3e8779d + 548289b commit 50dd8c9

File tree

12 files changed

+928
-209
lines changed

12 files changed

+928
-209
lines changed

internal/plugins/workspace/agent.go

Lines changed: 82 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"encoding/json"
66
"fmt"
7+
"log/slog"
78
"os"
89
"os/exec"
910
"path/filepath"
@@ -743,7 +744,8 @@ func (p *Plugin) scheduleInteractivePoll(worktreeName string, delay time.Duratio
743744
// AgentPollUnchangedMsg signals content unchanged, schedule next poll.
744745
type AgentPollUnchangedMsg struct {
745746
WorkspaceName string
746-
CurrentStatus WorktreeStatus // For adaptive polling interval selection
747+
CurrentStatus WorktreeStatus // Status including session file re-check
748+
WaitingFor string // Prompt text if waiting
747749
// Cursor position captured atomically (even when content unchanged)
748750
CursorRow int
749751
CursorCol int
@@ -845,41 +847,58 @@ func (p *Plugin) handlePollAgent(worktreeName string) tea.Cmd {
845847
output = trimCapturedOutput(output, maxBytes)
846848

847849
// Use hash-based change detection to skip processing if content unchanged
848-
if outputBuf != nil && !outputBuf.Update(output) {
849-
// Content unchanged - signal to schedule next poll with delay
850-
// Still include cursor position since cursor can move without content changing
851-
return AgentPollUnchangedMsg{
852-
WorkspaceName: worktreeName,
853-
CurrentStatus: currentStatus,
854-
CursorRow: cursorRow,
855-
CursorCol: cursorCol,
856-
CursorVisible: cursorVisible,
857-
HasCursor: hasCursor,
858-
PaneHeight: paneHeight,
859-
PaneWidth: paneWidth,
860-
}
861-
}
850+
outputChanged := outputBuf == nil || outputBuf.Update(output)
862851

863-
// Content changed - detect status (skip during interactive mode to reduce I/O, td-f29f2d)
852+
// Detect status. Both detectors run; each is authoritative for what it's good at (td-2fca7d):
853+
// - tmux patterns: thinking, done, error (high-signal, session files can't detect these)
854+
// - session files: active vs waiting (reliable, tmux patterns are noisy for this)
855+
// Session file detection ALWAYS runs (even when output unchanged) because the agent
856+
// may finish while tmux output stays the same (td-2fca7d v8).
864857
status := currentStatus
865858
waitingFor := ""
866859
if !interactiveCapture {
867-
status = detectStatus(output)
868-
if status == StatusWaiting {
869-
waitingFor = extractPrompt(output)
860+
if outputChanged {
861+
// Tmux pattern detection only when output changes (same output = same patterns).
862+
status = detectStatus(output)
863+
if status == StatusWaiting {
864+
waitingFor = extractPrompt(output)
865+
}
870866
}
871-
// For supported agents: supplement tmux detection with session file analysis
872-
// Session files are more reliable for detecting "waiting at prompt" state
873-
if status == StatusActive {
867+
// Session file check runs every poll — mtime changes independently of tmux output.
868+
// Only override active/waiting; preserve tmux-detected thinking/done/error.
869+
if status == StatusActive || status == StatusWaiting {
874870
if sessionStatus, ok := detectAgentSessionStatus(agentType, wtPath); ok {
875-
if sessionStatus == StatusWaiting {
876-
status = StatusWaiting
877-
waitingFor = "Waiting for input"
871+
prevStatus := status
872+
status = sessionStatus
873+
if status == StatusWaiting {
874+
waitingFor = extractPrompt(output)
875+
if waitingFor == "" {
876+
waitingFor = "Waiting for input"
877+
}
878+
} else {
879+
waitingFor = ""
878880
}
881+
slog.Debug("status: session file override", "worktree", worktreeName, "prev", prevStatus, "session", sessionStatus)
882+
} else {
883+
slog.Debug("status: no session file, using tmux", "worktree", worktreeName, "status", status, "agent", agentType)
879884
}
880885
}
881886
}
882887

888+
if !outputChanged {
889+
return AgentPollUnchangedMsg{
890+
WorkspaceName: worktreeName,
891+
CurrentStatus: status,
892+
WaitingFor: waitingFor,
893+
CursorRow: cursorRow,
894+
CursorCol: cursorCol,
895+
CursorVisible: cursorVisible,
896+
HasCursor: hasCursor,
897+
PaneHeight: paneHeight,
898+
PaneWidth: paneWidth,
899+
}
900+
}
901+
883902
return AgentOutputMsg{
884903
WorkspaceName: worktreeName,
885904
Output: output,
@@ -1068,31 +1087,34 @@ func tailUTF8Safe(s string, n int) string {
10681087
}
10691088

10701089
// detectStatus determines agent status from captured output.
1071-
// Optimized to avoid unnecessary string allocations.
1090+
// This is the tmux-based fallback for agents without session file support (td-2fca7d).
1091+
// For supported agents (Claude, Codex, Gemini, OpenCode), session file analysis runs
1092+
// first in handlePollAgent and is more reliable than tmux pattern matching.
10721093
func detectStatus(output string) WorktreeStatus {
10731094
// Check tail of output for status patterns (avoids splitting entire string)
10741095
checkText := tailUTF8Safe(output, statusCheckBytes)
10751096
textLower := strings.ToLower(checkText)
10761097

1077-
// Waiting patterns (agent needs user input) - highest priority
1078-
// Check before thinking since waiting blocks progress
1098+
// Waiting patterns — only check the last few lines of output (td-2fca7d).
1099+
// A prompt is only relevant if it's at the bottom of the screen (the agent is
1100+
// actually waiting right now). Checking 2048 bytes of scrollback history caused
1101+
// false positives from old prompts and shell prompt characters like "❯".
10791102
waitingPatterns := []string{
10801103
"[y/n]", // Claude Code permission prompt
10811104
"(y/n)", // Aider style
10821105
"allow edit", // Claude Code file edit
10831106
"allow bash", // Claude Code bash command
1084-
"waiting for", // Generic waiting
10851107
"press enter", // Continue prompt
10861108
"continue?",
10871109
"approve",
10881110
"confirm",
10891111
"do you want", // Common prompt
1090-
"❯", // Claude Code input prompt (waiting for user)
1091-
"╰─❯", // Claude Code prompt with tree line decoration
10921112
}
10931113

1114+
lastLines := extractLastNLines(checkText, 5)
1115+
lastLinesLower := strings.ToLower(lastLines)
10941116
for _, pattern := range waitingPatterns {
1095-
if strings.Contains(textLower, pattern) {
1117+
if strings.Contains(lastLinesLower, pattern) {
10961118
return StatusWaiting
10971119
}
10981120
}
@@ -1156,6 +1178,34 @@ func detectStatus(output string) WorktreeStatus {
11561178
return StatusActive
11571179
}
11581180

1181+
// extractLastNLines returns the last n non-empty lines of text.
1182+
// Used by detectStatus to restrict waiting pattern matching to the bottom of the terminal.
1183+
func extractLastNLines(text string, n int) string {
1184+
// Work backwards from the end to find the last n lines
1185+
end := len(text)
1186+
// Skip trailing whitespace/newlines
1187+
for end > 0 && (text[end-1] == '\n' || text[end-1] == '\r' || text[end-1] == ' ') {
1188+
end--
1189+
}
1190+
if end == 0 {
1191+
return ""
1192+
}
1193+
1194+
linesFound := 0
1195+
pos := end
1196+
for pos > 0 && linesFound < n {
1197+
pos--
1198+
if text[pos] == '\n' {
1199+
linesFound++
1200+
}
1201+
}
1202+
// If we stopped at a newline, skip past it
1203+
if pos > 0 || (pos == 0 && text[0] == '\n') {
1204+
pos++
1205+
}
1206+
return text[pos:end]
1207+
}
1208+
11591209
// extractPrompt finds the prompt text from output.
11601210
// Optimized to search backwards without splitting the entire string.
11611211
func extractPrompt(output string) string {

0 commit comments

Comments
 (0)