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.
97 lines
2.7 KiB
Go
97 lines
2.7 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"time"
|
|
)
|
|
|
|
// classifierTickInterval is how often the per-session classifier wakes
|
|
// up to re-evaluate every child's state. 250ms is fast enough that
|
|
// the sidebar badge looks live, slow enough that the cost is invisible
|
|
// even with dozens of children.
|
|
const classifierTickInterval = 250 * time.Millisecond
|
|
|
|
// classifierTailBytes is the size of the ring-buffer tail the
|
|
// classifier scans for promoter regexes. Big enough to catch a multi-
|
|
// line "Approve?" prompt, small enough that we don't pay for a full
|
|
// 1 MiB regex scan every tick.
|
|
const classifierTailBytes = 4096
|
|
|
|
// runClassifier loops over every live child every classifierTickInterval
|
|
// and updates IdleState when it changes. It runs until ctx is cancelled
|
|
// (the host shutdown path cancels). One goroutine per Session is plenty
|
|
// — the work is cheap (atomic loads + ~4 KiB regex scan per child).
|
|
func (s *Session) runClassifier(ctx context.Context) {
|
|
ticker := time.NewTicker(classifierTickInterval)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
s.classifyAll()
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Session) classifyAll() {
|
|
for _, c := range s.Children() {
|
|
s.classifyOne(c)
|
|
}
|
|
}
|
|
|
|
func (s *Session) classifyOne(c *Child) {
|
|
st := c.Status()
|
|
exited := st == StatusExited || st == StatusErrored
|
|
exitNonZero := false
|
|
if exited {
|
|
exitNonZero = c.ExitCode() != 0
|
|
}
|
|
idleMS := c.IdleMS()
|
|
titleIdleMS := c.TitleIdleMS()
|
|
title := c.Title()
|
|
tail := c.tailBytes(classifierTailBytes)
|
|
state, reason := classify(c.idleDetection, exited, exitNonZero, idleMS, titleIdleMS, title, tail)
|
|
if c.setIdleState(state, reason) {
|
|
s.emitStateChanged(c.ID, state)
|
|
}
|
|
}
|
|
|
|
// tailBytes returns up to n bytes from the end of the ring buffer.
|
|
// Safe to call from the classifier goroutine while pumpChild writes
|
|
// from another goroutine — both serialise on ringMu.
|
|
func (c *Child) tailBytes(n int) []byte {
|
|
c.ringMu.Lock()
|
|
defer c.ringMu.Unlock()
|
|
have := int64(ringCap)
|
|
if !c.ringFull {
|
|
have = c.ringWrites
|
|
}
|
|
if have == 0 {
|
|
return nil
|
|
}
|
|
want := int64(n)
|
|
if want > have {
|
|
want = have
|
|
}
|
|
out := make([]byte, want)
|
|
// The ring layout matches StreamRead: when not full, byte k lives
|
|
// at index k; when full, the oldest byte sits at ringPos and the
|
|
// newest at (ringPos-1) mod ringCap.
|
|
if !c.ringFull {
|
|
copy(out, c.ring[c.ringWrites-want:c.ringWrites])
|
|
return out
|
|
}
|
|
// Tail starts `want` bytes back from the write head.
|
|
start := (c.ringPos - int(want) + ringCap) % ringCap
|
|
first := ringCap - start
|
|
if first > int(want) {
|
|
first = int(want)
|
|
}
|
|
copy(out, c.ring[start:start+first])
|
|
if first < int(want) {
|
|
copy(out[first:], c.ring[:int(want)-first])
|
|
}
|
|
return out
|
|
}
|