Add idle-state classifier and Solo-parity timer tools
Classifies every running child as idle/working/thinking/permission/error using one of three pluggable strategies (output_activity, osc_title_stability, osc_title_status) plus optional regex promoters applied to the tail of recent output. State and last-match reason are exposed via MCP on ProcessInfo and get_process_status. Per-preset configuration lives on a new preset.IdleDetection block with bundled defaults for the first-party claude/codex/opencode presets. OSC title plumbing is exposed as Emulator.Title(), polled from the session pump after each emulator write so title-change activity feeds into the classifier without an extra cgo callback. The MCP timer surface expands to match Solo: timer_set, timer_fire_when_idle_any/all, timer_cancel, timer_pause, timer_resume, timer_list. timer_wait is now a thin wrapper that shares the same manager so it shows up in timer_list while pending. Timer bodies are delivered to the owner process through the existing InjectAsOrchestrator path. Top-level (non-agent) callers can attach timers to a specific process via owner_process_id; omitting it grants universal cancel/pause/resume/list privileges. The sidebar gains a state glyph per process row and appends a nearest-timer indicator when one is pending or paused. Tests: idle_test.go covers the classify() pure function across the three strategies and regex promotion; timers_test.go covers the manager. Harness scenarios cover output_activity, osc_title_stability, osc_title_status, and regex promotion, plus timer_set delivery, cancel, pause/resume, idle_any-on-transition, idle_all-pending, and idle_all-already-satisfied. A new wait_until_mcp harness step type polls an MCP method until an assertion holds.
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Event struct {
|
||||
@@ -175,6 +176,41 @@ func runStep(s *Session, step Step, results map[string]json.RawMessage) error {
|
||||
return fmt.Errorf("no saved result %q", step.From)
|
||||
}
|
||||
return assertJSONValue(raw, step.Path, step.Equals, step.Contains, step.AllowSubstring)
|
||||
case "wait_until_mcp":
|
||||
// Poll an MCP method until the assertion at Path holds (or
|
||||
// Contains substring matches), or TimeoutMS elapses. Used by the
|
||||
// idle-detection scenarios to wait for a child's idle_state to
|
||||
// reach a target value without sprinkling sleeps.
|
||||
params, perr := resolveParams(step.Params, results)
|
||||
if perr != nil {
|
||||
return perr
|
||||
}
|
||||
deadline := time.Now().Add(timeoutMS(step.TimeoutMS))
|
||||
var lastRaw json.RawMessage
|
||||
var lastErr error
|
||||
for {
|
||||
raw, err := s.MCPCall(step.Method, params)
|
||||
if err == nil {
|
||||
if aerr := assertJSONValue(raw, step.Path, step.Equals, step.Contains, step.AllowSubstring); aerr == nil {
|
||||
if step.SaveAs != "" {
|
||||
results[step.SaveAs] = raw
|
||||
}
|
||||
return nil
|
||||
} else {
|
||||
lastErr = aerr
|
||||
lastRaw = raw
|
||||
}
|
||||
} else {
|
||||
lastErr = err
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
if lastErr != nil {
|
||||
return fmt.Errorf("wait_until_mcp timeout: %w (last response: %s)", lastErr, string(lastRaw))
|
||||
}
|
||||
return fmt.Errorf("wait_until_mcp timeout (no successful call)")
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("unknown step type %q", step.Type)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user