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:
2026-05-15 09:49:59 +01:00
parent 1af032472b
commit 2b9e1ed77c
31 changed files with 2318 additions and 38 deletions

View File

@@ -123,6 +123,19 @@ type Child struct {
portsMu sync.Mutex
ports []PortSighting
// Idle-detection state. idleState carries the classifier's current
// opinion (StateIdle / StateWorking / …). lastTitleNS is the wall
// time of the most recent OSC title change — separate from
// lastWriteNS so the osc_title_* strategies can ignore plain output
// churn. idleDetection is the compiled per-preset config, resolved
// once at spawn and immutable thereafter.
idleState atomic.Pointer[IdleState]
idleReason atomic.Pointer[string]
titleMu sync.RWMutex
title string
lastTitleNS atomic.Int64
idleDetection *resolvedIdleDetection
cleanupMu sync.Mutex
cleanupPaths []string
restarting atomic.Bool
@@ -330,6 +343,75 @@ func (c *Child) IdleMS() int64 {
return (time.Now().UnixNano() - last) / int64(time.Millisecond)
}
// TitleIdleMS returns how many milliseconds since the OSC window title
// last changed. 0 means "no title set yet".
func (c *Child) TitleIdleMS() int64 {
last := c.lastTitleNS.Load()
if last == 0 {
return 0
}
return (time.Now().UnixNano() - last) / int64(time.Millisecond)
}
// Title returns the most recent OSC 0/2 title.
func (c *Child) Title() string {
c.titleMu.RLock()
defer c.titleMu.RUnlock()
return c.title
}
// recordTitle updates the cached title and bumps lastTitleNS when it
// actually changes. Called from Session.pumpChild after each PTY chunk
// — cheap because most chunks don't carry an OSC sequence.
func (c *Child) recordTitle(newTitle string) {
c.titleMu.Lock()
if c.title == newTitle {
c.titleMu.Unlock()
return
}
c.title = newTitle
c.titleMu.Unlock()
c.lastTitleNS.Store(time.Now().UnixNano())
}
// IdleState returns the classifier's current opinion. Empty string
// (StateUnknown) means the classifier hasn't run yet for this child.
func (c *Child) IdleState() IdleState {
p := c.idleState.Load()
if p == nil {
return StateUnknown
}
return *p
}
// IdleReason returns the human-readable reason the classifier last
// recorded. Empty when no classification has happened yet.
func (c *Child) IdleReason() string {
p := c.idleReason.Load()
if p == nil {
return ""
}
return *p
}
// setIdleState updates idleState + idleReason. Returns true when the
// state actually changed (so callers can fan out a notification).
func (c *Child) setIdleState(s IdleState, reason string) bool {
prev := c.IdleState()
if prev == s {
return false
}
c.idleState.Store(&s)
c.idleReason.Store(&reason)
return true
}
// setIdleDetection installs the resolved per-preset idle-detection
// config. Called once at spawn; not safe to swap at runtime.
func (c *Child) setIdleDetection(r *resolvedIdleDetection) {
c.idleDetection = r
}
func (c *Child) recordWrite(chunk []byte) {
c.lastWriteNS.Store(time.Now().UnixNano())
c.screenVersion.Add(1)