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

@@ -88,6 +88,13 @@ type ToolHost interface {
SendMessage(callerID, targetID, message string) error
RequestHumanAttention(callerID, processID, reason string) error
TimerWait(callerID string, seconds float64, label string) (string, error)
TimerSet(callerID string, args TimerSetArgs) (TimerHandle, error)
TimerFireWhenIdleAny(callerID string, args TimerFireWhenIdleArgs) (TimerFireWhenIdleResponse, error)
TimerFireWhenIdleAll(callerID string, args TimerFireWhenIdleArgs) (TimerFireWhenIdleResponse, error)
TimerCancel(callerID, id string) error
TimerPause(callerID, id string) error
TimerResume(callerID, id string) error
TimerList(callerID string) ([]TimerInfo, error)
// Scratchpads.
ScratchpadList() ([]scratchpad.Entry, error)
@@ -111,6 +118,13 @@ type ProcessInfo struct {
ExitCode *int `json:"exit_code,omitempty"`
IdleMS int64 `json:"idle_ms,omitempty"`
Trusted *bool `json:"trusted,omitempty"`
// IdleState is the idle-detection classifier's current opinion:
// one of "idle", "working", "thinking", "permission", "error".
// Empty when the classifier has not yet evaluated this child
// (typically right after spawn) or when idle detection is disabled.
IdleState string `json:"idle_state,omitempty"`
IdleReason string `json:"idle_reason,omitempty"`
}
// ProcessStatus is what get_process_status returns. Richer than
@@ -181,6 +195,63 @@ type SearchMatch struct {
Text string `json:"text"`
}
// TimerSetArgs is the input for timer_set: a one-shot delay timer that
// delivers Body to the owning agent as a fresh user turn when it fires.
// OwnerProcessID is optional — when empty the caller's own process_id
// is used (matching Solo's "bound agent" semantics). Top-level
// orchestrators (no caller identity) must set OwnerProcessID
// explicitly.
type TimerSetArgs struct {
Body string `json:"body"`
Label string `json:"label,omitempty"`
Seconds float64 `json:"seconds"`
OwnerProcessID string `json:"owner_process_id,omitempty"`
}
// TimerFireWhenIdleArgs is the input for timer_fire_when_idle_any /
// timer_fire_when_idle_all. Watched lists process_ids to monitor.
// MaxWaitSeconds bounds how long the timer can stay pending before
// firing anyway (0 = no max wait, fire only when the idle condition is
// met). OwnerProcessID: see TimerSetArgs.
type TimerFireWhenIdleArgs struct {
Body string `json:"body"`
Label string `json:"label,omitempty"`
Watched []string `json:"watched"`
MaxWaitSeconds float64 `json:"max_wait_seconds,omitempty"`
OwnerProcessID string `json:"owner_process_id,omitempty"`
}
// TimerHandle is the response for timer_set.
type TimerHandle struct {
ID string `json:"timer_id"`
}
// TimerFireWhenIdleResponse covers timer_fire_when_idle_any /
// timer_fire_when_idle_all. When every watched process is already idle
// at registration time, idle_all returns Status="already_satisfied"
// and ID="" — no timer is created (matches Solo). idle_any returns
// AlreadyIdle so the caller can see which processes were excluded from
// satisfaction.
type TimerFireWhenIdleResponse struct {
ID string `json:"timer_id,omitempty"`
Status string `json:"status"` // "pending" | "already_satisfied"
AlreadyIdle []string `json:"already_idle,omitempty"`
WaitingOn []string `json:"waiting_on,omitempty"`
}
// TimerInfo is one row in the timer_list response.
type TimerInfo struct {
ID string `json:"timer_id"`
Label string `json:"label,omitempty"`
Body string `json:"body,omitempty"`
Kind string `json:"kind"` // "delay" | "idle_any" | "idle_all"
Status string `json:"status"` // "pending" | "paused"
OwnerID string `json:"owner_process_id"`
WatchedIDs []string `json:"watched,omitempty"`
FiresAtUnixMS int64 `json:"fires_at_unix_ms,omitempty"`
PausedRemainingMS int64 `json:"paused_remaining_ms,omitempty"`
}
// PortSighting matches the per-child store in internal/app.
type PortSighting struct {
Port int `json:"port"`
@@ -575,6 +646,82 @@ func callTool(h ToolHost, callerID, method string, params json.RawMessage) (any,
}
return map[string]string{"timer_id": id}, 0, "", nil
case "timer_set":
var p TimerSetArgs
if err := unmarshalParams(params, &p); err != nil {
return nil, codeInvalidParams, err.Error(), nil
}
h2, err := h.TimerSet(callerID, p)
if err != nil {
return mapToolError(err)
}
return h2, 0, "", nil
case "timer_fire_when_idle_any":
var p TimerFireWhenIdleArgs
if err := unmarshalParams(params, &p); err != nil {
return nil, codeInvalidParams, err.Error(), nil
}
resp, err := h.TimerFireWhenIdleAny(callerID, p)
if err != nil {
return mapToolError(err)
}
return resp, 0, "", nil
case "timer_fire_when_idle_all":
var p TimerFireWhenIdleArgs
if err := unmarshalParams(params, &p); err != nil {
return nil, codeInvalidParams, err.Error(), nil
}
resp, err := h.TimerFireWhenIdleAll(callerID, p)
if err != nil {
return mapToolError(err)
}
return resp, 0, "", nil
case "timer_cancel":
var p struct {
TimerID string `json:"timer_id"`
}
if err := unmarshalParams(params, &p); err != nil {
return nil, codeInvalidParams, err.Error(), nil
}
if err := h.TimerCancel(callerID, p.TimerID); err != nil {
return mapToolError(err)
}
return "ok", 0, "", nil
case "timer_pause":
var p struct {
TimerID string `json:"timer_id"`
}
if err := unmarshalParams(params, &p); err != nil {
return nil, codeInvalidParams, err.Error(), nil
}
if err := h.TimerPause(callerID, p.TimerID); err != nil {
return mapToolError(err)
}
return "ok", 0, "", nil
case "timer_resume":
var p struct {
TimerID string `json:"timer_id"`
}
if err := unmarshalParams(params, &p); err != nil {
return nil, codeInvalidParams, err.Error(), nil
}
if err := h.TimerResume(callerID, p.TimerID); err != nil {
return mapToolError(err)
}
return "ok", 0, "", nil
case "timer_list":
ts, err := h.TimerList(callerID)
if err != nil {
return mapToolError(err)
}
return ts, 0, "", nil
case "scratchpad_list":
entries, err := h.ScratchpadList()
if err != nil {