diff --git a/internal/coordinator/server_test.go b/internal/coordinator/server_test.go index 450f6f8..ab3c1fe 100644 --- a/internal/coordinator/server_test.go +++ b/internal/coordinator/server_test.go @@ -185,8 +185,8 @@ func TestRenderMarkdown(t *testing.T) { Items: []string{"CRUD for sessions", "Health check"}, }) postJSON(t, base+"/spaces/feature-123/agent/cp", AgentUpdate{ - Status: StatusBlocked, - Summary: "Waiting for API schema", + Status: StatusBlocked, + Summary: "Waiting for API schema", Blockers: []string{"Need final OpenAPI spec"}, }) @@ -797,7 +797,6 @@ func TestSSEGlobalEndpoint(t *testing.T) { } } - func TestClientDeleteAgent(t *testing.T) { srv, cleanup := mustStartServer(t) defer cleanup() @@ -1049,3 +1048,97 @@ func TestDeleteSpaceCleansUpFiles(t *testing.T) { t.Error("expected md file to be deleted") } } + +func TestLineIsIdleIndicator(t *testing.T) { + tests := []struct { + name string + line string + want bool + }{ + // Claude Code prompt (exact ">" inside box-drawing) + {"claude code prompt bare", "│ > │", true}, + {"claude code prompt no box", ">", true}, + {"claude code prompt with space", "> ", true}, + {"claude code prompt inner space", "│ > │", true}, + + // Shell prompts + {"bash dollar", "user@host:~/code$ ", true}, + {"bare dollar", "$", true}, + {"zsh percent", "% ", true}, + {"root hash", "root@box:/# ", true}, + {"fish prompt", "~/code ❯ ", true}, + {"angle bracket prompt", ">>> ", true}, + + // Claude Code prompt with auto-suggestion + {"claude code prompt bare chevron", "❯", true}, + {"claude code prompt with suggestion", "❯ give me something to work on", true}, + {"claude code prompt chevron space", "❯ ", true}, + + // Claude Code / opencode hint lines + {"shortcuts hint", "? for shortcuts", true}, + {"auto-compact", " auto-compact enabled", true}, + {"auto-accept", " auto-accept on", true}, + + // Claude Code status bar (vim mode) + {"insert mode", " -- INSERT -- ⏵⏵ bypass permissions on (shift+tab to cycle) current: 2.1.70 · latest: 2.1.70", true}, + {"normal mode", " -- NORMAL -- current: 2.1.70 · latest: 2.1.70", true}, + + // OpenCode status bar + {"opencode status bar", " ctrl+t variants tab agents ctrl+p commands • OpenCode 1.2.17", true}, + + // OpenCode / generic idle keywords + {"waiting for input", "Waiting for input...", true}, + {"ready", "Ready", true}, + {"type a message", "Type a message to begin", true}, + + // Busy indicators — should NOT match + {"running command output", "Building project...", false}, + {"file content", "func main() {", false}, + {"progress bar", "[=====> ] 50%", false}, + {"error output", "Error: file not found", false}, + {"git diff line", "+++ b/file.go", false}, + {"empty string", "", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := lineIsIdleIndicator(tt.line) + if got != tt.want { + t.Errorf("lineIsIdleIndicator(%q) = %v, want %v", tt.line, got, tt.want) + } + }) + } +} + +func TestIsShellPrompt(t *testing.T) { + tests := []struct { + line string + want bool + }{ + {"$", true}, + {"$ ", true}, + {"user@host:~$ ", true}, + {"%", true}, + {"zsh% ", true}, + {">", true}, + {">>> ", true}, + {"#", true}, + {"root@box:/# ", true}, + {"~/code ❯ ", true}, + {"❯", true}, + // Not prompts + {"", false}, + {"hello world", false}, + {"func main() {", false}, + {"Building...", false}, + } + + for _, tt := range tests { + t.Run(tt.line, func(t *testing.T) { + got := isShellPrompt(tt.line) + if got != tt.want { + t.Errorf("isShellPrompt(%q) = %v, want %v", tt.line, got, tt.want) + } + }) + } +} diff --git a/internal/coordinator/tmux.go b/internal/coordinator/tmux.go index 531df5a..9e9e147 100644 --- a/internal/coordinator/tmux.go +++ b/internal/coordinator/tmux.go @@ -8,6 +8,7 @@ import ( "strings" "sync" "time" + "unicode/utf8" ) const ( @@ -224,23 +225,128 @@ func tmuxApprove(session string) error { return exec.CommandContext(ctx, "tmux", "send-keys", "-t", session, "Enter").Run() } +// tmuxIsIdle reports whether the tmux session appears to be waiting for input +// (i.e., no agent or process is actively running). It is intentionally generous: +// a session is "busy" only when there is positive evidence of activity. func tmuxIsIdle(session string) bool { - lines, err := tmuxCapturePaneLines(session, 5) + lines, err := tmuxCapturePaneLines(session, 10) if err != nil { - return false + // Cannot read the pane — default to idle rather than falsely reporting busy. + return true } + + // An entirely empty pane (all blank lines) is idle. + if len(lines) == 0 { + return true + } + + // Check each of the last N non-empty lines for idle indicators. for _, line := range lines { - inner := strings.TrimSpace(strings.ReplaceAll(line, "│", "")) - if inner == ">" { + if lineIsIdleIndicator(line) { return true } - trimmed := strings.TrimSpace(line) - if strings.HasPrefix(trimmed, "?") && strings.Contains(trimmed, "for shortcuts") { - return true + } + return false +} + +// lineIsIdleIndicator returns true if a single pane line indicates the session +// is idle / waiting for user input. +func lineIsIdleIndicator(line string) bool { + trimmed := strings.TrimSpace(line) + // Strip box-drawing characters used by Claude Code / opencode TUI. + // Both light (│ U+2502) and heavy (┃ U+2503) verticals are used. + inner := trimmed + inner = strings.ReplaceAll(inner, "│", "") + inner = strings.ReplaceAll(inner, "┃", "") + inner = strings.TrimSpace(inner) + + // ── Claude Code / opencode prompt ── + // The prompt line inside the TUI box is just ">" (possibly with trailing space). + if inner == ">" || inner == "> " { + return true + } + + // ── Claude Code prompt with suggestion ── + // Claude Code shows "❯" as its prompt. When idle it may auto-fill a + // suggested prompt after the ❯ (e.g. "❯ give me something to work on"). + // A line starting with ❯ means the agent is waiting for input regardless + // of what follows (user-typed text or auto-suggestion). + if strings.HasPrefix(trimmed, "❯") { + return true + } + + // ── Shell prompts ── + // Common interactive shell prompts end with $, %, >, #, or ❯ possibly + // followed by a space. We check the last non-space rune of the line. + if isShellPrompt(trimmed) { + return true + } + + // ── Claude Code / opencode hint lines ── + if strings.HasPrefix(trimmed, "?") && strings.Contains(trimmed, "for shortcuts") { + return true + } + if strings.Contains(trimmed, "auto-compact") || strings.Contains(trimmed, "auto-accept") { + return true + } + + // ── Claude Code / opencode status bar ── + // OpenCode's bottom bar contains "ctrl+p commands" when idle. + // Claude Code's bottom bar contains "-- INSERT --" or "-- NORMAL --" (vim mode). + if strings.Contains(trimmed, "ctrl+p commands") { + return true + } + if strings.Contains(trimmed, "-- INSERT --") || strings.Contains(trimmed, "-- NORMAL --") { + return true + } + + // ── OpenCode / Claude Code status bar keywords ── + lower := strings.ToLower(trimmed) + if strings.Contains(lower, "waiting for input") || + strings.Contains(lower, "ready") || + strings.Contains(lower, "type a message") || + strings.Contains(lower, "press enter") { + return true + } + + return false +} + +// isShellPrompt returns true if the line looks like a common shell prompt. +// It matches lines whose last meaningful character is one of $, %, >, #, or ❯, +// but guards against false positives like "50%" or "line #3". +func isShellPrompt(line string) bool { + s := strings.TrimRight(line, " \t") + if s == "" { + return false + } + last, size := utf8.DecodeLastRuneInString(s) + switch last { + case '$', '❯', '»': + // These are unambiguous prompt characters. + return true + case '>': + // Reject "=>" (fat arrow), "->" (arrow), but allow bare ">" or ">>> ". + if len(s) >= 2 { + prev := s[len(s)-2] + if prev == '=' || prev == '-' { + return false + } } - if strings.Contains(trimmed, "auto-compact") || strings.Contains(trimmed, "auto-accept") { - return true + return true + case '%', '#': + // Reject "50%" or "line #3" — these chars are only prompts when NOT + // preceded by a digit. + before := s[:len(s)-size] + before = strings.TrimRight(before, " \t") + if before == "" { + return true // bare "%" or "#" + } + prevChar := before[len(before)-1] + if prevChar >= '0' && prevChar <= '9' { + return false } + return true } return false }