feat: cli passthrough state transitions (#95)

This commit is contained in:
Carlos Florêncio
2026-01-25 18:37:37 +00:00
committed by GitHub
parent 66cda240e0
commit ab404261dd
14 changed files with 144 additions and 874 deletions
@@ -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},