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