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:
225
internal/app/idle.go
Normal file
225
internal/app/idle.go
Normal file
@@ -0,0 +1,225 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
|
||||
"github.com/hjbdev/patterm/internal/preset"
|
||||
)
|
||||
|
||||
// IdleState is the classifier's opinion about what a child is doing.
|
||||
// Inspired by Solo's five-state model. ERROR is a terminal state — set
|
||||
// when a child exits non-zero or matches an error-promoter regex —
|
||||
// while the other four reflect transient runtime state.
|
||||
type IdleState string
|
||||
|
||||
const (
|
||||
StateUnknown IdleState = ""
|
||||
StateIdle IdleState = "idle"
|
||||
StateWorking IdleState = "working"
|
||||
StateThinking IdleState = "thinking"
|
||||
StatePermission IdleState = "permission"
|
||||
StateError IdleState = "error"
|
||||
)
|
||||
|
||||
// IdleStrategy picks the primary signal used to decide idle vs working.
|
||||
// Promoter regexes can override this on top.
|
||||
type IdleStrategy string
|
||||
|
||||
const (
|
||||
StrategyOutputActivity IdleStrategy = "output_activity"
|
||||
StrategyOSCTitleStability IdleStrategy = "osc_title_stability"
|
||||
StrategyOSCTitleStatus IdleStrategy = "osc_title_status"
|
||||
)
|
||||
|
||||
// defaultIdleThresholdMS is used when a preset doesn't override it.
|
||||
const defaultIdleThresholdMS = 2000
|
||||
|
||||
// resolvedIdleDetection is the compiled, runtime-ready form of a
|
||||
// preset.IdleDetection block. Built once at child spawn and held
|
||||
// read-only by the classifier; regex patterns are compiled here so the
|
||||
// hot path doesn't pay for it.
|
||||
type resolvedIdleDetection struct {
|
||||
strategy IdleStrategy
|
||||
idleThresholdMS int64
|
||||
|
||||
titleStatusMap map[string]IdleState
|
||||
|
||||
permissionRegexes []*regexp.Regexp
|
||||
thinkingRegexes []*regexp.Regexp
|
||||
errorRegexes []*regexp.Regexp
|
||||
}
|
||||
|
||||
// resolveIdleDetection compiles a preset.IdleDetection (which may be
|
||||
// nil) into the runtime form. Unknown strategies fall back to
|
||||
// output_activity. Pattern compile errors are skipped silently — the
|
||||
// preset loader is responsible for surfacing them as warnings.
|
||||
func resolveIdleDetection(cfg *preset.IdleDetection) *resolvedIdleDetection {
|
||||
r := &resolvedIdleDetection{
|
||||
strategy: StrategyOutputActivity,
|
||||
idleThresholdMS: defaultIdleThresholdMS,
|
||||
}
|
||||
if cfg == nil {
|
||||
return r
|
||||
}
|
||||
switch IdleStrategy(cfg.Strategy) {
|
||||
case StrategyOSCTitleStability, StrategyOSCTitleStatus, StrategyOutputActivity:
|
||||
r.strategy = IdleStrategy(cfg.Strategy)
|
||||
}
|
||||
if cfg.IdleThresholdMS > 0 {
|
||||
r.idleThresholdMS = int64(cfg.IdleThresholdMS)
|
||||
}
|
||||
if len(cfg.TitleStatusMap) > 0 {
|
||||
r.titleStatusMap = make(map[string]IdleState, len(cfg.TitleStatusMap))
|
||||
for k, v := range cfg.TitleStatusMap {
|
||||
switch IdleState(v) {
|
||||
case StateIdle, StateWorking, StateThinking, StatePermission, StateError:
|
||||
r.titleStatusMap[k] = IdleState(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
r.permissionRegexes = compilePatterns(cfg.PermissionPatterns)
|
||||
r.thinkingRegexes = compilePatterns(cfg.ThinkingPatterns)
|
||||
r.errorRegexes = compilePatterns(cfg.ErrorPatterns)
|
||||
return r
|
||||
}
|
||||
|
||||
func compilePatterns(ps []string) []*regexp.Regexp {
|
||||
if len(ps) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]*regexp.Regexp, 0, len(ps))
|
||||
for _, p := range ps {
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
re, err := regexp.Compile(p)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, re)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// classify computes the IdleState from the inputs the classifier loop
|
||||
// has already gathered. Pure function so it's easy to unit-test.
|
||||
//
|
||||
// Resolution order:
|
||||
// 1. terminal: process exited non-zero → error (latched)
|
||||
// 2. error-promoter regex match in recent output → error
|
||||
// 3. permission-promoter regex match → permission
|
||||
// 4. thinking-promoter regex match → thinking
|
||||
// 5. strategy-specific base classification (idle vs working).
|
||||
//
|
||||
// inputs:
|
||||
// - exited: whether the child process has exited
|
||||
// - exitNonZero: whether the exit was non-zero (only meaningful when exited)
|
||||
// - idleMS: ms since the last PTY output
|
||||
// - titleIdleMS: ms since the last OSC title change (0 if no title yet)
|
||||
// - title: current OSC title
|
||||
// - tail: recent output bytes for regex matching
|
||||
func classify(cfg *resolvedIdleDetection, exited, exitNonZero bool, idleMS, titleIdleMS int64, title string, tail []byte) (IdleState, string) {
|
||||
if exited {
|
||||
if exitNonZero {
|
||||
return StateError, "process exited non-zero"
|
||||
}
|
||||
return StateIdle, "process exited cleanly"
|
||||
}
|
||||
if cfg == nil {
|
||||
cfg = &resolvedIdleDetection{strategy: StrategyOutputActivity, idleThresholdMS: defaultIdleThresholdMS}
|
||||
}
|
||||
if len(tail) > 0 {
|
||||
if matchAny(cfg.errorRegexes, tail) {
|
||||
return StateError, "error regex matched"
|
||||
}
|
||||
if matchAny(cfg.permissionRegexes, tail) {
|
||||
return StatePermission, "permission regex matched"
|
||||
}
|
||||
if matchAny(cfg.thinkingRegexes, tail) {
|
||||
return StateThinking, "thinking regex matched"
|
||||
}
|
||||
}
|
||||
threshold := cfg.idleThresholdMS
|
||||
switch cfg.strategy {
|
||||
case StrategyOSCTitleStatus:
|
||||
// First try the title-status map; if no match, fall back to
|
||||
// title-stability behaviour so we still produce idle/working.
|
||||
if s, ok := matchTitleStatus(cfg.titleStatusMap, title); ok {
|
||||
return s, "title status match"
|
||||
}
|
||||
fallthrough
|
||||
case StrategyOSCTitleStability:
|
||||
// If we've never seen a title, fall back to output activity so
|
||||
// we don't latch in idle while the child is clearly running.
|
||||
if titleIdleMS == 0 {
|
||||
return baseStateFromIdleMS(idleMS, threshold)
|
||||
}
|
||||
return baseStateFromIdleMS(titleIdleMS, threshold)
|
||||
default: // output_activity
|
||||
return baseStateFromIdleMS(idleMS, threshold)
|
||||
}
|
||||
}
|
||||
|
||||
func baseStateFromIdleMS(idleMS, threshold int64) (IdleState, string) {
|
||||
// idleMS == 0 means "no writes yet" (per Child.IdleMS) — treat as
|
||||
// not-idle so we don't classify a freshly-spawned child as idle.
|
||||
if idleMS == 0 {
|
||||
return StateWorking, "no activity yet"
|
||||
}
|
||||
if idleMS < threshold {
|
||||
return StateWorking, "recent activity"
|
||||
}
|
||||
return StateIdle, "quiet for threshold"
|
||||
}
|
||||
|
||||
func matchAny(res []*regexp.Regexp, tail []byte) bool {
|
||||
for _, re := range res {
|
||||
if re.Match(tail) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func matchTitleStatus(m map[string]IdleState, title string) (IdleState, bool) {
|
||||
if len(m) == 0 || title == "" {
|
||||
return StateUnknown, false
|
||||
}
|
||||
for k, v := range m {
|
||||
if k == "" {
|
||||
continue
|
||||
}
|
||||
if containsFold(title, k) {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
return StateUnknown, false
|
||||
}
|
||||
|
||||
// containsFold reports whether s contains sub, case-insensitively.
|
||||
// Cheap implementation suitable for short titles.
|
||||
func containsFold(s, sub string) bool {
|
||||
if len(sub) == 0 {
|
||||
return true
|
||||
}
|
||||
if len(sub) > len(s) {
|
||||
return false
|
||||
}
|
||||
ls, lsub := lower(s), lower(sub)
|
||||
for i := 0; i+len(lsub) <= len(ls); i++ {
|
||||
if ls[i:i+len(lsub)] == lsub {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func lower(s string) string {
|
||||
b := []byte(s)
|
||||
for i, c := range b {
|
||||
if c >= 'A' && c <= 'Z' {
|
||||
b[i] = c + 32
|
||||
}
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
Reference in New Issue
Block a user