feat: cli passthrough state transitions (#95)
This commit is contained in:
@@ -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"`
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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": [],
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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},
|
||||
|
||||
Reference in New Issue
Block a user