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:
@@ -113,6 +113,11 @@ func Run(ctx context.Context, opts Options) error {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
// Per-session idle-detection classifier. One goroutine ticks every
|
||||
// 250ms over every live child and updates IdleState. It stops when
|
||||
// ctx is cancelled.
|
||||
go sess.runClassifier(ctx)
|
||||
|
||||
st := &uiState{
|
||||
sess: sess,
|
||||
presets: presets,
|
||||
@@ -120,6 +125,7 @@ func Run(ctx context.Context, opts Options) error {
|
||||
pads: pads,
|
||||
chromeWake: make(chan struct{}, 1),
|
||||
trust: trustStore,
|
||||
timers: host.timers,
|
||||
hostCols: cols,
|
||||
hostRows: rows,
|
||||
stdinTTY: term.IsTerminal(int(os.Stdin.Fd())),
|
||||
@@ -296,6 +302,7 @@ type uiState struct {
|
||||
launcher *Launcher
|
||||
pads *scratchpad.Store
|
||||
trust *trust.Store
|
||||
timers *timerManager
|
||||
|
||||
outMu sync.Mutex
|
||||
|
||||
@@ -610,6 +617,14 @@ func (st *uiState) OnChildSpawned(c *Child) {
|
||||
st.drawStatusLine()
|
||||
}
|
||||
|
||||
// OnChildStateChanged repaints the sidebar whenever a child's
|
||||
// idle-state badge flips. Cheap — the badge is the only chrome that
|
||||
// reflects state today, and drawSidebar bails when the cached frame
|
||||
// hasn't changed.
|
||||
func (st *uiState) OnChildStateChanged(string, IdleState) {
|
||||
st.drawSidebar()
|
||||
}
|
||||
|
||||
// OnChildExited drops focus and shows the empty state if it was the
|
||||
// focused child.
|
||||
func (st *uiState) OnChildExited(c *Child) {
|
||||
|
||||
Reference in New Issue
Block a user