Files
patterm/internal/app/idle.go
Harry Bayliss 2b9e1ed77c 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.
2026-05-15 09:49:59 +01:00

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)
}