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:
@@ -61,12 +61,11 @@ type toolHost struct {
|
||||
prompter trustPrompter
|
||||
scratch scratchpadSink
|
||||
|
||||
timersMu sync.Mutex
|
||||
nextTimer int
|
||||
timers *timerManager
|
||||
}
|
||||
|
||||
func newToolHost(sess *Session, pads *scratchpad.Store, launcher *Launcher, presets preset.Set, tr *trust.Store, cols, rows uint16) *toolHost {
|
||||
return &toolHost{
|
||||
h := &toolHost{
|
||||
sess: sess,
|
||||
pads: pads,
|
||||
launcher: launcher,
|
||||
@@ -76,6 +75,28 @@ func newToolHost(sess *Session, pads *scratchpad.Store, launcher *Launcher, pres
|
||||
defaultRow: rows,
|
||||
startedAt: make(map[string]time.Time),
|
||||
}
|
||||
h.timers = newTimerManager(sess)
|
||||
// Plug the timer manager into the session's state-change fan-out so
|
||||
// idle-aware timers fire when watched children transition into idle.
|
||||
// Tests can construct a host with a nil session for sizing checks —
|
||||
// those never run timers, so the subscribe is skipped.
|
||||
if sess != nil {
|
||||
sess.Subscribe(timerListenerAdapter{m: h.timers})
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// timerListenerAdapter forwards OnChildStateChanged into the timer
|
||||
// manager and ignores the other ChildEventListener methods. The
|
||||
// session's listener API is by-interface, so we wrap the manager
|
||||
// rather than make it implement the full surface.
|
||||
type timerListenerAdapter struct{ m *timerManager }
|
||||
|
||||
func (a timerListenerAdapter) OnChildSpawned(*Child) {}
|
||||
func (a timerListenerAdapter) OnChildExited(*Child) {}
|
||||
func (a timerListenerAdapter) OnPTYOut(string, []byte) {}
|
||||
func (a timerListenerAdapter) OnChildStateChanged(id string, st IdleState) {
|
||||
a.m.onChildStateChanged(id, st)
|
||||
}
|
||||
|
||||
func (h *toolHost) SetSize(cols, rows uint16) {
|
||||
@@ -531,6 +552,7 @@ func (n *chunkNotifier) OnPTYOut(id string, chunk []byte) {
|
||||
default:
|
||||
}
|
||||
}
|
||||
func (n *chunkNotifier) OnChildStateChanged(string, IdleState) {}
|
||||
|
||||
func (h *toolHost) GetProcessPorts(callerID, processID string) ([]mcp.PortSighting, error) {
|
||||
c := h.sess.FindChild(processID)
|
||||
@@ -725,27 +747,59 @@ func (h *toolHost) RequestHumanAttention(callerID, processID, reason string) err
|
||||
return nil
|
||||
}
|
||||
|
||||
// TimerWait is the legacy fire-and-forget delay timer. It now wraps
|
||||
// TimerSet with an empty body — defaultFireFn substitutes the
|
||||
// "[system] Your timer […] has completed." line so behaviour matches
|
||||
// the original API. New callers should use timer_set with an explicit
|
||||
// body.
|
||||
func (h *toolHost) TimerWait(callerID string, seconds float64, label string) (string, error) {
|
||||
caller := h.sess.FindChild(callerID)
|
||||
if caller == nil {
|
||||
return "", mcp.Errorf(mcp.ErrorKindNotFound, "caller %q not known to patterm", callerID)
|
||||
return h.timers.TimerSet(callerID, "", label, seconds)
|
||||
}
|
||||
|
||||
func (h *toolHost) TimerSet(callerID string, args mcp.TimerSetArgs) (mcp.TimerHandle, error) {
|
||||
owner := resolveTimerOwner(callerID, args.OwnerProcessID)
|
||||
id, err := h.timers.TimerSet(owner, args.Body, args.Label, args.Seconds)
|
||||
if err != nil {
|
||||
return mcp.TimerHandle{}, err
|
||||
}
|
||||
h.timersMu.Lock()
|
||||
h.nextTimer++
|
||||
id := fmt.Sprintf("t%d", h.nextTimer)
|
||||
h.timersMu.Unlock()
|
||||
if label == "" {
|
||||
label = id
|
||||
return mcp.TimerHandle{ID: id}, nil
|
||||
}
|
||||
|
||||
func (h *toolHost) TimerFireWhenIdleAny(callerID string, args mcp.TimerFireWhenIdleArgs) (mcp.TimerFireWhenIdleResponse, error) {
|
||||
owner := resolveTimerOwner(callerID, args.OwnerProcessID)
|
||||
return h.timers.TimerFireWhenIdleAny(owner, args.Body, args.Label, args.Watched, args.MaxWaitSeconds)
|
||||
}
|
||||
|
||||
func (h *toolHost) TimerFireWhenIdleAll(callerID string, args mcp.TimerFireWhenIdleArgs) (mcp.TimerFireWhenIdleResponse, error) {
|
||||
owner := resolveTimerOwner(callerID, args.OwnerProcessID)
|
||||
return h.timers.TimerFireWhenIdleAll(owner, args.Body, args.Label, args.Watched, args.MaxWaitSeconds)
|
||||
}
|
||||
|
||||
// resolveTimerOwner picks the owner process for a timer. Explicit
|
||||
// owner_process_id wins; otherwise the caller's own id is used.
|
||||
// Top-level MCP clients (no callerID) must provide owner_process_id
|
||||
// explicitly.
|
||||
func resolveTimerOwner(callerID, explicit string) string {
|
||||
if explicit != "" {
|
||||
return explicit
|
||||
}
|
||||
go func() {
|
||||
time.Sleep(time.Duration(seconds * float64(time.Second)))
|
||||
if !caller.IsLive() {
|
||||
return
|
||||
}
|
||||
line := fmt.Sprintf("[system] Your timer [%s] has completed.\r", label)
|
||||
_ = caller.InjectAsOrchestrator([]byte(line))
|
||||
}()
|
||||
return id, nil
|
||||
return callerID
|
||||
}
|
||||
|
||||
func (h *toolHost) TimerCancel(callerID, id string) error {
|
||||
return h.timers.TimerCancel(callerID, id)
|
||||
}
|
||||
|
||||
func (h *toolHost) TimerPause(callerID, id string) error {
|
||||
return h.timers.TimerPause(callerID, id)
|
||||
}
|
||||
|
||||
func (h *toolHost) TimerResume(callerID, id string) error {
|
||||
return h.timers.TimerResume(callerID, id)
|
||||
}
|
||||
|
||||
func (h *toolHost) TimerList(callerID string) ([]mcp.TimerInfo, error) {
|
||||
return h.timers.TimerList(callerID), nil
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────
|
||||
@@ -816,6 +870,10 @@ func (h *toolHost) processInfoOf(c *Child) mcp.ProcessInfo {
|
||||
t := h.trust.IsTrusted(c.PresetRef)
|
||||
info.Trusted = &t
|
||||
}
|
||||
if s := c.IdleState(); s != StateUnknown {
|
||||
info.IdleState = string(s)
|
||||
info.IdleReason = c.IdleReason()
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user