From ab404261ddd746a530d2b12d8fabdbd33b3ac0b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Sun, 25 Jan 2026 18:37:37 +0000 Subject: [PATCH] feat: cli passthrough state transitions (#95) --- .../internal/agent/lifecycle/event_types.go | 1 + .../internal/agent/lifecycle/events.go | 1 + .../internal/agent/lifecycle/manager.go | 36 ++- .../internal/agent/registry/agents.json | 35 +-- .../server/process/claude_code_detector.go | 173 ----------- .../process/claude_code_detector_test.go | 271 ------------------ .../agentctl/server/process/codex_detector.go | 164 ----------- .../server/process/codex_detector_test.go | 231 --------------- .../agentctl/server/process/idle_detector.go | 35 +++ .../server/process/interactive_runner.go | 10 +- .../gateway/websocket/terminal_handler.go | 27 ++ .../internal/orchestrator/event_handlers.go | 29 ++ apps/backend/internal/orchestrator/service.go | 2 + .../internal/orchestrator/watcher/watcher.go | 3 + 14 files changed, 144 insertions(+), 874 deletions(-) delete mode 100644 apps/backend/internal/agentctl/server/process/claude_code_detector.go delete mode 100644 apps/backend/internal/agentctl/server/process/claude_code_detector_test.go delete mode 100644 apps/backend/internal/agentctl/server/process/codex_detector.go delete mode 100644 apps/backend/internal/agentctl/server/process/codex_detector_test.go create mode 100644 apps/backend/internal/agentctl/server/process/idle_detector.go diff --git a/apps/backend/internal/agent/lifecycle/event_types.go b/apps/backend/internal/agent/lifecycle/event_types.go index 425499de6b..d722e91636 100644 --- a/apps/backend/internal/agent/lifecycle/event_types.go +++ b/apps/backend/internal/agent/lifecycle/event_types.go @@ -7,6 +7,7 @@ import "time" type AgentEventPayload struct { InstanceID string `json:"instance_id"` TaskID string `json:"task_id"` + SessionID string `json:"session_id,omitempty"` AgentProfileID string `json:"agent_profile_id"` ContainerID string `json:"container_id,omitempty"` Status string `json:"status"` diff --git a/apps/backend/internal/agent/lifecycle/events.go b/apps/backend/internal/agent/lifecycle/events.go index 59d80ad1a5..078a481192 100644 --- a/apps/backend/internal/agent/lifecycle/events.go +++ b/apps/backend/internal/agent/lifecycle/events.go @@ -37,6 +37,7 @@ func (p *EventPublisher) PublishAgentEvent(ctx context.Context, eventType string payload := AgentEventPayload{ InstanceID: execution.ID, TaskID: execution.TaskID, + SessionID: execution.SessionID, AgentProfileID: execution.AgentProfileID, ContainerID: execution.ContainerID, Status: string(execution.Status), diff --git a/apps/backend/internal/agent/lifecycle/manager.go b/apps/backend/internal/agent/lifecycle/manager.go index 37cef9de77..8d1149385e 100644 --- a/apps/backend/internal/agent/lifecycle/manager.go +++ b/apps/backend/internal/agent/lifecycle/manager.go @@ -1662,6 +1662,11 @@ func (m *Manager) MarkReady(executionID string) error { return fmt.Errorf("execution %q not found", executionID) } + // Skip if already ready (prevents duplicate events) + if execution.Status == v1.AgentStatusReady { + return nil + } + m.executionStore.UpdateStatus(executionID, v1.AgentStatusReady) m.logger.Info("execution ready for follow-up prompts", @@ -1929,8 +1934,32 @@ func (m *Manager) RespondToPermissionBySessionID(sessionID, pendingID, optionID return m.RespondToPermission(execution.ID, pendingID, optionID, cancelled) } +// MarkPassthroughRunning marks a passthrough execution as running when user submits input. +// This is called when Enter key is detected in the terminal handler. +// It updates the execution status and publishes an AgentRunning event. +func (m *Manager) MarkPassthroughRunning(sessionID string) error { + execution, exists := m.executionStore.GetBySessionID(sessionID) + if !exists { + return fmt.Errorf("no agent execution found for session: %s", sessionID) + } + + if execution.PassthroughProcessID == "" { + return fmt.Errorf("session %s is not in passthrough mode", sessionID) + } + + // Only publish if not already running (prevents duplicate events) + if execution.Status != v1.AgentStatusRunning { + m.executionStore.UpdateStatus(execution.ID, v1.AgentStatusRunning) + m.eventPublisher.PublishAgentEvent(context.Background(), events.AgentRunning, execution) + } + + return nil +} + // WritePassthroughStdin writes data to the agent process stdin in passthrough mode. // Returns an error if the session is not in passthrough mode or if writing fails. +// Note: For terminal handler input, use MarkPassthroughRunning directly since +// the terminal handler writes to PTY directly for performance. func (m *Manager) WritePassthroughStdin(ctx context.Context, sessionID string, data string) error { execution, exists := m.executionStore.GetBySessionID(sessionID) if !exists { @@ -1947,7 +1976,12 @@ func (m *Manager) WritePassthroughStdin(ctx context.Context, sessionID string, d return fmt.Errorf("interactive runner not available") } - return interactiveRunner.WriteStdin(execution.PassthroughProcessID, data) + // Write to stdin + if err := interactiveRunner.WriteStdin(execution.PassthroughProcessID, data); err != nil { + return err + } + + return nil } // ResizePassthroughPTY resizes the PTY for a passthrough process. diff --git a/apps/backend/internal/agent/registry/agents.json b/apps/backend/internal/agent/registry/agents.json index 407fcd63c6..d6c1a7691f 100644 --- a/apps/backend/internal/agent/registry/agents.json +++ b/apps/backend/internal/agent/registry/agents.json @@ -26,8 +26,7 @@ "@augmentcode/auggie" ], "model_flag": "--model {model}", - "prompt_pattern": "^>\\s*$", - "idle_timeout_ms": 5000, + "idle_timeout_ms": 3000, "buffer_max_bytes": 2097152 }, "working_dir": "/workspace", @@ -189,12 +188,8 @@ "--full-auto" ], "model_flag": "--model {model}", - "prompt_pattern": "^>\\s*$", - "idle_timeout_ms": 5000, - "buffer_max_bytes": 2097152, - "status_detector": "codex", - "check_interval_ms": 100, - "stability_window_ms": 1000 + "idle_timeout_ms": 3000, + "buffer_max_bytes": 2097152 }, "working_dir": "/workspace", "required_env": [], @@ -340,12 +335,8 @@ "--verbose" ], "model_flag": "--model {model}", - "prompt_pattern": "^>\\s*$", - "idle_timeout_ms": 5000, - "buffer_max_bytes": 2097152, - "status_detector": "claude_code", - "check_interval_ms": 100, - "stability_window_ms": 0 + "idle_timeout_ms": 3000, + "buffer_max_bytes": 2097152 }, "working_dir": "{workspace}", "required_env": [ @@ -519,12 +510,8 @@ ], "model_flag": "--model {model}", "prompt_flag": "--prompt-interactive {prompt}", - "prompt_pattern": "^>\\s*$", - "idle_timeout_ms": 5000, - "buffer_max_bytes": 2097152, - "status_detector": "", - "check_interval_ms": 100, - "stability_window_ms": 0 + "idle_timeout_ms": 3000, + "buffer_max_bytes": 2097152 }, "permission_settings": { "auto_approve": { @@ -613,12 +600,8 @@ ], "model_flag": "--model {model}", "prompt_flag": "--prompt {prompt}", - "prompt_pattern": "^>\\s*$", - "idle_timeout_ms": 5000, - "buffer_max_bytes": 2097152, - "status_detector": "", - "check_interval_ms": 100, - "stability_window_ms": 0 + "idle_timeout_ms": 3000, + "buffer_max_bytes": 2097152 }, "working_dir": "{workspace}", "required_env": [], diff --git a/apps/backend/internal/agentctl/server/process/claude_code_detector.go b/apps/backend/internal/agentctl/server/process/claude_code_detector.go deleted file mode 100644 index e9b5cd50cf..0000000000 --- a/apps/backend/internal/agentctl/server/process/claude_code_detector.go +++ /dev/null @@ -1,173 +0,0 @@ -// Package process provides background process execution and output streaming for agentctl. -// -// ClaudeCodeDetector implements agent-specific TUI pattern matching for Claude Code CLI -// to detect states: Working, WaitingApproval, WaitingInput. - -package process - -import ( - "regexp" - "strings" - - "github.com/tuzig/vt10x" -) - -// ClaudeCodeDetector detects Claude Code CLI states by analyzing the terminal TUI. -type ClaudeCodeDetector struct{} - -// NewClaudeCodeDetector creates a new Claude Code detector. -func NewClaudeCodeDetector() *ClaudeCodeDetector { - return &ClaudeCodeDetector{} -} - -var ( - // Working State Detection - // Pattern: Task line with symbol + ellipsis + interrupt hint - // Example: "✻ Billowing… (ctrl+c to interrupt)" - // Example: "✻ Reading files... (esc to interrupt)" - workingTaskPattern = regexp.MustCompile( - `^\s*[✻✽✶∴·○◆▪▫□■☐☑☒★☆✓✔✗✘⚬⚫⚪⬤◯▸▹►▻◂◃◄◅✢*]\s+.+[…\.]{2,}\s*\((esc|ctrl\+c)\s+to\s+interrupt`, - ) - - // Tip/Hint line pattern (indicates waiting for input) - // Example: "⎿ Tip: Press Enter to continue" - tipPattern = regexp.MustCompile(`^[\s\x{00a0}]*⎿[\s\x{00a0}]+(?:Tip|Next|Hint):`) - - // Separator line pattern (input box boundaries) - // A line that's mostly horizontal box-drawing characters - separatorPattern = regexp.MustCompile(`^[─━═┄┅┈┉\-]{10,}$`) - - // Approval patterns - // Pattern: Navigation prompt (menu selection) - enterToSelectPattern = regexp.MustCompile(`(?i)enter\s+to\s+select`) - - // Pattern: Answer submission - submitAnswersPattern = regexp.MustCompile(`(?i)ready\s+to\s+submit\s+your\s+answers`) - - // Pattern: File operations - doYouWantToPattern = regexp.MustCompile(`(?i)do\s+you\s+want\s+to\s+`) - - // Pattern: Procedural confirmation - doYouWantToProceedPattern = regexp.MustCompile(`(?i)do\s+you\s+want\s+to\s+proceed`) - - // Pattern: Selection arrow for menu - selectionArrowPattern = regexp.MustCompile(`^[\s]*[❯>]\s*\d+\.`) - - // Pattern: Yes/No confirmation prompt - yesNoPattern = regexp.MustCompile(`(?i)\[?y/?n\]?`) -) - -// DetectState examines the visible terminal content and returns the detected state. -func (d *ClaudeCodeDetector) DetectState(lines []string, glyphs [][]vt10x.Glyph) AgentState { - // Try approval detection first (highest priority) - if state := d.detectApproval(lines); state != StateUnknown { - return state - } - - // Then working/waiting detection - return d.detectWorkingAndWaiting(lines, glyphs) -} - -// ShouldAcceptStateChange determines if a state transition should be accepted. -// Claude Code doesn't need a stability window. -func (d *ClaudeCodeDetector) ShouldAcceptStateChange(currentState, newState AgentState) bool { - return true -} - -// detectWorkingAndWaiting detects working and waiting for input states. -func (d *ClaudeCodeDetector) detectWorkingAndWaiting(lines []string, glyphs [][]vt10x.Glyph) AgentState { - // Find input box boundaries (lines of horizontal separator characters) - separatorIndices := d.findSeparatorLines(lines) - - // Check for working task pattern in the visible lines - for _, line := range lines { - line = strings.TrimRight(line, " \t") - if workingTaskPattern.MatchString(line) { - return StateWorking - } - } - - // If we have an input box, look for tip/hint line (indicates waiting for input) - if len(separatorIndices) >= 2 { - inputBoxStart := separatorIndices[len(separatorIndices)-2] - inputBoxEnd := separatorIndices[len(separatorIndices)-1] - - for i := inputBoxEnd - 1; i >= inputBoxStart; i-- { - if i >= 0 && i < len(lines) { - if tipPattern.MatchString(lines[i]) { - return StateWaitingInput - } - } - } - } - - // Also check for tip pattern anywhere in the visible area - for _, line := range lines { - if tipPattern.MatchString(line) { - return StateWaitingInput - } - } - - return StateUnknown -} - -// detectApproval detects approval prompts. -func (d *ClaudeCodeDetector) detectApproval(lines []string) AgentState { - // Search from bottom up since prompts are typically at the bottom - for i := len(lines) - 1; i >= 0; i-- { - line := strings.TrimRight(lines[i], " \t") - - // Check for various approval patterns - if enterToSelectPattern.MatchString(line) { - return StateWaitingApproval - } - - if doYouWantToProceedPattern.MatchString(line) { - return StateWaitingApproval - } - - if submitAnswersPattern.MatchString(line) { - return StateWaitingApproval - } - - // "Do you want to [create/delete/etc]" - if doYouWantToPattern.MatchString(line) { - return StateWaitingApproval - } - - // Selection arrow with confirmation context - if selectionArrowPattern.MatchString(line) { - // Check if there's a confirmation prompt nearby - for j := i + 1; j < len(lines) && j < i+5; j++ { - nearbyLine := strings.TrimRight(lines[j], " \t") - if strings.Contains(strings.ToLower(nearbyLine), "confirm") || - strings.Contains(strings.ToLower(nearbyLine), "enter to") { - return StateWaitingApproval - } - } - } - - // Yes/No prompt - if yesNoPattern.MatchString(line) && (strings.Contains(strings.ToLower(line), "?") || - strings.Contains(strings.ToLower(line), "allow") || - strings.Contains(strings.ToLower(line), "approve")) { - return StateWaitingApproval - } - } - - return StateUnknown -} - -// findSeparatorLines finds lines that are horizontal separators (input box boundaries). -func (d *ClaudeCodeDetector) findSeparatorLines(lines []string) []int { - var indices []int - - for i, line := range lines { - trimmed := strings.TrimSpace(line) - if len(trimmed) >= 10 && separatorPattern.MatchString(trimmed) { - indices = append(indices, i) - } - } - - return indices -} diff --git a/apps/backend/internal/agentctl/server/process/claude_code_detector_test.go b/apps/backend/internal/agentctl/server/process/claude_code_detector_test.go deleted file mode 100644 index b4153f86f4..0000000000 --- a/apps/backend/internal/agentctl/server/process/claude_code_detector_test.go +++ /dev/null @@ -1,271 +0,0 @@ -package process - -import ( - "testing" - - "github.com/tuzig/vt10x" -) - -func TestClaudeCodeDetector_DetectState_Working(t *testing.T) { - detector := NewClaudeCodeDetector() - - tests := []struct { - name string - lines []string - expected AgentState - }{ - { - name: "working with ellipsis and esc interrupt", - lines: []string{ - "", - "✻ Billowing... (esc to interrupt)", - "", - }, - expected: StateWorking, - }, - { - name: "working with dots and ctrl+c interrupt", - lines: []string{ - "", - "✻ Reading files... (ctrl+c to interrupt)", - "", - }, - expected: StateWorking, - }, - { - name: "working with different bullet symbol", - lines: []string{ - "* Processing request... (esc to interrupt)", - }, - expected: StateWorking, - }, - { - name: "working with star symbol", - lines: []string{ - " ★ Analyzing code... (esc to interrupt)", - }, - expected: StateWorking, - }, - { - name: "not working - no interrupt hint", - lines: []string{ - "✻ Billowing…", - }, - expected: StateUnknown, - }, - { - name: "not working - incomplete pattern", - lines: []string{ - "Some random text", - }, - expected: StateUnknown, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Create empty glyphs slice matching lines - glyphs := make([][]vt10x.Glyph, len(tt.lines)) - for i, line := range tt.lines { - glyphs[i] = make([]vt10x.Glyph, len(line)) - } - - result := detector.DetectState(tt.lines, glyphs) - if result != tt.expected { - t.Errorf("DetectState() = %v, want %v", result, tt.expected) - } - }) - } -} - -func TestClaudeCodeDetector_DetectState_WaitingInput(t *testing.T) { - detector := NewClaudeCodeDetector() - - tests := []struct { - name string - lines []string - expected AgentState - }{ - { - name: "tip line indicates waiting for input", - lines: []string{ - "Some output", - "⎿ Tip: Press Enter to continue", - "", - }, - expected: StateWaitingInput, - }, - { - name: "tip with non-breaking spaces", - lines: []string{ - "\u00a0\u00a0⎿\u00a0Tip: Use arrow keys", - }, - expected: StateWaitingInput, - }, - { - name: "hint line", - lines: []string{ - "⎿ Hint: Type your message", - }, - expected: StateWaitingInput, - }, - { - name: "next line", - lines: []string{ - "⎿ Next: Choose an option", - }, - expected: StateWaitingInput, - }, - { - name: "input box with tip inside", - lines: []string{ - "────────────────────", - "", - "⎿ Tip: Enter to send", - "────────────────────", - }, - expected: StateWaitingInput, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - glyphs := make([][]vt10x.Glyph, len(tt.lines)) - for i, line := range tt.lines { - glyphs[i] = make([]vt10x.Glyph, len(line)) - } - - result := detector.DetectState(tt.lines, glyphs) - if result != tt.expected { - t.Errorf("DetectState() = %v, want %v", result, tt.expected) - } - }) - } -} - -func TestClaudeCodeDetector_DetectState_WaitingApproval(t *testing.T) { - detector := NewClaudeCodeDetector() - - tests := []struct { - name string - lines []string - expected AgentState - }{ - { - name: "enter to select prompt", - lines: []string{ - "Options:", - "1. Option A", - "2. Option B", - "Press enter to select", - }, - expected: StateWaitingApproval, - }, - { - name: "do you want to proceed", - lines: []string{ - "Changes detected", - "Do you want to proceed?", - }, - expected: StateWaitingApproval, - }, - { - name: "do you want to create file", - lines: []string{ - "Do you want to create this file?", - }, - expected: StateWaitingApproval, - }, - { - name: "submit answers prompt", - lines: []string{ - "Ready to submit your answers?", - }, - expected: StateWaitingApproval, - }, - { - name: "yes/no with allow", - lines: []string{ - "Allow access to file? [y/n]", - }, - expected: StateWaitingApproval, - }, - { - name: "yes/no with approve", - lines: []string{ - "Approve changes? [Y/N]", - }, - expected: StateWaitingApproval, - }, - { - name: "selection arrow with confirm nearby", - lines: []string{ - "❯ 1. First option", - " 2. Second option", - "Press Enter to confirm", - }, - expected: StateWaitingApproval, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - glyphs := make([][]vt10x.Glyph, len(tt.lines)) - for i, line := range tt.lines { - glyphs[i] = make([]vt10x.Glyph, len(line)) - } - - result := detector.DetectState(tt.lines, glyphs) - if result != tt.expected { - t.Errorf("DetectState() = %v, want %v", result, tt.expected) - } - }) - } -} - -func TestClaudeCodeDetector_ShouldAcceptStateChange(t *testing.T) { - detector := NewClaudeCodeDetector() - - // Claude Code detector always accepts state changes (no stability window) - tests := []struct { - current AgentState - new AgentState - expected bool - }{ - {StateUnknown, StateWorking, true}, - {StateWorking, StateWaitingInput, true}, - {StateWorking, StateUnknown, true}, - {StateWaitingApproval, StateWorking, true}, - } - - for _, tt := range tests { - result := detector.ShouldAcceptStateChange(tt.current, tt.new) - if result != tt.expected { - t.Errorf("ShouldAcceptStateChange(%v, %v) = %v, want %v", - tt.current, tt.new, result, tt.expected) - } - } -} - -func TestClaudeCodeDetector_FindSeparatorLines(t *testing.T) { - detector := NewClaudeCodeDetector() - - lines := []string{ - "Some text", - "──────────────────────────", - "Input area", - "──────────────────────────", - "More text", - } - - indices := detector.findSeparatorLines(lines) - - if len(indices) != 2 { - t.Errorf("findSeparatorLines() found %d separators, want 2", len(indices)) - } - if len(indices) >= 2 { - if indices[0] != 1 || indices[1] != 3 { - t.Errorf("findSeparatorLines() = %v, want [1, 3]", indices) - } - } -} diff --git a/apps/backend/internal/agentctl/server/process/codex_detector.go b/apps/backend/internal/agentctl/server/process/codex_detector.go deleted file mode 100644 index 3ec71dc73c..0000000000 --- a/apps/backend/internal/agentctl/server/process/codex_detector.go +++ /dev/null @@ -1,164 +0,0 @@ -// Package process provides background process execution and output streaming for agentctl. -// -// CodexDetector implements agent-specific TUI pattern matching for OpenAI Codex CLI -// to detect states: Working, WaitingApproval, WaitingInput. -// -// Codex requires a stability window because its TUI has intermittent output during -// working state - we need to ensure we don't falsely exit working state. - -package process - -import ( - "regexp" - "strings" - "sync" - "time" - - "github.com/tuzig/vt10x" -) - -// CodexDetector detects Codex CLI states by analyzing the terminal TUI. -type CodexDetector struct { - mu sync.Mutex - lastWorkingDetected time.Time -} - -// NewCodexDetector creates a new Codex detector. -func NewCodexDetector() *CodexDetector { - return &CodexDetector{} -} - -const ( - // codexMinWorkingExitInterval prevents false exits from working state - // Codex has intermittent output during processing, so we require 1 second - // of stability before accepting a transition out of working state. - codexMinWorkingExitInterval = 1000 * time.Millisecond -) - -var ( - // Working State Detection - // Pattern: Bullet + action + timer - // Example: "• Working (65s • esc to interrupt)" - // Example: "◦ Processing request (2m 30s • esc to interrupt)" - codexWorkingPattern = regexp.MustCompile( - `^[•◦]\s*.+\(?(\d+h\s+)?(\d+m\s+)?\d+s\s*[•·]\s*(esc|ctrl\+c)\s+to\s+interrup(t)?\)?`, - ) - - // Worked Line Pattern (task complete, waiting for input) - // Example: "─ Worked for 2m 30s─────────" - codexWorkedPattern = regexp.MustCompile(`^─\s*Worked\s+for\s+.+─+$`) - - // Starting MCP servers noise (should be ignored during detection) - mcpServersPattern = regexp.MustCompile(`(?i)starting\s+mcp\s+servers?`) - - // Codex approval patterns - // Selection arrow with numbered option - codexSelectionPattern = regexp.MustCompile(`^[›❯]\s*\d+\.\s+`) - - // Confirmation prompt - codexConfirmPattern = regexp.MustCompile(`(?i)press\s+enter\s+to\s+confirm`) - - // Cancel prompt - codexCancelPattern = regexp.MustCompile(`(?i)esc\s+to\s+cancel`) - - // Approval required prompt - codexApprovalPattern = regexp.MustCompile(`(?i)(approve|allow|confirm|proceed)\s*\?`) -) - -// DetectState examines the visible terminal content and returns the detected state. -func (d *CodexDetector) DetectState(lines []string, glyphs [][]vt10x.Glyph) AgentState { - // Check for approval prompts first - if state := d.detectApproval(lines); state != StateUnknown { - return state - } - - // Check for working and waiting states - for _, line := range lines { - line = strings.TrimRight(line, " \t") - - // Skip MCP servers noise - if mcpServersPattern.MatchString(line) { - continue - } - - // Check for working pattern - if codexWorkingPattern.MatchString(line) { - d.mu.Lock() - d.lastWorkingDetected = time.Now() - d.mu.Unlock() - return StateWorking - } - - // Check for worked pattern (completed, waiting for input) - if codexWorkedPattern.MatchString(line) { - return StateWaitingInput - } - } - - return StateUnknown -} - -// ShouldAcceptStateChange determines if a state transition should be accepted. -// Codex needs a stability window when exiting working state to prevent false transitions. -func (d *CodexDetector) ShouldAcceptStateChange(currentState, newState AgentState) bool { - // If exiting working state, require stability window - if currentState == StateWorking && newState != StateWorking { - d.mu.Lock() - lastWorking := d.lastWorkingDetected - d.mu.Unlock() - - if time.Since(lastWorking) < codexMinWorkingExitInterval { - // Don't accept - not enough time has passed since last working detection - return false - } - } - - return true -} - -// detectApproval detects approval prompts in Codex TUI. -func (d *CodexDetector) detectApproval(lines []string) AgentState { - hasSelectionArrow := false - hasConfirmPrompt := false - - for i, line := range lines { - line = strings.TrimRight(line, " \t") - - // Check for selection arrow - if codexSelectionPattern.MatchString(line) { - hasSelectionArrow = true - - // Check nearby lines for confirmation prompt - for j := i + 1; j < len(lines) && j < i+5; j++ { - nearbyLine := strings.TrimRight(lines[j], " \t") - if codexConfirmPattern.MatchString(nearbyLine) || - codexCancelPattern.MatchString(nearbyLine) { - hasConfirmPrompt = true - break - } - } - - if hasConfirmPrompt { - return StateWaitingApproval - } - } - - // Direct approval pattern - if codexApprovalPattern.MatchString(line) { - return StateWaitingApproval - } - - // Standalone confirm/cancel pattern - if codexConfirmPattern.MatchString(line) && codexCancelPattern.MatchString(line) { - return StateWaitingApproval - } - } - - // If we found a selection arrow without explicit confirmation, - // still consider it as potentially needing approval - if hasSelectionArrow { - return StateWaitingApproval - } - - return StateUnknown -} diff --git a/apps/backend/internal/agentctl/server/process/codex_detector_test.go b/apps/backend/internal/agentctl/server/process/codex_detector_test.go deleted file mode 100644 index 246c4fd5dd..0000000000 --- a/apps/backend/internal/agentctl/server/process/codex_detector_test.go +++ /dev/null @@ -1,231 +0,0 @@ -package process - -import ( - "testing" - "time" - - "github.com/tuzig/vt10x" -) - -func TestCodexDetector_DetectState_Working(t *testing.T) { - detector := NewCodexDetector() - - tests := []struct { - name string - lines []string - expected AgentState - }{ - { - name: "working with seconds timer", - lines: []string{ - "• Working (65s • esc to interrupt)", - }, - expected: StateWorking, - }, - { - name: "working with minutes and seconds", - lines: []string{ - "• Processing request (2m 30s • esc to interrupt)", - }, - expected: StateWorking, - }, - { - name: "working with hollow bullet", - lines: []string{ - "◦ Analyzing (5s • esc to interrupt)", - }, - expected: StateWorking, - }, - { - name: "working with hours minutes seconds", - lines: []string{ - "• Long task (1h 30m 45s • esc to interrupt)", - }, - expected: StateWorking, - }, - { - name: "working with ctrl+c", - lines: []string{ - "• Working (10s • ctrl+c to interrupt)", - }, - expected: StateWorking, - }, - { - name: "not working - MCP servers noise", - lines: []string{ - "Starting MCP servers...", - "• Working (5s • esc to interrupt)", - }, - expected: StateWorking, // Should still detect working despite MCP noise - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - glyphs := make([][]vt10x.Glyph, len(tt.lines)) - for i, line := range tt.lines { - glyphs[i] = make([]vt10x.Glyph, len(line)) - } - - result := detector.DetectState(tt.lines, glyphs) - if result != tt.expected { - t.Errorf("DetectState() = %v, want %v", result, tt.expected) - } - }) - } -} - -func TestCodexDetector_DetectState_WaitingInput(t *testing.T) { - detector := NewCodexDetector() - - tests := []struct { - name string - lines []string - expected AgentState - }{ - { - name: "worked line indicates completion", - lines: []string{ - "─ Worked for 2m 30s────────────────", - }, - expected: StateWaitingInput, - }, - { - name: "worked line with different duration", - lines: []string{ - "─ Worked for 45s─────────────────────", - }, - expected: StateWaitingInput, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - glyphs := make([][]vt10x.Glyph, len(tt.lines)) - for i, line := range tt.lines { - glyphs[i] = make([]vt10x.Glyph, len(line)) - } - - result := detector.DetectState(tt.lines, glyphs) - if result != tt.expected { - t.Errorf("DetectState() = %v, want %v", result, tt.expected) - } - }) - } -} - -func TestCodexDetector_DetectState_WaitingApproval(t *testing.T) { - detector := NewCodexDetector() - - tests := []struct { - name string - lines []string - expected AgentState - }{ - { - name: "selection with confirm prompt", - lines: []string{ - "› 1. Option one", - " 2. Option two", - "Press enter to confirm", - }, - expected: StateWaitingApproval, - }, - { - name: "selection with cancel prompt", - lines: []string{ - "❯ 1. First choice", - "esc to cancel", - }, - expected: StateWaitingApproval, - }, - { - name: "approval question with question mark", - lines: []string{ - "Do you approve?", - }, - expected: StateWaitingApproval, - }, - { - name: "confirm question with question mark", - lines: []string{ - "Please confirm?", - }, - expected: StateWaitingApproval, - }, - { - name: "confirm and cancel on same line", - lines: []string{ - "press enter to confirm, esc to cancel", - }, - expected: StateWaitingApproval, - }, - { - name: "standalone selection arrow", - lines: []string{ - "› 1. Some option", - }, - expected: StateWaitingApproval, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - glyphs := make([][]vt10x.Glyph, len(tt.lines)) - for i, line := range tt.lines { - glyphs[i] = make([]vt10x.Glyph, len(line)) - } - - result := detector.DetectState(tt.lines, glyphs) - if result != tt.expected { - t.Errorf("DetectState() = %v, want %v", result, tt.expected) - } - }) - } -} - -func TestCodexDetector_ShouldAcceptStateChange_StabilityWindow(t *testing.T) { - detector := NewCodexDetector() - - // Set last working time to now - detector.mu.Lock() - detector.lastWorkingDetected = time.Now() - detector.mu.Unlock() - - // Exiting working state immediately should be rejected - if detector.ShouldAcceptStateChange(StateWorking, StateWaitingInput) { - t.Error("ShouldAcceptStateChange should reject immediate exit from working state") - } - - // Wait for stability window - time.Sleep(codexMinWorkingExitInterval + 50*time.Millisecond) - - // Now it should be accepted - if !detector.ShouldAcceptStateChange(StateWorking, StateWaitingInput) { - t.Error("ShouldAcceptStateChange should accept exit from working state after stability window") - } -} - -func TestCodexDetector_ShouldAcceptStateChange_NonWorkingTransitions(t *testing.T) { - detector := NewCodexDetector() - - // Non-working state transitions should always be accepted - tests := []struct { - current AgentState - new AgentState - expected bool - }{ - {StateUnknown, StateWorking, true}, - {StateUnknown, StateWaitingInput, true}, - {StateWaitingInput, StateWorking, true}, - {StateWaitingApproval, StateWaitingInput, true}, - } - - for _, tt := range tests { - result := detector.ShouldAcceptStateChange(tt.current, tt.new) - if result != tt.expected { - t.Errorf("ShouldAcceptStateChange(%v, %v) = %v, want %v", - tt.current, tt.new, result, tt.expected) - } - } -} diff --git a/apps/backend/internal/agentctl/server/process/idle_detector.go b/apps/backend/internal/agentctl/server/process/idle_detector.go new file mode 100644 index 0000000000..981acb74e8 --- /dev/null +++ b/apps/backend/internal/agentctl/server/process/idle_detector.go @@ -0,0 +1,35 @@ +// Package process provides background process execution and output streaming for agentctl. +// +// IdleDetector is a simple detector that uses idle timeout to detect when the agent +// has stopped producing output and is likely waiting for input. + +package process + +import ( + "github.com/tuzig/vt10x" +) + +// IdleDetector detects agent idle state based on lack of output. +// Unlike TUI-based detectors, this simply relies on the idle timeout mechanism +// in the InteractiveRunner to trigger turn completion. +type IdleDetector struct{} + +// NewIdleDetector creates a new idle-based detector. +func NewIdleDetector() *IdleDetector { + return &IdleDetector{} +} + +// DetectState always returns StateUnknown since idle detection is handled +// by the InteractiveRunner's idle timer, not by analyzing terminal content. +// When the idle timer fires, it triggers turn complete directly. +func (d *IdleDetector) DetectState(lines []string, glyphs [][]vt10x.Glyph) AgentState { + // Idle detection doesn't analyze terminal content. + // Turn completion is triggered by the idle timer in InteractiveRunner. + return StateUnknown +} + +// ShouldAcceptStateChange always returns true since state changes are handled +// by the idle timer mechanism. +func (d *IdleDetector) ShouldAcceptStateChange(currentState, newState AgentState) bool { + return true +} diff --git a/apps/backend/internal/agentctl/server/process/interactive_runner.go b/apps/backend/internal/agentctl/server/process/interactive_runner.go index c06fb98399..c822b98c58 100644 --- a/apps/backend/internal/agentctl/server/process/interactive_runner.go +++ b/apps/backend/internal/agentctl/server/process/interactive_runner.go @@ -158,15 +158,9 @@ func (r *InteractiveRunner) SetStateCallback(cb AgentStateCallback) { } // createStatusDetector creates the appropriate detector based on the detector type. +// The idle detector is the default - it relies on the idle timer mechanism for turn detection. func createStatusDetector(detectorType string) StatusDetector { - switch detectorType { - case "claude_code": - return NewClaudeCodeDetector() - case "codex": - return NewCodexDetector() - default: - return &NoOpDetector{} - } + return NewIdleDetector() } // Start creates an interactive process entry and defers PTY creation until first resize. diff --git a/apps/backend/internal/gateway/websocket/terminal_handler.go b/apps/backend/internal/gateway/websocket/terminal_handler.go index 5ef3d964c6..36c2803cba 100644 --- a/apps/backend/internal/gateway/websocket/terminal_handler.go +++ b/apps/backend/internal/gateway/websocket/terminal_handler.go @@ -314,6 +314,8 @@ func (h *TerminalHandler) runTerminalBridge( zap.Error(err)) return } + // Detect Enter key (user submitted input) - notify lifecycle manager + h.detectInputSubmission(sessionID, data) } else { // PTY not ready yet - try to set it up if h.setupPtyAccess(sessionID, processID, interactiveRunner, wsw, &ptyWriter) { @@ -331,6 +333,8 @@ func (h *TerminalHandler) runTerminalBridge( zap.Error(err)) return } + // Detect Enter key (user submitted input) - notify lifecycle manager + h.detectInputSubmission(sessionID, data) } else { h.logger.Debug("PTY not ready, dropping input", zap.String("session_id", sessionID), @@ -340,6 +344,29 @@ func (h *TerminalHandler) runTerminalBridge( } } +// detectInputSubmission checks if the input contains Enter key and notifies +// the lifecycle manager to update the execution state to Running. +func (h *TerminalHandler) detectInputSubmission(sessionID string, data []byte) { + // Check for Enter key (CR or LF) + hasEnter := false + for _, b := range data { + if b == '\n' || b == '\r' { + hasEnter = true + break + } + } + + if hasEnter { + // Notify lifecycle manager that user submitted input + if err := h.lifecycleMgr.MarkPassthroughRunning(sessionID); err != nil { + // Log at debug level - may fail if session ended or not in passthrough mode + h.logger.Debug("failed to mark passthrough as running", + zap.String("session_id", sessionID), + zap.Error(err)) + } + } +} + // setupPtyAccess attempts to get PTY writer and set up direct output. // Returns true if successful. func (h *TerminalHandler) setupPtyAccess( diff --git a/apps/backend/internal/orchestrator/event_handlers.go b/apps/backend/internal/orchestrator/event_handlers.go index bd293d876e..ecbf332d8d 100644 --- a/apps/backend/internal/orchestrator/event_handlers.go +++ b/apps/backend/internal/orchestrator/event_handlers.go @@ -75,6 +75,35 @@ func (s *Service) storeResumeToken(ctx context.Context, taskID, sessionID, acpSe zap.String("resume_token", acpSessionID)) } +// handleAgentRunning handles agent running events (user sent input in passthrough mode) +// This is called when the user sends input to the agent, indicating a new turn started. +func (s *Service) handleAgentRunning(ctx context.Context, data watcher.AgentEventData) { + if data.SessionID == "" { + s.logger.Warn("missing session_id for agent running event", + zap.String("task_id", data.TaskID)) + return + } + + // Move session to running and task to in progress + s.setSessionRunning(ctx, data.TaskID, data.SessionID) +} + +// handleAgentReady handles agent ready events (turn complete in passthrough mode) +// This is called when the agent finishes processing and is waiting for input. +func (s *Service) handleAgentReady(ctx context.Context, data watcher.AgentEventData) { + if data.SessionID == "" { + s.logger.Warn("missing session_id for agent ready event", + zap.String("task_id", data.TaskID)) + return + } + + // Complete the current turn + s.completeTurnForSession(ctx, data.SessionID) + + // Move session to waiting for input and task to review + s.setSessionWaitingForInput(ctx, data.TaskID, data.SessionID) +} + // handleAgentCompleted handles agent completion events func (s *Service) handleAgentCompleted(ctx context.Context, data watcher.AgentEventData) { s.logger.Info("handling agent completed", diff --git a/apps/backend/internal/orchestrator/service.go b/apps/backend/internal/orchestrator/service.go index 1a73f63cd9..855e4970ee 100644 --- a/apps/backend/internal/orchestrator/service.go +++ b/apps/backend/internal/orchestrator/service.go @@ -153,6 +153,8 @@ func NewService( // Create the watcher with event handlers that wire everything together handlers := watcher.EventHandlers{ + OnAgentRunning: s.handleAgentRunning, + OnAgentReady: s.handleAgentReady, OnAgentCompleted: s.handleAgentCompleted, OnAgentFailed: s.handleAgentFailed, OnAgentStreamEvent: s.handleAgentStreamEvent, diff --git a/apps/backend/internal/orchestrator/watcher/watcher.go b/apps/backend/internal/orchestrator/watcher/watcher.go index 3ed4778a1d..5490d39e0d 100644 --- a/apps/backend/internal/orchestrator/watcher/watcher.go +++ b/apps/backend/internal/orchestrator/watcher/watcher.go @@ -26,6 +26,7 @@ type TaskEventData struct { // AgentEventData contains data from agent events type AgentEventData struct { TaskID string `json:"task_id"` + SessionID string `json:"session_id"` AgentExecutionID string `json:"agent_execution_id"` AgentProfileID string `json:"agent_profile_id"` ExitCode *int `json:"exit_code,omitempty"` @@ -79,6 +80,7 @@ type EventHandlers struct { // Agent events OnAgentStarted func(ctx context.Context, data AgentEventData) + OnAgentRunning func(ctx context.Context, data AgentEventData) OnAgentReady func(ctx context.Context, data AgentEventData) OnAgentCompleted func(ctx context.Context, data AgentEventData) OnAgentFailed func(ctx context.Context, data AgentEventData) @@ -248,6 +250,7 @@ func (w *Watcher) subscribeToAgentEvents() error { handler func(ctx context.Context, data AgentEventData) }{ {events.AgentStarted, w.handlers.OnAgentStarted}, + {events.AgentRunning, w.handlers.OnAgentRunning}, {events.AgentReady, w.handlers.OnAgentReady}, {events.AgentCompleted, w.handlers.OnAgentCompleted}, {events.AgentFailed, w.handlers.OnAgentFailed},