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.
226 lines
6.5 KiB
Go
226 lines
6.5 KiB
Go
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)
|
|
}
|