257 lines
7.1 KiB
Go
257 lines
7.1 KiB
Go
// Package process provides background process execution and output streaming for agentctl.
|
|
//
|
|
// StatusTracker uses a virtual terminal emulator (vt10x) to parse agent TUI output
|
|
// and detect agent states (working, waiting for approval, waiting for input).
|
|
|
|
package process
|
|
|
|
import (
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/kandev/kandev/internal/common/logger"
|
|
"github.com/tuzig/vt10x"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// AgentState represents the detected state of an agent CLI.
|
|
type AgentState string
|
|
|
|
const (
|
|
StateUnknown AgentState = "unknown"
|
|
StateWorking AgentState = "working"
|
|
StateWaitingApproval AgentState = "waiting_approval"
|
|
StateWaitingInput AgentState = "waiting_input"
|
|
)
|
|
|
|
// StatusDetector is implemented by agent-specific detectors to detect state from terminal content.
|
|
type StatusDetector interface {
|
|
// DetectState examines the visible terminal content and returns the detected state.
|
|
// lines contains the visible text lines, glyphs contains the full glyph data with attributes.
|
|
DetectState(lines []string, glyphs [][]vt10x.Glyph) AgentState
|
|
|
|
// ShouldAcceptStateChange determines if a state transition should be accepted.
|
|
// This allows detectors to implement stability windows for debouncing.
|
|
ShouldAcceptStateChange(currentState, newState AgentState) bool
|
|
}
|
|
|
|
// StateChangeCallback is called when the detected state changes.
|
|
type StateChangeCallback func(sessionID string, state AgentState)
|
|
|
|
// StatusTrackerConfig contains configuration for the status tracker.
|
|
type StatusTrackerConfig struct {
|
|
Rows int // Terminal rows (default 24)
|
|
Cols int // Terminal columns (default 80)
|
|
CheckInterval time.Duration // How often to check state (default 100ms)
|
|
StabilityWindow time.Duration // Time state must be stable before reporting (default 0)
|
|
}
|
|
|
|
// DefaultStatusTrackerConfig returns the default configuration.
|
|
func DefaultStatusTrackerConfig() StatusTrackerConfig {
|
|
return StatusTrackerConfig{
|
|
Rows: 24,
|
|
Cols: 80,
|
|
CheckInterval: 100 * time.Millisecond,
|
|
StabilityWindow: 0,
|
|
}
|
|
}
|
|
|
|
// StatusTracker uses a virtual terminal emulator to track agent state.
|
|
type StatusTracker struct {
|
|
logger *logger.Logger
|
|
sessionID string
|
|
detector StatusDetector
|
|
callback StateChangeCallback
|
|
config StatusTrackerConfig
|
|
|
|
// vt10x terminal emulator
|
|
term vt10x.Terminal
|
|
|
|
// State tracking
|
|
mu sync.Mutex
|
|
lastState AgentState
|
|
lastStateChange time.Time
|
|
lastCheck time.Time
|
|
pendingState AgentState // State waiting for stability window
|
|
pendingStateTime time.Time // When pending state was first detected
|
|
}
|
|
|
|
// NewStatusTracker creates a new status tracker with the given detector.
|
|
func NewStatusTracker(
|
|
sessionID string,
|
|
detector StatusDetector,
|
|
callback StateChangeCallback,
|
|
config StatusTrackerConfig,
|
|
log *logger.Logger,
|
|
) *StatusTracker {
|
|
if config.Rows <= 0 {
|
|
config.Rows = 24
|
|
}
|
|
if config.Cols <= 0 {
|
|
config.Cols = 80
|
|
}
|
|
if config.CheckInterval <= 0 {
|
|
config.CheckInterval = 100 * time.Millisecond
|
|
}
|
|
|
|
// Create vt10x terminal emulator
|
|
term := vt10x.New(vt10x.WithSize(config.Cols, config.Rows))
|
|
|
|
return &StatusTracker{
|
|
logger: log.WithFields(zap.String("component", "status-tracker"), zap.String("session_id", sessionID)),
|
|
sessionID: sessionID,
|
|
detector: detector,
|
|
callback: callback,
|
|
config: config,
|
|
term: term,
|
|
lastState: StateUnknown,
|
|
}
|
|
}
|
|
|
|
// Write feeds data to the virtual terminal emulator.
|
|
// This should be called with all PTY output data.
|
|
func (t *StatusTracker) Write(data []byte) {
|
|
t.mu.Lock()
|
|
defer t.mu.Unlock()
|
|
|
|
// Feed data to the virtual terminal
|
|
_, _ = t.term.Write(data)
|
|
}
|
|
|
|
// Resize updates the virtual terminal size.
|
|
func (t *StatusTracker) Resize(cols, rows int) {
|
|
t.mu.Lock()
|
|
defer t.mu.Unlock()
|
|
|
|
t.term.Resize(cols, rows)
|
|
t.config.Cols = cols
|
|
t.config.Rows = rows
|
|
}
|
|
|
|
// ShouldCheck returns true if it's time to check the terminal state.
|
|
func (t *StatusTracker) ShouldCheck() bool {
|
|
t.mu.Lock()
|
|
defer t.mu.Unlock()
|
|
|
|
return time.Since(t.lastCheck) >= t.config.CheckInterval
|
|
}
|
|
|
|
// CheckAndUpdate checks the terminal state and emits a callback if the state changed.
|
|
// Returns the current state.
|
|
func (t *StatusTracker) CheckAndUpdate() AgentState {
|
|
t.mu.Lock()
|
|
defer t.mu.Unlock()
|
|
|
|
t.lastCheck = time.Now()
|
|
|
|
// Extract visible lines and glyphs from the terminal
|
|
lines, glyphs := t.extractTerminalContent()
|
|
|
|
// Detect state using the agent-specific detector
|
|
detectedState := t.detector.DetectState(lines, glyphs)
|
|
|
|
// Handle stability window if configured
|
|
if t.config.StabilityWindow > 0 {
|
|
return t.handleStabilityWindow(detectedState)
|
|
}
|
|
|
|
// No stability window - check if detector accepts the transition
|
|
if detectedState != t.lastState {
|
|
if t.detector.ShouldAcceptStateChange(t.lastState, detectedState) {
|
|
t.emitStateChange(detectedState)
|
|
}
|
|
}
|
|
|
|
return t.lastState
|
|
}
|
|
|
|
// CurrentState returns the current detected state.
|
|
func (t *StatusTracker) CurrentState() AgentState {
|
|
t.mu.Lock()
|
|
defer t.mu.Unlock()
|
|
return t.lastState
|
|
}
|
|
|
|
// extractTerminalContent extracts the visible lines and glyphs from the vt10x terminal.
|
|
func (t *StatusTracker) extractTerminalContent() ([]string, [][]vt10x.Glyph) {
|
|
rows := t.config.Rows
|
|
cols := t.config.Cols
|
|
|
|
lines := make([]string, rows)
|
|
glyphs := make([][]vt10x.Glyph, rows)
|
|
|
|
for row := 0; row < rows; row++ {
|
|
rowGlyphs := make([]vt10x.Glyph, cols)
|
|
var rowChars []rune
|
|
|
|
for col := 0; col < cols; col++ {
|
|
g := t.term.Cell(col, row)
|
|
rowGlyphs[col] = g
|
|
if g.Char == 0 {
|
|
rowChars = append(rowChars, ' ')
|
|
} else {
|
|
rowChars = append(rowChars, g.Char)
|
|
}
|
|
}
|
|
|
|
lines[row] = string(rowChars)
|
|
glyphs[row] = rowGlyphs
|
|
}
|
|
|
|
return lines, glyphs
|
|
}
|
|
|
|
// handleStabilityWindow implements debouncing for state changes.
|
|
func (t *StatusTracker) handleStabilityWindow(detectedState AgentState) AgentState {
|
|
now := time.Now()
|
|
|
|
if detectedState != t.pendingState {
|
|
// New state detected, start stability timer
|
|
t.pendingState = detectedState
|
|
t.pendingStateTime = now
|
|
return t.lastState
|
|
}
|
|
|
|
// Same state as pending - check if stable long enough
|
|
if now.Sub(t.pendingStateTime) >= t.config.StabilityWindow {
|
|
if t.pendingState != t.lastState {
|
|
if t.detector.ShouldAcceptStateChange(t.lastState, t.pendingState) {
|
|
t.emitStateChange(t.pendingState)
|
|
}
|
|
}
|
|
}
|
|
|
|
return t.lastState
|
|
}
|
|
|
|
// emitStateChange updates the state and calls the callback.
|
|
// Must be called with mutex held.
|
|
func (t *StatusTracker) emitStateChange(newState AgentState) {
|
|
oldState := t.lastState
|
|
t.lastState = newState
|
|
t.lastStateChange = time.Now()
|
|
|
|
t.logger.Debug("agent state changed",
|
|
zap.String("old_state", string(oldState)),
|
|
zap.String("new_state", string(newState)))
|
|
|
|
if t.callback != nil {
|
|
// Release lock before callback to prevent deadlocks
|
|
t.mu.Unlock()
|
|
t.callback(t.sessionID, newState)
|
|
t.mu.Lock()
|
|
}
|
|
}
|
|
|
|
// NoOpDetector is a detector that always returns StateUnknown.
|
|
// Used when no detector is configured.
|
|
type NoOpDetector struct{}
|
|
|
|
func (d *NoOpDetector) DetectState(lines []string, glyphs [][]vt10x.Glyph) AgentState {
|
|
return StateUnknown
|
|
}
|
|
|
|
func (d *NoOpDetector) ShouldAcceptStateChange(currentState, newState AgentState) bool {
|
|
return true
|
|
}
|