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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user