Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 96 additions & 3 deletions internal/coordinator/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
})

Expand Down Expand Up @@ -797,7 +797,6 @@ func TestSSEGlobalEndpoint(t *testing.T) {
}
}


func TestClientDeleteAgent(t *testing.T) {
srv, cleanup := mustStartServer(t)
defer cleanup()
Expand Down Expand Up @@ -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)
}
})
}
}
124 changes: 115 additions & 9 deletions internal/coordinator/tmux.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"strings"
"sync"
"time"
"unicode/utf8"
)

const (
Expand Down Expand Up @@ -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
}
Expand Down