From 2b9e1ed77c8aca95991a2077d8d8baa2e9154aa2 Mon Sep 17 00:00:00 2001 From: Harry Bayliss Date: Fri, 15 May 2026 09:49:59 +0100 Subject: [PATCH] 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. --- CHANGELOG.md | 37 ++ internal/app/app.go | 15 + internal/app/child.go | 82 +++ internal/app/classifier.go | 96 ++++ internal/app/host.go | 100 +++- internal/app/idle.go | 225 ++++++++ internal/app/idle_test.go | 112 ++++ internal/app/launch.go | 8 +- internal/app/session.go | 19 + internal/app/sidebar.go | 73 ++- internal/app/style.go | 1 + internal/app/timers.go | 488 ++++++++++++++++++ internal/app/timers_test.go | 223 ++++++++ internal/harness/runner.go | 36 ++ internal/harness/scenario.go | 22 +- .../scenarios/idle_osc_title_stability.json | 44 ++ .../scenarios/idle_osc_title_status.json | 48 ++ .../scenarios/idle_output_activity.json | 44 ++ .../harness/scenarios/idle_regex_promote.json | 33 ++ internal/harness/scenarios/timer_cancel.json | 44 ++ .../timer_idle_all_already_satisfied.json | 48 ++ .../scenarios/timer_idle_all_pending.json | 89 ++++ .../timer_idle_any_fires_on_transition.json | 67 +++ .../harness/scenarios/timer_pause_resume.json | 62 +++ .../harness/scenarios/timer_set_delivers.json | 40 ++ internal/mcp/protocol.go | 70 ++- internal/mcp/tools.go | 147 ++++++ internal/preset/preset.go | 56 +- internal/vt/emulator.go | 5 + internal/vt/ghostty.go | 21 + internal/vt/ghostty_nocgo.go | 1 + 31 files changed, 2318 insertions(+), 38 deletions(-) create mode 100644 internal/app/classifier.go create mode 100644 internal/app/idle.go create mode 100644 internal/app/idle_test.go create mode 100644 internal/app/timers.go create mode 100644 internal/app/timers_test.go create mode 100644 internal/harness/scenarios/idle_osc_title_stability.json create mode 100644 internal/harness/scenarios/idle_osc_title_status.json create mode 100644 internal/harness/scenarios/idle_output_activity.json create mode 100644 internal/harness/scenarios/idle_regex_promote.json create mode 100644 internal/harness/scenarios/timer_cancel.json create mode 100644 internal/harness/scenarios/timer_idle_all_already_satisfied.json create mode 100644 internal/harness/scenarios/timer_idle_all_pending.json create mode 100644 internal/harness/scenarios/timer_idle_any_fires_on_transition.json create mode 100644 internal/harness/scenarios/timer_pause_resume.json create mode 100644 internal/harness/scenarios/timer_set_delivers.json diff --git a/CHANGELOG.md b/CHANGELOG.md index e1d556f..333ddca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,40 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] ### Added +- Per-child idle-state classifier with five states (`idle`, `working`, + `thinking`, `permission`, `error`) and three pluggable strategies: + `output_activity` (claude / opencode defaults), `osc_title_stability` + (codex), and `osc_title_status` (gemini-style status-in-title agents). + Optional `permission_patterns` / `thinking_patterns` / `error_patterns` + regexes promote a base state when matched against the tail of recent + output. State and last-match reason are exposed via MCP on + `ProcessInfo` and `get_process_status` (`idle_state`, `idle_reason`). +- New `idle_detection` block on `preset.Preset` for setting the strategy + threshold, title-to-state map, and promoter regex lists. Bundled + defaults are shipped for the first-party claude / codex / opencode + presets. +- Sidebar now renders a state glyph per process row (`○` idle, `●` + working, `◐` thinking, `?` permission, `✕` error) and, when a process + has a pending or paused timer, appends a nearest-timer indicator + (`⏱ 12s` or `⏸ paused`). +- MCP timer surface expanded to match Solo's tool set: `timer_set`, + `timer_fire_when_idle_any`, `timer_fire_when_idle_all`, `timer_cancel`, + `timer_pause`, `timer_resume`, `timer_list`. Idle-aware timers + registered against already-idle children fire synchronously + (`status: already_satisfied`) for `idle_all`, and report + `already_idle` / `waiting_on` arrays so callers can introspect the + watch set. Timer bodies are delivered to the owner process via the + same orchestrator-injection path as `send_message`. +- Timer tools accept an explicit `owner_process_id` so top-level + (non-agent) callers — including the harness MCP client — can attribute + timers to a specific process. Omitting it treats the caller as the + orchestrator with universal cancel / pause / resume / list privileges. +- libghostty-vt `Title()` accessor on the emulator surface, polled from + the session pump so OSC 0/1/2 title updates feed into the classifier + without a callback round-trip. +- Harness `wait_until_mcp` step type that re-runs an MCP method until an + assertion (Equals / Contains) holds or the timeout elapses. Used by + the new idle / timer scenarios. - User-created top-level command processes now survive a patterm restart. Each spawn (palette form, command preset, or MCP `spawn_process` with `kind=command`) writes a record to @@ -64,6 +98,9 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). after a child program disables mouse tracking. ### Changed +- `timer_wait` is now a thin wrapper over the shared timer manager + (`timer_set` semantics). Existing callers see no behavioural change; + the timer is visible in `timer_list` while it's pending. - CLI flag parsing switched from Go's stdlib `flag` to `spf13/pflag`. `--project` (and the internal `--socket` / `--identity` / `--scenario` / `--patterm-bin` flags) are now the only accepted form diff --git a/internal/app/app.go b/internal/app/app.go index 8bf515d..e150b10 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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) { diff --git a/internal/app/child.go b/internal/app/child.go index 993a6fa..17cc8f6 100644 --- a/internal/app/child.go +++ b/internal/app/child.go @@ -123,6 +123,19 @@ type Child struct { portsMu sync.Mutex ports []PortSighting + // Idle-detection state. idleState carries the classifier's current + // opinion (StateIdle / StateWorking / …). lastTitleNS is the wall + // time of the most recent OSC title change — separate from + // lastWriteNS so the osc_title_* strategies can ignore plain output + // churn. idleDetection is the compiled per-preset config, resolved + // once at spawn and immutable thereafter. + idleState atomic.Pointer[IdleState] + idleReason atomic.Pointer[string] + titleMu sync.RWMutex + title string + lastTitleNS atomic.Int64 + idleDetection *resolvedIdleDetection + cleanupMu sync.Mutex cleanupPaths []string restarting atomic.Bool @@ -330,6 +343,75 @@ func (c *Child) IdleMS() int64 { return (time.Now().UnixNano() - last) / int64(time.Millisecond) } +// TitleIdleMS returns how many milliseconds since the OSC window title +// last changed. 0 means "no title set yet". +func (c *Child) TitleIdleMS() int64 { + last := c.lastTitleNS.Load() + if last == 0 { + return 0 + } + return (time.Now().UnixNano() - last) / int64(time.Millisecond) +} + +// Title returns the most recent OSC 0/2 title. +func (c *Child) Title() string { + c.titleMu.RLock() + defer c.titleMu.RUnlock() + return c.title +} + +// recordTitle updates the cached title and bumps lastTitleNS when it +// actually changes. Called from Session.pumpChild after each PTY chunk +// — cheap because most chunks don't carry an OSC sequence. +func (c *Child) recordTitle(newTitle string) { + c.titleMu.Lock() + if c.title == newTitle { + c.titleMu.Unlock() + return + } + c.title = newTitle + c.titleMu.Unlock() + c.lastTitleNS.Store(time.Now().UnixNano()) +} + +// IdleState returns the classifier's current opinion. Empty string +// (StateUnknown) means the classifier hasn't run yet for this child. +func (c *Child) IdleState() IdleState { + p := c.idleState.Load() + if p == nil { + return StateUnknown + } + return *p +} + +// IdleReason returns the human-readable reason the classifier last +// recorded. Empty when no classification has happened yet. +func (c *Child) IdleReason() string { + p := c.idleReason.Load() + if p == nil { + return "" + } + return *p +} + +// setIdleState updates idleState + idleReason. Returns true when the +// state actually changed (so callers can fan out a notification). +func (c *Child) setIdleState(s IdleState, reason string) bool { + prev := c.IdleState() + if prev == s { + return false + } + c.idleState.Store(&s) + c.idleReason.Store(&reason) + return true +} + +// setIdleDetection installs the resolved per-preset idle-detection +// config. Called once at spawn; not safe to swap at runtime. +func (c *Child) setIdleDetection(r *resolvedIdleDetection) { + c.idleDetection = r +} + func (c *Child) recordWrite(chunk []byte) { c.lastWriteNS.Store(time.Now().UnixNano()) c.screenVersion.Add(1) diff --git a/internal/app/classifier.go b/internal/app/classifier.go new file mode 100644 index 0000000..ef8680c --- /dev/null +++ b/internal/app/classifier.go @@ -0,0 +1,96 @@ +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 +} diff --git a/internal/app/host.go b/internal/app/host.go index 6c96706..78985a4 100644 --- a/internal/app/host.go +++ b/internal/app/host.go @@ -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 } diff --git a/internal/app/idle.go b/internal/app/idle.go new file mode 100644 index 0000000..cc80c73 --- /dev/null +++ b/internal/app/idle.go @@ -0,0 +1,225 @@ +package app + +import ( + "regexp" + + "github.com/hjbdev/patterm/internal/preset" +) + +// IdleState is the classifier's opinion about what a child is doing. +// Inspired by Solo's five-state model. ERROR is a terminal state — set +// when a child exits non-zero or matches an error-promoter regex — +// while the other four reflect transient runtime state. +type IdleState string + +const ( + StateUnknown IdleState = "" + StateIdle IdleState = "idle" + StateWorking IdleState = "working" + StateThinking IdleState = "thinking" + StatePermission IdleState = "permission" + StateError IdleState = "error" +) + +// IdleStrategy picks the primary signal used to decide idle vs working. +// Promoter regexes can override this on top. +type IdleStrategy string + +const ( + StrategyOutputActivity IdleStrategy = "output_activity" + StrategyOSCTitleStability IdleStrategy = "osc_title_stability" + StrategyOSCTitleStatus IdleStrategy = "osc_title_status" +) + +// defaultIdleThresholdMS is used when a preset doesn't override it. +const defaultIdleThresholdMS = 2000 + +// resolvedIdleDetection is the compiled, runtime-ready form of a +// preset.IdleDetection block. Built once at child spawn and held +// read-only by the classifier; regex patterns are compiled here so the +// hot path doesn't pay for it. +type resolvedIdleDetection struct { + strategy IdleStrategy + idleThresholdMS int64 + + titleStatusMap map[string]IdleState + + permissionRegexes []*regexp.Regexp + thinkingRegexes []*regexp.Regexp + errorRegexes []*regexp.Regexp +} + +// resolveIdleDetection compiles a preset.IdleDetection (which may be +// nil) into the runtime form. Unknown strategies fall back to +// output_activity. Pattern compile errors are skipped silently — the +// preset loader is responsible for surfacing them as warnings. +func resolveIdleDetection(cfg *preset.IdleDetection) *resolvedIdleDetection { + r := &resolvedIdleDetection{ + strategy: StrategyOutputActivity, + idleThresholdMS: defaultIdleThresholdMS, + } + if cfg == nil { + return r + } + switch IdleStrategy(cfg.Strategy) { + case StrategyOSCTitleStability, StrategyOSCTitleStatus, StrategyOutputActivity: + r.strategy = IdleStrategy(cfg.Strategy) + } + if cfg.IdleThresholdMS > 0 { + r.idleThresholdMS = int64(cfg.IdleThresholdMS) + } + if len(cfg.TitleStatusMap) > 0 { + r.titleStatusMap = make(map[string]IdleState, len(cfg.TitleStatusMap)) + for k, v := range cfg.TitleStatusMap { + switch IdleState(v) { + case StateIdle, StateWorking, StateThinking, StatePermission, StateError: + r.titleStatusMap[k] = IdleState(v) + } + } + } + r.permissionRegexes = compilePatterns(cfg.PermissionPatterns) + r.thinkingRegexes = compilePatterns(cfg.ThinkingPatterns) + r.errorRegexes = compilePatterns(cfg.ErrorPatterns) + return r +} + +func compilePatterns(ps []string) []*regexp.Regexp { + if len(ps) == 0 { + return nil + } + out := make([]*regexp.Regexp, 0, len(ps)) + for _, p := range ps { + if p == "" { + continue + } + re, err := regexp.Compile(p) + if err != nil { + continue + } + out = append(out, re) + } + return out +} + +// classify computes the IdleState from the inputs the classifier loop +// has already gathered. Pure function so it's easy to unit-test. +// +// Resolution order: +// 1. terminal: process exited non-zero → error (latched) +// 2. error-promoter regex match in recent output → error +// 3. permission-promoter regex match → permission +// 4. thinking-promoter regex match → thinking +// 5. strategy-specific base classification (idle vs working). +// +// inputs: +// - exited: whether the child process has exited +// - exitNonZero: whether the exit was non-zero (only meaningful when exited) +// - idleMS: ms since the last PTY output +// - titleIdleMS: ms since the last OSC title change (0 if no title yet) +// - title: current OSC title +// - tail: recent output bytes for regex matching +func classify(cfg *resolvedIdleDetection, exited, exitNonZero bool, idleMS, titleIdleMS int64, title string, tail []byte) (IdleState, string) { + if exited { + if exitNonZero { + return StateError, "process exited non-zero" + } + return StateIdle, "process exited cleanly" + } + if cfg == nil { + cfg = &resolvedIdleDetection{strategy: StrategyOutputActivity, idleThresholdMS: defaultIdleThresholdMS} + } + if len(tail) > 0 { + if matchAny(cfg.errorRegexes, tail) { + return StateError, "error regex matched" + } + if matchAny(cfg.permissionRegexes, tail) { + return StatePermission, "permission regex matched" + } + if matchAny(cfg.thinkingRegexes, tail) { + return StateThinking, "thinking regex matched" + } + } + threshold := cfg.idleThresholdMS + switch cfg.strategy { + case StrategyOSCTitleStatus: + // First try the title-status map; if no match, fall back to + // title-stability behaviour so we still produce idle/working. + if s, ok := matchTitleStatus(cfg.titleStatusMap, title); ok { + return s, "title status match" + } + fallthrough + case StrategyOSCTitleStability: + // If we've never seen a title, fall back to output activity so + // we don't latch in idle while the child is clearly running. + if titleIdleMS == 0 { + return baseStateFromIdleMS(idleMS, threshold) + } + return baseStateFromIdleMS(titleIdleMS, threshold) + default: // output_activity + return baseStateFromIdleMS(idleMS, threshold) + } +} + +func baseStateFromIdleMS(idleMS, threshold int64) (IdleState, string) { + // idleMS == 0 means "no writes yet" (per Child.IdleMS) — treat as + // not-idle so we don't classify a freshly-spawned child as idle. + if idleMS == 0 { + return StateWorking, "no activity yet" + } + if idleMS < threshold { + return StateWorking, "recent activity" + } + return StateIdle, "quiet for threshold" +} + +func matchAny(res []*regexp.Regexp, tail []byte) bool { + for _, re := range res { + if re.Match(tail) { + return true + } + } + return false +} + +func matchTitleStatus(m map[string]IdleState, title string) (IdleState, bool) { + if len(m) == 0 || title == "" { + return StateUnknown, false + } + for k, v := range m { + if k == "" { + continue + } + if containsFold(title, k) { + return v, true + } + } + return StateUnknown, false +} + +// containsFold reports whether s contains sub, case-insensitively. +// Cheap implementation suitable for short titles. +func containsFold(s, sub string) bool { + if len(sub) == 0 { + return true + } + if len(sub) > len(s) { + return false + } + ls, lsub := lower(s), lower(sub) + for i := 0; i+len(lsub) <= len(ls); i++ { + if ls[i:i+len(lsub)] == lsub { + return true + } + } + return false +} + +func lower(s string) string { + b := []byte(s) + for i, c := range b { + if c >= 'A' && c <= 'Z' { + b[i] = c + 32 + } + } + return string(b) +} diff --git a/internal/app/idle_test.go b/internal/app/idle_test.go new file mode 100644 index 0000000..d448a45 --- /dev/null +++ b/internal/app/idle_test.go @@ -0,0 +1,112 @@ +package app + +import ( + "regexp" + "testing" +) + +func mustCompile(t *testing.T, p string) *regexp.Regexp { + t.Helper() + re, err := regexp.Compile(p) + if err != nil { + t.Fatalf("regex %q: %v", p, err) + } + return re +} + +func TestClassifyOutputActivity(t *testing.T) { + cfg := &resolvedIdleDetection{strategy: StrategyOutputActivity, idleThresholdMS: 2000} + + cases := []struct { + name string + idleMS int64 + want IdleState + }{ + {"fresh-spawn no writes", 0, StateWorking}, + {"recent activity", 500, StateWorking}, + {"under threshold", 1999, StateWorking}, + {"at threshold", 2000, StateIdle}, + {"over threshold", 5000, StateIdle}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, _ := classify(cfg, false, false, tc.idleMS, 0, "", nil) + if got != tc.want { + t.Fatalf("got %q want %q", got, tc.want) + } + }) + } +} + +func TestClassifyTitleStability(t *testing.T) { + cfg := &resolvedIdleDetection{strategy: StrategyOSCTitleStability, idleThresholdMS: 2000} + // Title change recent → working. + if got, _ := classify(cfg, false, false, 9999, 500, "step 3", nil); got != StateWorking { + t.Fatalf("recent title change: got %q", got) + } + // Title stable past threshold → idle. + if got, _ := classify(cfg, false, false, 9999, 5000, "step 3", nil); got != StateIdle { + t.Fatalf("stable title: got %q", got) + } + // No title yet: fall back to output activity. + if got, _ := classify(cfg, false, false, 100, 0, "", nil); got != StateWorking { + t.Fatalf("no title yet, recent output: got %q", got) + } + if got, _ := classify(cfg, false, false, 5000, 0, "", nil); got != StateIdle { + t.Fatalf("no title yet, output idle: got %q", got) + } +} + +func TestClassifyTitleStatus(t *testing.T) { + cfg := &resolvedIdleDetection{ + strategy: StrategyOSCTitleStatus, + idleThresholdMS: 2000, + titleStatusMap: map[string]IdleState{ + "thinking": StateThinking, + "permission": StatePermission, + "error": StateError, + }, + } + if got, _ := classify(cfg, false, false, 9999, 500, "Thinking…", nil); got != StateThinking { + t.Fatalf("thinking title: got %q", got) + } + if got, _ := classify(cfg, false, false, 9999, 500, "Waiting for permission", nil); got != StatePermission { + t.Fatalf("permission title: got %q", got) + } + // No match in map → fall back to stability. + if got, _ := classify(cfg, false, false, 9999, 5000, "ready", nil); got != StateIdle { + t.Fatalf("unmatched title, stable: got %q", got) + } +} + +func TestClassifyPromoterRegex(t *testing.T) { + cfg := &resolvedIdleDetection{ + strategy: StrategyOutputActivity, + idleThresholdMS: 2000, + permissionRegexes: []*regexp.Regexp{mustCompile(t, `Approve\?`)}, + errorRegexes: []*regexp.Regexp{mustCompile(t, `panic:`)}, + thinkingRegexes: []*regexp.Regexp{mustCompile(t, `Thinking`)}, + } + // Permission promoter beats idle. + if got, _ := classify(cfg, false, false, 5000, 0, "", []byte("Approve? [y/n]")); got != StatePermission { + t.Fatalf("permission promoter: got %q", got) + } + // Error trumps permission. + if got, _ := classify(cfg, false, false, 5000, 0, "", []byte("panic: bad\nApprove?")); got != StateError { + t.Fatalf("error promoter beats permission: got %q", got) + } + // Thinking promoter on idle output. + if got, _ := classify(cfg, false, false, 5000, 0, "", []byte("Thinking…")); got != StateThinking { + t.Fatalf("thinking promoter: got %q", got) + } +} + +func TestClassifyExitTerminal(t *testing.T) { + cfg := &resolvedIdleDetection{strategy: StrategyOutputActivity, idleThresholdMS: 2000} + if got, _ := classify(cfg, true, true, 0, 0, "", nil); got != StateError { + t.Fatalf("non-zero exit: got %q", got) + } + if got, _ := classify(cfg, true, false, 0, 0, "", nil); got != StateIdle { + t.Fatalf("clean exit: got %q", got) + } +} diff --git a/internal/app/launch.go b/internal/app/launch.go index 6261d23..fad52b6 100644 --- a/internal/app/launch.go +++ b/internal/app/launch.go @@ -140,6 +140,7 @@ func (l *Launcher) LaunchAgent(p *preset.Preset, displayName, initialPrompt, par cleanup() return nil, err } + c.setIdleDetection(resolveIdleDetection(p.IdleDetection)) // Wait for the preset's ready signal, then type the initial prompt. idle := time.Duration(1000) * time.Millisecond @@ -171,7 +172,7 @@ func (l *Launcher) LaunchCommandPreset(p *preset.Preset, displayName, parentID s env = append(env, k+"="+v) } cols, rows := l.size() - return l.sess.Spawn(SpawnSpec{ + c, err := l.sess.Spawn(SpawnSpec{ Kind: KindCommand, Argv: p.ResolvedArgv(), Env: env, @@ -180,6 +181,11 @@ func (l *Launcher) LaunchCommandPreset(p *preset.Preset, displayName, parentID s WorkDir: p.WorkingDir, PresetRef: p.Name, }, cols, rows) + if err != nil { + return nil, err + } + c.setIdleDetection(resolveIdleDetection(p.IdleDetection)) + return c, nil } // LaunchCommandArgv spawns a freeform-argv command entry. Trust gating diff --git a/internal/app/session.go b/internal/app/session.go index 11f72c5..8e78fcb 100644 --- a/internal/app/session.go +++ b/internal/app/session.go @@ -70,6 +70,10 @@ type ChildEventListener interface { // Only the focused-child chunk should reach the screen — the TUI // filters by id. OnPTYOut(childID string, chunk []byte) + // OnChildStateChanged fires when the idle-detection classifier + // updates a child's IdleState. Listeners use this to repaint the + // sidebar badge and to evaluate idle-aware timers. + OnChildStateChanged(childID string, state IdleState) } func NewSession(projectDir, projectKey string) *Session { @@ -140,6 +144,12 @@ func (s *Session) emitPTYOut(id string, chunk []byte) { } } +func (s *Session) emitStateChanged(id string, state IdleState) { + for _, l := range s.listenersSnapshot() { + l.OnChildStateChanged(id, state) + } +} + func (s *Session) ChildEnv() []string { env := os.Environ() // Mark patterm-owned PTYs so a recursive `patterm` invocation can @@ -374,6 +384,15 @@ func (s *Session) pumpChild(c *Child, runID uint64) { if _, werr := em.Write(chunk); werr != nil { logf("emulator.Write(child %s): %v", c.ID, werr) } + // OSC 0/2 title updates ride on the same byte stream as + // the rest of the output. Polling the emulator after each + // Write is cheap (one cgo call returning a borrowed + // string) and lets the classifier treat title changes as + // an activity signal — even when the title isn't visible + // in the rendered grid. + if t, terr := em.Title(); terr == nil { + c.recordTitle(t) + } } c.recordWrite(chunk) s.emitPTYOut(c.ID, chunk) diff --git a/internal/app/sidebar.go b/internal/app/sidebar.go index b232bde..490cb03 100644 --- a/internal/app/sidebar.go +++ b/internal/app/sidebar.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "strings" + "time" ) const ( @@ -11,6 +12,24 @@ const ( statusRows = 1 ) +// formatShortDuration renders a duration as a short, sidebar-friendly +// suffix: ms under 1s, "12s" under 60s, "3m" otherwise. +func formatShortDuration(d time.Duration) string { + if d <= 0 { + return "0s" + } + if d < time.Second { + return fmt.Sprintf("%dms", int(d/time.Millisecond)) + } + if d < time.Minute { + return fmt.Sprintf("%ds", int(d/time.Second)) + } + if d < time.Hour { + return fmt.Sprintf("%dm", int(d/time.Minute)) + } + return fmt.Sprintf("%dh", int(d/time.Hour)) +} + // drawSidebar paints the right-rail session tree + scratchpad list. // SPEC §4: the rail is the active session's child hierarchy on top and // the scratchpad list (with preview) on the bottom. @@ -62,14 +81,56 @@ func (st *uiState) drawSidebar() { write(" " + styleActive + text + styleReset) write(" " + styleBorder + strings.Repeat("─", width-2) + styleReset) } + // timerIndicator returns a short " ⏱ 12s" or " ⏸ paused" suffix + // when c has a pending or paused timer attached (owns or watches). + // Empty string when no timer is in play. + timerIndicator := func(c *Child) string { + if st.timers == nil { + return "" + } + info := st.timers.activeForChild(c.ID) + if info == nil { + return "" + } + if info.Status == timerStatusPaused { + return " " + styleDim + "⏸" + styleReset + } + remaining := "" + if info.FiresAtUnixMS > 0 { + d := time.Until(time.UnixMilli(info.FiresAtUnixMS)) + if d < 0 { + d = 0 + } + remaining = formatShortDuration(d) + } + return " " + styleDim + "⏱" + styleReset + " " + styleHint + remaining + styleReset + } statusGlyph := func(c *Child, focused bool) string { if c.Status() != StatusRunning { return styleDim + "○" + styleReset } + // Idle-detection states paint over the plain running glyph so + // the rail communicates "running but waiting on you" vs "running + // and busy" at a glance. Focused entries always use the accent + // colour so the user's selection stays visible. + style := styleHint if focused { - return styleAccent + "●" + styleReset + style = styleAccent + } + switch c.IdleState() { + case StateError: + return styleError + "✕" + styleReset + case StatePermission: + return styleAccent + "?" + styleReset + case StateThinking: + return style + "◐" + styleReset + case StateIdle: + return style + "○" + styleReset + case StateWorking: + return style + "●" + styleReset + default: + return style + "●" + styleReset } - return styleHint + "●" + styleReset } // Processes section — top-level command/terminal processes, @@ -92,9 +153,9 @@ func (st *uiState) drawSidebar() { var line string if focused { line = " " + styleAccent + "▎" + styleReset + " " + glyph + " " + - styleBold + c.DisplayName() + styleReset + marker + styleBold + c.DisplayName() + styleReset + marker + timerIndicator(c) } else { - line = " " + glyph + " " + styleHint + c.DisplayName() + styleReset + marker + line = " " + glyph + " " + styleHint + c.DisplayName() + styleReset + marker + timerIndicator(c) } write(line) } @@ -124,9 +185,9 @@ func (st *uiState) drawSidebar() { var line string if focused { line = " " + styleAccent + "▎" + styleReset + " " + indent + glyph + " " + - styleBold + c.DisplayName() + styleReset + styleBold + c.DisplayName() + styleReset + timerIndicator(c) } else { - line = " " + indent + glyph + " " + styleHint + c.DisplayName() + styleReset + line = " " + indent + glyph + " " + styleHint + c.DisplayName() + styleReset + timerIndicator(c) } write(line) } diff --git a/internal/app/style.go b/internal/app/style.go index f6b984f..6875507 100644 --- a/internal/app/style.go +++ b/internal/app/style.go @@ -11,4 +11,5 @@ const ( styleAccent = "\x1b[38;5;75m" styleHint = "\x1b[38;5;244m" styleActive = "\x1b[1;38;5;253m" + styleError = "\x1b[38;5;203m" ) diff --git a/internal/app/timers.go b/internal/app/timers.go new file mode 100644 index 0000000..985f08a --- /dev/null +++ b/internal/app/timers.go @@ -0,0 +1,488 @@ +package app + +import ( + "fmt" + "sync" + "time" + + "github.com/hjbdev/patterm/internal/mcp" +) + +// pendingTimerKind picks the firing rule. +type pendingTimerKind string + +const ( + timerKindDelay pendingTimerKind = "delay" + timerKindIdleAny pendingTimerKind = "idle_any" + timerKindIdleAll pendingTimerKind = "idle_all" +) + +const ( + timerStatusPending = "pending" + timerStatusPaused = "paused" + timerStatusFired = "fired" + timerStatusCanceled = "canceled" +) + +// pendingTimer is one live timer tracked by the manager. The body is +// delivered verbatim to the owning child's PTY as a fresh user turn +// when the timer fires. +// +// Locking: every field is protected by timerManager.mu. The runtime +// time.Timer (rt) is started outside the lock so the firing goroutine +// can take the lock without deadlocking. +type pendingTimer struct { + id string + label string + body string + ownerID string + kind pendingTimerKind + status string + + watched []string + idleBaseline map[string]bool // for idle_any: ids already idle at registration (excluded from satisfaction) + + firesAt time.Time + pausedRemaining time.Duration + pausedWasMaxWait bool // for idle_*: true if the active timer was max-wait, not delay + + rt *time.Timer // delay timer or idle_* max-wait fallback +} + +// timerManager owns the pending-timer registry. Mutating operations +// (set, cancel, pause, resume) all serialise through mu; fire callbacks +// from the runtime timer also take mu to safely transition state. +type timerManager struct { + sess *Session + + mu sync.Mutex + nextID int + timers map[string]*pendingTimer + + // fireFn is the callback used to deliver the body to the owning + // process. Decoupled so tests can substitute a recorder. Defaults + // to caller.InjectAsOrchestrator + "\r". + fireFn func(owner *Child, body, label string) +} + +func newTimerManager(sess *Session) *timerManager { + m := &timerManager{ + sess: sess, + timers: make(map[string]*pendingTimer), + } + m.fireFn = defaultFireFn + return m +} + +func defaultFireFn(owner *Child, body, label string) { + if owner == nil || !owner.IsLive() { + return + } + // Solo delivers body verbatim. patterm's PTY-injection path expects + // a trailing CR so the line submits in TUI agents (Claude/Codex/ + // OpenCode all paste-detect). A bare body without CR sits in the + // input buffer; that's almost never what the caller wants. + if body == "" { + body = fmt.Sprintf("[system] Your timer [%s] has completed.", label) + } + _ = owner.InjectAsOrchestrator([]byte(body + "\r")) +} + +func (m *timerManager) mintID() string { + m.nextID++ + return fmt.Sprintf("t%d", m.nextID) +} + +// TimerSet schedules a delay timer. Returns immediately; the body is +// delivered to the owning child when the timer fires. +func (m *timerManager) TimerSet(ownerID string, body, label string, seconds float64) (string, error) { + owner := m.sess.FindChild(ownerID) + if owner == nil { + return "", mcp.Errorf(mcp.ErrorKindNotFound, "caller %q not known to patterm", ownerID) + } + if seconds < 0 { + return "", mcp.Errorf(mcp.ErrorKindInvalidArgs, "timer_set: seconds must be ≥ 0") + } + d := time.Duration(seconds * float64(time.Second)) + m.mu.Lock() + id := m.mintID() + if label == "" { + label = id + } + t := &pendingTimer{ + id: id, + label: label, + body: body, + ownerID: ownerID, + kind: timerKindDelay, + status: timerStatusPending, + firesAt: time.Now().Add(d), + } + m.timers[id] = t + m.mu.Unlock() + t.rt = time.AfterFunc(d, func() { m.fireDelay(id) }) + return id, nil +} + +func (m *timerManager) fireDelay(id string) { + m.mu.Lock() + t, ok := m.timers[id] + if !ok || t.status != timerStatusPending { + m.mu.Unlock() + return + } + t.status = timerStatusFired + owner := m.sess.FindChild(t.ownerID) + body, label := t.body, t.label + m.mu.Unlock() + m.fireFn(owner, body, label) +} + +// TimerFireWhenIdleAny schedules an idle-any timer. Children already +// idle at registration are excluded from satisfaction — only a +// transition into idle by a still-active watched child fires the +// timer. Max-wait, when positive, acts as a fallback fire deadline. +func (m *timerManager) TimerFireWhenIdleAny(ownerID, body, label string, watched []string, maxWait float64) (mcp.TimerFireWhenIdleResponse, error) { + return m.registerIdleTimer(timerKindIdleAny, ownerID, body, label, watched, maxWait) +} + +// TimerFireWhenIdleAll schedules an idle-all timer. Already-idle +// children count as satisfied; if every watched child is already idle +// at registration time the response is "already_satisfied" with no +// timer created. +func (m *timerManager) TimerFireWhenIdleAll(ownerID, body, label string, watched []string, maxWait float64) (mcp.TimerFireWhenIdleResponse, error) { + return m.registerIdleTimer(timerKindIdleAll, ownerID, body, label, watched, maxWait) +} + +func (m *timerManager) registerIdleTimer(kind pendingTimerKind, ownerID, body, label string, watched []string, maxWait float64) (mcp.TimerFireWhenIdleResponse, error) { + if m.sess.FindChild(ownerID) == nil { + return mcp.TimerFireWhenIdleResponse{}, mcp.Errorf(mcp.ErrorKindNotFound, "caller %q not known to patterm", ownerID) + } + if len(watched) == 0 { + return mcp.TimerFireWhenIdleResponse{}, mcp.Errorf(mcp.ErrorKindInvalidArgs, "watched must contain at least one process_id") + } + if maxWait < 0 { + return mcp.TimerFireWhenIdleResponse{}, mcp.Errorf(mcp.ErrorKindInvalidArgs, "max_wait_seconds must be ≥ 0") + } + // Validate watched ids and compute the idle baseline up front. + already := make([]string, 0) + waiting := make([]string, 0) + baseline := make(map[string]bool, len(watched)) + for _, id := range watched { + c := m.sess.FindChild(id) + if c == nil { + return mcp.TimerFireWhenIdleResponse{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q in watched", id) + } + if isIdleState(c.IdleState()) { + already = append(already, id) + baseline[id] = true + } else { + waiting = append(waiting, id) + } + } + resp := mcp.TimerFireWhenIdleResponse{AlreadyIdle: already, WaitingOn: waiting} + + // idle_all: if all watched are already idle, satisfy synchronously + // — Solo semantics; no pending timer is created. + if kind == timerKindIdleAll && len(waiting) == 0 { + resp.Status = "already_satisfied" + owner := m.sess.FindChild(ownerID) + go m.fireFn(owner, body, label) + return resp, nil + } + + m.mu.Lock() + id := m.mintID() + if label == "" { + label = id + } + t := &pendingTimer{ + id: id, + label: label, + body: body, + ownerID: ownerID, + kind: kind, + status: timerStatusPending, + watched: append([]string(nil), watched...), + idleBaseline: baseline, + } + if maxWait > 0 { + d := time.Duration(maxWait * float64(time.Second)) + t.firesAt = time.Now().Add(d) + t.rt = time.AfterFunc(d, func() { m.fireIdleMaxWait(id) }) + } + m.timers[id] = t + m.mu.Unlock() + resp.ID = id + resp.Status = "pending" + return resp, nil +} + +func (m *timerManager) fireIdleMaxWait(id string) { + m.mu.Lock() + t, ok := m.timers[id] + if !ok || t.status != timerStatusPending { + m.mu.Unlock() + return + } + t.status = timerStatusFired + owner := m.sess.FindChild(t.ownerID) + body, label := t.body, t.label + m.mu.Unlock() + m.fireFn(owner, body, label) +} + +// onChildStateChanged evaluates every pending idle_any / idle_all +// timer whenever any child's IdleState flips. Cheap — there are few +// pending timers and the per-tick check is just a map lookup + a slice +// scan. +func (m *timerManager) onChildStateChanged(childID string, state IdleState) { + if !isIdleState(state) { + return + } + m.mu.Lock() + type firing struct { + owner *Child + body string + label string + } + var fires []firing + for _, t := range m.timers { + if t.status != timerStatusPending { + continue + } + if !contains(t.watched, childID) { + continue + } + switch t.kind { + case timerKindIdleAny: + if t.idleBaseline[childID] { + continue // already idle at registration; excluded + } + t.status = timerStatusFired + if t.rt != nil { + t.rt.Stop() + } + fires = append(fires, firing{ + owner: m.sess.FindChild(t.ownerID), + body: t.body, + label: t.label, + }) + case timerKindIdleAll: + if m.allWatchedIdleLocked(t) { + t.status = timerStatusFired + if t.rt != nil { + t.rt.Stop() + } + fires = append(fires, firing{ + owner: m.sess.FindChild(t.ownerID), + body: t.body, + label: t.label, + }) + } + } + } + m.mu.Unlock() + for _, f := range fires { + m.fireFn(f.owner, f.body, f.label) + } +} + +// allWatchedIdleLocked reports whether every watched child is now +// idle. Called with m.mu held — uses live Child.IdleState() under the +// child's own atomic, not under m.mu. +func (m *timerManager) allWatchedIdleLocked(t *pendingTimer) bool { + for _, id := range t.watched { + c := m.sess.FindChild(id) + if c == nil { + continue // disappeared; treat as satisfied so we don't hang + } + if !isIdleState(c.IdleState()) { + return false + } + } + return true +} + +// TimerCancel removes a pending or paused timer owned by ownerID. +func (m *timerManager) TimerCancel(ownerID, id string) error { + m.mu.Lock() + defer m.mu.Unlock() + t, ok := m.timers[id] + if !ok { + return mcp.Errorf(mcp.ErrorKindNotFound, "no such timer %q", id) + } + // Empty ownerID = top-level orchestrator caller (e.g. a non-agent + // MCP client); allow it to manage every timer in the session. + // Otherwise the caller's own id must match the timer's owner. + if ownerID != "" && t.ownerID != ownerID { + return mcp.Errorf(mcp.ErrorKindRoleForbidden, "timer %q is not owned by caller", id) + } + if t.status == timerStatusFired || t.status == timerStatusCanceled { + // Cancelling a fired/cancelled timer is idempotent. + return nil + } + if t.rt != nil { + t.rt.Stop() + t.rt = nil + } + t.status = timerStatusCanceled + return nil +} + +// TimerPause stops the delay clock (or detaches the idle watch) but +// keeps the timer in the registry. +func (m *timerManager) TimerPause(ownerID, id string) error { + m.mu.Lock() + defer m.mu.Unlock() + t, ok := m.timers[id] + if !ok { + return mcp.Errorf(mcp.ErrorKindNotFound, "no such timer %q", id) + } + // Empty ownerID = top-level orchestrator caller (e.g. a non-agent + // MCP client); allow it to manage every timer in the session. + // Otherwise the caller's own id must match the timer's owner. + if ownerID != "" && t.ownerID != ownerID { + return mcp.Errorf(mcp.ErrorKindRoleForbidden, "timer %q is not owned by caller", id) + } + if t.status != timerStatusPending { + return mcp.Errorf(mcp.ErrorKindInvalidArgs, "timer %q is not pending", id) + } + if t.rt != nil { + t.pausedRemaining = time.Until(t.firesAt) + if t.pausedRemaining < 0 { + t.pausedRemaining = 0 + } + t.rt.Stop() + t.rt = nil + // For idle_* timers, only the max-wait timer rides on rt — the + // idle-evaluation path lives in onChildStateChanged. Mark the + // pause so resume rearms the right thing. + t.pausedWasMaxWait = t.kind != timerKindDelay + } + t.status = timerStatusPaused + return nil +} + +// TimerResume re-arms a paused timer. For delay timers the remaining +// duration is restored; idle-* timers re-attach to the state-change +// watch list, and any remaining max-wait clock resumes. +func (m *timerManager) TimerResume(ownerID, id string) error { + m.mu.Lock() + defer m.mu.Unlock() + t, ok := m.timers[id] + if !ok { + return mcp.Errorf(mcp.ErrorKindNotFound, "no such timer %q", id) + } + // Empty ownerID = top-level orchestrator caller (e.g. a non-agent + // MCP client); allow it to manage every timer in the session. + // Otherwise the caller's own id must match the timer's owner. + if ownerID != "" && t.ownerID != ownerID { + return mcp.Errorf(mcp.ErrorKindRoleForbidden, "timer %q is not owned by caller", id) + } + if t.status != timerStatusPaused { + return mcp.Errorf(mcp.ErrorKindInvalidArgs, "timer %q is not paused", id) + } + t.status = timerStatusPending + if t.pausedRemaining > 0 { + t.firesAt = time.Now().Add(t.pausedRemaining) + switch t.kind { + case timerKindDelay: + localID := id + t.rt = time.AfterFunc(t.pausedRemaining, func() { m.fireDelay(localID) }) + default: + localID := id + t.rt = time.AfterFunc(t.pausedRemaining, func() { m.fireIdleMaxWait(localID) }) + } + t.pausedRemaining = 0 + t.pausedWasMaxWait = false + } + return nil +} + +// TimerList returns timers owned by ownerID, oldest-first. An empty +// ownerID lists every active timer — the top-level orchestrator view. +func (m *timerManager) TimerList(ownerID string) []mcp.TimerInfo { + m.mu.Lock() + defer m.mu.Unlock() + out := make([]mcp.TimerInfo, 0) + for _, t := range m.timers { + if ownerID != "" && t.ownerID != ownerID { + continue + } + if t.status != timerStatusPending && t.status != timerStatusPaused { + continue + } + info := mcp.TimerInfo{ + ID: t.id, + Label: t.label, + Body: t.body, + Kind: string(t.kind), + Status: t.status, + OwnerID: t.ownerID, + WatchedIDs: append([]string(nil), t.watched...), + } + if t.status == timerStatusPending && !t.firesAt.IsZero() { + info.FiresAtUnixMS = t.firesAt.UnixMilli() + } + if t.status == timerStatusPaused && t.pausedRemaining > 0 { + info.PausedRemainingMS = t.pausedRemaining.Milliseconds() + } + out = append(out, info) + } + return out +} + +// activeForChild returns the nearest pending or paused timer attached +// to child id (either owned by it or watching it). Used by the sidebar +// for the "⏱ 12s" indicator. nil when none. +func (m *timerManager) activeForChild(id string) *mcp.TimerInfo { + m.mu.Lock() + defer m.mu.Unlock() + var best *pendingTimer + for _, t := range m.timers { + if t.status != timerStatusPending && t.status != timerStatusPaused { + continue + } + if t.ownerID != id && !contains(t.watched, id) { + continue + } + if best == nil { + best = t + continue + } + if t.firesAt.Before(best.firesAt) && !t.firesAt.IsZero() { + best = t + } + } + if best == nil { + return nil + } + info := mcp.TimerInfo{ + ID: best.id, + Label: best.label, + Kind: string(best.kind), + Status: best.status, + OwnerID: best.ownerID, + } + if best.status == timerStatusPending && !best.firesAt.IsZero() { + info.FiresAtUnixMS = best.firesAt.UnixMilli() + } + if best.status == timerStatusPaused { + info.PausedRemainingMS = best.pausedRemaining.Milliseconds() + } + return &info +} + +func isIdleState(s IdleState) bool { + return s == StateIdle +} + +func contains(haystack []string, needle string) bool { + for _, h := range haystack { + if h == needle { + return true + } + } + return false +} diff --git a/internal/app/timers_test.go b/internal/app/timers_test.go new file mode 100644 index 0000000..1dd2956 --- /dev/null +++ b/internal/app/timers_test.go @@ -0,0 +1,223 @@ +package app + +import ( + "sync" + "testing" + "time" +) + +// recorderFire collects timer firings without touching a PTY. Lets the +// timer manager run end-to-end logic in unit tests. +type recorderFire struct { + mu sync.Mutex + fires []recordedFire +} + +type recordedFire struct { + OwnerID string + Body string + Label string +} + +func (r *recorderFire) fn(owner *Child, body, label string) { + r.mu.Lock() + defer r.mu.Unlock() + id := "" + if owner != nil { + id = owner.ID + } + r.fires = append(r.fires, recordedFire{OwnerID: id, Body: body, Label: label}) +} + +func (r *recorderFire) snapshot() []recordedFire { + r.mu.Lock() + defer r.mu.Unlock() + out := make([]recordedFire, len(r.fires)) + copy(out, r.fires) + return out +} + +// fakeChild constructs a Child shell suitable for timer-manager tests. +// Doesn't open a PTY — fireFn is overridden so InjectAsOrchestrator is +// never reached. +func fakeChild(id string) *Child { + c := newChildEntry(id, id, KindAgent, []string{"echo"}, nil, "", "", "") + running := StatusRunning + c.status.Store(&running) + return c +} + +// addChild bypasses Spawn (no PTY needed) so the manager can find the +// child by id and read its IdleState. +func addChild(s *Session, c *Child) { + s.mu.Lock() + s.children[c.ID] = c + s.order = append(s.order, c.ID) + s.mu.Unlock() +} + +func newTestManager(t *testing.T) (*Session, *timerManager, *recorderFire) { + t.Helper() + sess := NewSession(t.TempDir(), "test") + mgr := newTimerManager(sess) + rec := &recorderFire{} + mgr.fireFn = rec.fn + return sess, mgr, rec +} + +func TestTimerSetDelivers(t *testing.T) { + sess, mgr, rec := newTestManager(t) + c := fakeChild("p_owner") + addChild(sess, c) + id, err := mgr.TimerSet("p_owner", "wake up", "test", 0.05) + if err != nil { + t.Fatalf("TimerSet: %v", err) + } + if id == "" { + t.Fatal("empty timer id") + } + deadline := time.Now().Add(time.Second) + for time.Now().Before(deadline) { + if len(rec.snapshot()) > 0 { + break + } + time.Sleep(10 * time.Millisecond) + } + got := rec.snapshot() + if len(got) != 1 { + t.Fatalf("got %d fires, want 1", len(got)) + } + if got[0].Body != "wake up" || got[0].OwnerID != "p_owner" { + t.Fatalf("unexpected fire: %+v", got[0]) + } +} + +func TestTimerIdleAllAlreadySatisfied(t *testing.T) { + sess, mgr, rec := newTestManager(t) + owner := fakeChild("p_owner") + a := fakeChild("p_a") + b := fakeChild("p_b") + addChild(sess, owner) + addChild(sess, a) + addChild(sess, b) + idle := StateIdle + a.idleState.Store(&idle) + b.idleState.Store(&idle) + resp, err := mgr.TimerFireWhenIdleAll("p_owner", "all done", "", []string{"p_a", "p_b"}, 0) + if err != nil { + t.Fatalf("TimerFireWhenIdleAll: %v", err) + } + if resp.Status != "already_satisfied" { + t.Fatalf("status: got %q want already_satisfied", resp.Status) + } + // fire is dispatched on a goroutine; wait briefly. + time.Sleep(50 * time.Millisecond) + got := rec.snapshot() + if len(got) != 1 || got[0].Body != "all done" { + t.Fatalf("fires: %+v", got) + } +} + +func TestTimerIdleAnyFiresOnTransition(t *testing.T) { + sess, mgr, rec := newTestManager(t) + owner := fakeChild("p_owner") + a := fakeChild("p_a") + addChild(sess, owner) + addChild(sess, a) + // p_a starts busy. + working := StateWorking + a.idleState.Store(&working) + resp, err := mgr.TimerFireWhenIdleAny("p_owner", "one done", "", []string{"p_a"}, 0) + if err != nil { + t.Fatalf("TimerFireWhenIdleAny: %v", err) + } + if resp.Status != "pending" { + t.Fatalf("status: got %q want pending", resp.Status) + } + // Flip a into idle and deliver the state-change event. + idle := StateIdle + a.idleState.Store(&idle) + mgr.onChildStateChanged("p_a", StateIdle) + got := rec.snapshot() + if len(got) != 1 || got[0].Body != "one done" { + t.Fatalf("fires: %+v", got) + } +} + +func TestTimerIdleAnyExcludesBaseline(t *testing.T) { + sess, mgr, rec := newTestManager(t) + owner := fakeChild("p_owner") + a := fakeChild("p_a") + addChild(sess, owner) + addChild(sess, a) + idle := StateIdle + a.idleState.Store(&idle) + resp, err := mgr.TimerFireWhenIdleAny("p_owner", "one done", "", []string{"p_a"}, 0) + if err != nil { + t.Fatalf("TimerFireWhenIdleAny: %v", err) + } + if resp.Status != "pending" { + t.Fatalf("status: got %q want pending", resp.Status) + } + // Send a redundant idle transition for p_a; should NOT fire because + // p_a was idle at registration. + mgr.onChildStateChanged("p_a", StateIdle) + if got := rec.snapshot(); len(got) != 0 { + t.Fatalf("unexpected fires: %+v", got) + } +} + +func TestTimerCancelPauseResume(t *testing.T) { + sess, mgr, rec := newTestManager(t) + owner := fakeChild("p_owner") + addChild(sess, owner) + + // Cancel before fire. + id, _ := mgr.TimerSet("p_owner", "x", "", 0.2) + if err := mgr.TimerCancel("p_owner", id); err != nil { + t.Fatalf("Cancel: %v", err) + } + time.Sleep(300 * time.Millisecond) + if got := rec.snapshot(); len(got) != 0 { + t.Fatalf("cancel didn't stop fire: %+v", got) + } + + // Pause then resume → fire after resume. + id2, _ := mgr.TimerSet("p_owner", "y", "", 0.2) + time.Sleep(50 * time.Millisecond) + if err := mgr.TimerPause("p_owner", id2); err != nil { + t.Fatalf("Pause: %v", err) + } + time.Sleep(300 * time.Millisecond) // would have fired by now if not paused + if got := rec.snapshot(); len(got) != 0 { + t.Fatalf("paused timer fired: %+v", got) + } + if err := mgr.TimerResume("p_owner", id2); err != nil { + t.Fatalf("Resume: %v", err) + } + deadline := time.Now().Add(time.Second) + for time.Now().Before(deadline) { + if len(rec.snapshot()) > 0 { + break + } + time.Sleep(20 * time.Millisecond) + } + if got := rec.snapshot(); len(got) != 1 || got[0].Body != "y" { + t.Fatalf("resume fire: %+v", got) + } +} + +func TestTimerOwnershipEnforced(t *testing.T) { + sess, mgr, _ := newTestManager(t) + a := fakeChild("p_a") + b := fakeChild("p_b") + addChild(sess, a) + addChild(sess, b) + id, _ := mgr.TimerSet("p_a", "hi", "", 60) + if err := mgr.TimerCancel("p_b", id); err == nil { + t.Fatal("expected ownership error from foreign cancel") + } + if err := mgr.TimerPause("p_b", id); err == nil { + t.Fatal("expected ownership error from foreign pause") + } +} diff --git a/internal/harness/runner.go b/internal/harness/runner.go index 7a0a70b..56b8468 100644 --- a/internal/harness/runner.go +++ b/internal/harness/runner.go @@ -5,6 +5,7 @@ import ( "fmt" "regexp" "strings" + "time" ) type Event struct { @@ -175,6 +176,41 @@ func runStep(s *Session, step Step, results map[string]json.RawMessage) error { return fmt.Errorf("no saved result %q", step.From) } return assertJSONValue(raw, step.Path, step.Equals, step.Contains, step.AllowSubstring) + case "wait_until_mcp": + // Poll an MCP method until the assertion at Path holds (or + // Contains substring matches), or TimeoutMS elapses. Used by the + // idle-detection scenarios to wait for a child's idle_state to + // reach a target value without sprinkling sleeps. + params, perr := resolveParams(step.Params, results) + if perr != nil { + return perr + } + deadline := time.Now().Add(timeoutMS(step.TimeoutMS)) + var lastRaw json.RawMessage + var lastErr error + for { + raw, err := s.MCPCall(step.Method, params) + if err == nil { + if aerr := assertJSONValue(raw, step.Path, step.Equals, step.Contains, step.AllowSubstring); aerr == nil { + if step.SaveAs != "" { + results[step.SaveAs] = raw + } + return nil + } else { + lastErr = aerr + lastRaw = raw + } + } else { + lastErr = err + } + if time.Now().After(deadline) { + if lastErr != nil { + return fmt.Errorf("wait_until_mcp timeout: %w (last response: %s)", lastErr, string(lastRaw)) + } + return fmt.Errorf("wait_until_mcp timeout (no successful call)") + } + time.Sleep(100 * time.Millisecond) + } } return fmt.Errorf("unknown step type %q", step.Type) } diff --git a/internal/harness/scenario.go b/internal/harness/scenario.go index 591de50..6ca9f52 100644 --- a/internal/harness/scenario.go +++ b/internal/harness/scenario.go @@ -25,11 +25,23 @@ type ScenarioPresets struct { } type ScenarioPreset struct { - Name string `json:"name"` - Argv []string `json:"argv"` - Env map[string]string `json:"env,omitempty"` - WorkingDir string `json:"working_dir,omitempty"` - Shell bool `json:"shell,omitempty"` + Name string `json:"name"` + Argv []string `json:"argv"` + Env map[string]string `json:"env,omitempty"` + WorkingDir string `json:"working_dir,omitempty"` + Shell bool `json:"shell,omitempty"` + IdleDetection *ScenarioIdleDetection `json:"idle_detection,omitempty"` +} + +// ScenarioIdleDetection mirrors preset.IdleDetection so scenarios can +// configure per-strategy idle detection for fake agent presets. +type ScenarioIdleDetection struct { + Strategy string `json:"strategy,omitempty"` + IdleThresholdMS int `json:"idle_threshold_ms,omitempty"` + TitleStatusMap map[string]string `json:"title_status_map,omitempty"` + PermissionPatterns []string `json:"permission_patterns,omitempty"` + ThinkingPatterns []string `json:"thinking_patterns,omitempty"` + ErrorPatterns []string `json:"error_patterns,omitempty"` } type ScenarioScript struct { diff --git a/internal/harness/scenarios/idle_osc_title_stability.json b/internal/harness/scenarios/idle_osc_title_stability.json new file mode 100644 index 0000000..55f4f3d --- /dev/null +++ b/internal/harness/scenarios/idle_osc_title_stability.json @@ -0,0 +1,44 @@ +{ + "name": "idle_osc_title_stability", + "presets": { + "processes": [ + { + "name": "titler", + "argv": [ + "sh", + "-lc", + "i=0; while [ $i -lt 6 ]; do printf '\\033]2;step %d\\007' $i; i=$((i+1)); sleep 0.2; done; sleep 60" + ], + "idle_detection": { + "strategy": "osc_title_stability", + "idle_threshold_ms": 1000 + } + } + ] + }, + "trust": ["titler"], + "steps": [ + { + "type": "mcp_call", + "method": "spawn_process", + "params": {"kind": "command", "preset": "titler", "name": "titler"}, + "save_as": "proc" + }, + { + "type": "wait_until_mcp", + "method": "get_process_status", + "params": {"process_id": "{{proc.process_id}}"}, + "path": "idle_state", + "equals": "working", + "timeout_ms": 3000 + }, + { + "type": "wait_until_mcp", + "method": "get_process_status", + "params": {"process_id": "{{proc.process_id}}"}, + "path": "idle_state", + "equals": "idle", + "timeout_ms": 4000 + } + ] +} diff --git a/internal/harness/scenarios/idle_osc_title_status.json b/internal/harness/scenarios/idle_osc_title_status.json new file mode 100644 index 0000000..ca1bedb --- /dev/null +++ b/internal/harness/scenarios/idle_osc_title_status.json @@ -0,0 +1,48 @@ +{ + "name": "idle_osc_title_status", + "presets": { + "processes": [ + { + "name": "geminilike", + "argv": [ + "sh", + "-lc", + "printf '\\033]2;Thinking\\007'; sleep 1; printf '\\033]2;Permission required\\007'; sleep 60" + ], + "idle_detection": { + "strategy": "osc_title_status", + "idle_threshold_ms": 1000, + "title_status_map": { + "thinking": "thinking", + "permission": "permission" + } + } + } + ] + }, + "trust": ["geminilike"], + "steps": [ + { + "type": "mcp_call", + "method": "spawn_process", + "params": {"kind": "command", "preset": "geminilike", "name": "geminilike"}, + "save_as": "proc" + }, + { + "type": "wait_until_mcp", + "method": "get_process_status", + "params": {"process_id": "{{proc.process_id}}"}, + "path": "idle_state", + "equals": "thinking", + "timeout_ms": 3000 + }, + { + "type": "wait_until_mcp", + "method": "get_process_status", + "params": {"process_id": "{{proc.process_id}}"}, + "path": "idle_state", + "equals": "permission", + "timeout_ms": 4000 + } + ] +} diff --git a/internal/harness/scenarios/idle_output_activity.json b/internal/harness/scenarios/idle_output_activity.json new file mode 100644 index 0000000..dc90a76 --- /dev/null +++ b/internal/harness/scenarios/idle_output_activity.json @@ -0,0 +1,44 @@ +{ + "name": "idle_output_activity", + "presets": { + "processes": [ + { + "name": "blinker", + "argv": ["sh", "-lc", "echo step1; sleep 3; echo step2; sleep 60"], + "idle_detection": { + "strategy": "output_activity", + "idle_threshold_ms": 1000 + } + } + ] + }, + "trust": ["blinker"], + "steps": [ + { + "type": "mcp_call", + "method": "spawn_process", + "params": { + "kind": "command", + "preset": "blinker", + "name": "blinker" + }, + "save_as": "proc" + }, + { + "type": "wait_until_mcp", + "method": "get_process_status", + "params": {"process_id": "{{proc.process_id}}"}, + "path": "idle_state", + "equals": "working", + "timeout_ms": 4000 + }, + { + "type": "wait_until_mcp", + "method": "get_process_status", + "params": {"process_id": "{{proc.process_id}}"}, + "path": "idle_state", + "equals": "idle", + "timeout_ms": 4000 + } + ] +} diff --git a/internal/harness/scenarios/idle_regex_promote.json b/internal/harness/scenarios/idle_regex_promote.json new file mode 100644 index 0000000..03aa59b --- /dev/null +++ b/internal/harness/scenarios/idle_regex_promote.json @@ -0,0 +1,33 @@ +{ + "name": "idle_regex_promote", + "presets": { + "processes": [ + { + "name": "approver", + "argv": ["sh", "-lc", "echo 'Do you want to proceed?'; sleep 60"], + "idle_detection": { + "strategy": "output_activity", + "idle_threshold_ms": 500, + "permission_patterns": ["Do you want to proceed\\?"] + } + } + ] + }, + "trust": ["approver"], + "steps": [ + { + "type": "mcp_call", + "method": "spawn_process", + "params": {"kind": "command", "preset": "approver", "name": "approver"}, + "save_as": "proc" + }, + { + "type": "wait_until_mcp", + "method": "get_process_status", + "params": {"process_id": "{{proc.process_id}}"}, + "path": "idle_state", + "equals": "permission", + "timeout_ms": 4000 + } + ] +} diff --git a/internal/harness/scenarios/timer_cancel.json b/internal/harness/scenarios/timer_cancel.json new file mode 100644 index 0000000..1f8f1a7 --- /dev/null +++ b/internal/harness/scenarios/timer_cancel.json @@ -0,0 +1,44 @@ +{ + "name": "timer_cancel", + "presets": { + "processes": [ + { + "name": "echoer", + "argv": ["sh", "-lc", "while read line; do echo \"saw:$line\"; done"] + } + ] + }, + "trust": ["echoer"], + "steps": [ + { + "type": "mcp_call", + "method": "spawn_process", + "params": {"kind": "command", "preset": "echoer", "name": "echoer"}, + "save_as": "proc" + }, + { "type": "wait_stable", "timeout_ms": 1500 }, + { + "type": "mcp_call", + "method": "timer_set", + "params": {"seconds": 1, "body": "should-not-arrive", "owner_process_id": "{{proc.process_id}}"}, + "save_as": "tmr" + }, + { + "type": "mcp_call", + "method": "timer_cancel", + "params": {"timer_id": "{{tmr.timer_id}}"} + }, + { + "type": "mcp_call", + "method": "timer_list", + "params": {"owner_process_id": "{{proc.process_id}}"}, + "save_as": "listed" + }, + { + "type": "assert_saved", + "from": "listed", + "path": "", + "equals": [] + } + ] +} diff --git a/internal/harness/scenarios/timer_idle_all_already_satisfied.json b/internal/harness/scenarios/timer_idle_all_already_satisfied.json new file mode 100644 index 0000000..3ddbd35 --- /dev/null +++ b/internal/harness/scenarios/timer_idle_all_already_satisfied.json @@ -0,0 +1,48 @@ +{ + "name": "timer_idle_all_already_satisfied", + "presets": { + "processes": [ + { + "name": "quiet", + "argv": ["sh", "-lc", "echo ready; sleep 60"], + "idle_detection": { + "strategy": "output_activity", + "idle_threshold_ms": 500 + } + } + ] + }, + "trust": ["quiet"], + "steps": [ + { + "type": "mcp_call", + "method": "spawn_process", + "params": {"kind": "command", "preset": "quiet", "name": "quiet"}, + "save_as": "proc" + }, + { + "type": "wait_until_mcp", + "method": "get_process_status", + "params": {"process_id": "{{proc.process_id}}"}, + "path": "idle_state", + "equals": "idle", + "timeout_ms": 4000 + }, + { + "type": "mcp_call", + "method": "timer_fire_when_idle_all", + "params": { + "watched": ["{{proc.process_id}}"], + "body": "all-idle", + "owner_process_id": "{{proc.process_id}}" + }, + "save_as": "resp" + }, + { + "type": "assert_saved", + "from": "resp", + "path": "status", + "equals": "already_satisfied" + } + ] +} diff --git a/internal/harness/scenarios/timer_idle_all_pending.json b/internal/harness/scenarios/timer_idle_all_pending.json new file mode 100644 index 0000000..850f71a --- /dev/null +++ b/internal/harness/scenarios/timer_idle_all_pending.json @@ -0,0 +1,89 @@ +{ + "name": "timer_idle_all_pending", + "presets": { + "processes": [ + { + "name": "echoer", + "argv": ["sh", "-lc", "while read line; do echo \"saw:$line\"; done"] + }, + { + "name": "quiet", + "argv": ["sh", "-lc", "echo ready; sleep 60"], + "idle_detection": { + "strategy": "output_activity", + "idle_threshold_ms": 500 + } + }, + { + "name": "busy", + "argv": ["sh", "-lc", "for i in 1 2 3 4 5; do echo tick $i; sleep 0.2; done; sleep 60"], + "idle_detection": { + "strategy": "output_activity", + "idle_threshold_ms": 500 + } + } + ] + }, + "trust": ["echoer", "quiet", "busy"], + "steps": [ + { + "type": "mcp_call", + "method": "spawn_process", + "params": {"kind": "command", "preset": "echoer", "name": "echoer"}, + "save_as": "owner" + }, + { + "type": "mcp_call", + "method": "spawn_process", + "params": {"kind": "command", "preset": "quiet", "name": "quiet"}, + "save_as": "q" + }, + { + "type": "mcp_call", + "method": "spawn_process", + "params": {"kind": "command", "preset": "busy", "name": "busy"}, + "save_as": "b" + }, + { + "type": "wait_until_mcp", + "method": "get_process_status", + "params": {"process_id": "{{q.process_id}}"}, + "path": "idle_state", + "equals": "idle", + "timeout_ms": 3000 + }, + { + "type": "wait_until_mcp", + "method": "get_process_status", + "params": {"process_id": "{{b.process_id}}"}, + "path": "idle_state", + "equals": "working", + "timeout_ms": 3000 + }, + { + "type": "mcp_call", + "method": "timer_fire_when_idle_all", + "params": { + "watched": ["{{q.process_id}}", "{{b.process_id}}"], + "body": "all-idle", + "owner_process_id": "{{owner.process_id}}" + }, + "save_as": "resp" + }, + { + "type": "assert_saved", + "from": "resp", + "path": "status", + "equals": "pending" + }, + { + "type": "wait_until_mcp", + "method": "get_process_output", + "params": {"process_id": "{{owner.process_id}}", "mode": "grid"}, + "path": "content", + "contains": "saw:all-idle", + "allow_substring": true, + "timeout_ms": 6000 + } + ] +} diff --git a/internal/harness/scenarios/timer_idle_any_fires_on_transition.json b/internal/harness/scenarios/timer_idle_any_fires_on_transition.json new file mode 100644 index 0000000..82162d8 --- /dev/null +++ b/internal/harness/scenarios/timer_idle_any_fires_on_transition.json @@ -0,0 +1,67 @@ +{ + "name": "timer_idle_any_fires_on_transition", + "presets": { + "processes": [ + { + "name": "echoer", + "argv": ["sh", "-lc", "while read line; do echo \"saw:$line\"; done"] + }, + { + "name": "busy", + "argv": ["sh", "-lc", "for i in 1 2 3 4 5; do echo tick $i; sleep 0.2; done; sleep 60"], + "idle_detection": { + "strategy": "output_activity", + "idle_threshold_ms": 500 + } + } + ] + }, + "trust": ["echoer", "busy"], + "steps": [ + { + "type": "mcp_call", + "method": "spawn_process", + "params": {"kind": "command", "preset": "echoer", "name": "echoer"}, + "save_as": "owner" + }, + { + "type": "mcp_call", + "method": "spawn_process", + "params": {"kind": "command", "preset": "busy", "name": "busy"}, + "save_as": "watch" + }, + { + "type": "wait_until_mcp", + "method": "get_process_status", + "params": {"process_id": "{{watch.process_id}}"}, + "path": "idle_state", + "equals": "working", + "timeout_ms": 3000 + }, + { + "type": "mcp_call", + "method": "timer_fire_when_idle_any", + "params": { + "watched": ["{{watch.process_id}}"], + "body": "any-idle", + "owner_process_id": "{{owner.process_id}}" + }, + "save_as": "resp" + }, + { + "type": "assert_saved", + "from": "resp", + "path": "status", + "equals": "pending" + }, + { + "type": "wait_until_mcp", + "method": "get_process_output", + "params": {"process_id": "{{owner.process_id}}", "mode": "grid"}, + "path": "content", + "contains": "saw:any-idle", + "allow_substring": true, + "timeout_ms": 6000 + } + ] +} diff --git a/internal/harness/scenarios/timer_pause_resume.json b/internal/harness/scenarios/timer_pause_resume.json new file mode 100644 index 0000000..cb4d7b8 --- /dev/null +++ b/internal/harness/scenarios/timer_pause_resume.json @@ -0,0 +1,62 @@ +{ + "name": "timer_pause_resume", + "presets": { + "processes": [ + { + "name": "echoer", + "argv": ["sh", "-lc", "while read line; do echo \"saw:$line\"; done"] + } + ] + }, + "trust": ["echoer"], + "steps": [ + { + "type": "mcp_call", + "method": "spawn_process", + "params": {"kind": "command", "preset": "echoer", "name": "echoer"}, + "save_as": "proc" + }, + { "type": "wait_stable", "timeout_ms": 1500 }, + { + "type": "mcp_call", + "method": "timer_set", + "params": { + "seconds": 1, + "body": "after-resume", + "owner_process_id": "{{proc.process_id}}" + }, + "save_as": "tmr" + }, + { + "type": "mcp_call", + "method": "timer_pause", + "params": {"timer_id": "{{tmr.timer_id}}"} + }, + { + "type": "mcp_call", + "method": "timer_list", + "params": {"owner_process_id": "{{proc.process_id}}"}, + "save_as": "listed" + }, + { + "type": "assert_saved", + "from": "listed", + "path": "0.status", + "equals": "paused" + }, + { + "type": "mcp_call", + "method": "timer_resume", + "params": {"timer_id": "{{tmr.timer_id}}"} + }, + { + "type": "wait_until_mcp", + "method": "get_process_output", + "params": {"process_id": "{{proc.process_id}}", "mode": "grid"}, + "path": "content", + "contains": "saw:after-resume", + "allow_substring": true, + "timeout_ms": 5000 + } + ] +} diff --git a/internal/harness/scenarios/timer_set_delivers.json b/internal/harness/scenarios/timer_set_delivers.json new file mode 100644 index 0000000..f0b3d17 --- /dev/null +++ b/internal/harness/scenarios/timer_set_delivers.json @@ -0,0 +1,40 @@ +{ + "name": "timer_set_delivers", + "presets": { + "processes": [ + { + "name": "echoer", + "argv": ["sh", "-lc", "while read line; do echo \"saw:$line\"; done"] + } + ] + }, + "trust": ["echoer"], + "steps": [ + { + "type": "mcp_call", + "method": "spawn_process", + "params": {"kind": "command", "preset": "echoer", "name": "echoer"}, + "save_as": "proc" + }, + { "type": "wait_stable", "timeout_ms": 1500 }, + { + "type": "mcp_call", + "method": "timer_set", + "params": { + "seconds": 0.5, + "body": "hello-from-timer", + "owner_process_id": "{{proc.process_id}}" + }, + "save_as": "tmr" + }, + { + "type": "wait_until_mcp", + "method": "get_process_output", + "params": {"process_id": "{{proc.process_id}}", "mode": "grid"}, + "path": "content", + "contains": "saw:hello-from-timer", + "allow_substring": true, + "timeout_ms": 5000 + } + ] +} diff --git a/internal/mcp/protocol.go b/internal/mcp/protocol.go index 7616b49..77367b1 100644 --- a/internal/mcp/protocol.go +++ b/internal/mcp/protocol.go @@ -73,6 +73,14 @@ func booleanProp(desc string) map[string]any { return map[string]any{"type": "boolean", "description": desc} } +func arrayOfStringsProp(desc string) map[string]any { + return map[string]any{ + "type": "array", + "description": desc, + "items": map[string]any{"type": "string"}, + } +} + // toolCatalog is the full list advertised via tools/list. Descriptions // are intentionally short — clients are expected to fetch help() for // detail. Schemas mirror the param structs in tools.go. @@ -239,12 +247,70 @@ func toolCatalog() []toolDescriptor { }, { Name: "timer_wait", - Description: "Sleep server-side for `seconds` and return a timer id (use to pace polling).", + Description: "Schedule a delay timer that injects a fixed `[system]` line into your pane when it fires (legacy; prefer timer_set).", InputSchema: objectSchema(map[string]any{ - "seconds": numberProp("Sleep duration."), + "seconds": numberProp("Delay duration."), "label": stringProp("Optional label for diagnostics."), }, []string{"seconds"}), }, + { + Name: "timer_set", + Description: "Schedule a one-shot delay timer that delivers `body` to the owning agent as a fresh user turn when it fires.", + InputSchema: objectSchema(map[string]any{ + "seconds": numberProp("Delay duration."), + "body": stringProp("Message delivered verbatim to the owning agent as a user turn when the timer fires."), + "label": stringProp("Optional label for diagnostics."), + "owner_process_id": stringProp("Owner process id; defaults to the caller. Top-level callers must supply this explicitly."), + }, []string{"seconds", "body"}), + }, + { + Name: "timer_fire_when_idle_any", + Description: "Schedule a timer that fires when any watched process enters idle (already-idle entries excluded), or when max_wait_seconds elapses.", + InputSchema: objectSchema(map[string]any{ + "watched": arrayOfStringsProp("Process ids to watch."), + "body": stringProp("Message delivered verbatim to the owning agent when the timer fires."), + "label": stringProp("Optional label for diagnostics."), + "max_wait_seconds": numberProp("Optional cap; 0 means no fallback fire."), + "owner_process_id": stringProp("Owner process id; defaults to the caller."), + }, []string{"watched", "body"}), + }, + { + Name: "timer_fire_when_idle_all", + Description: "Schedule a timer that fires when all watched processes are idle (already-idle entries count as satisfied), or when max_wait_seconds elapses.", + InputSchema: objectSchema(map[string]any{ + "watched": arrayOfStringsProp("Process ids to watch."), + "body": stringProp("Message delivered verbatim to the owning agent when the timer fires."), + "label": stringProp("Optional label for diagnostics."), + "max_wait_seconds": numberProp("Optional cap; 0 means no fallback fire."), + "owner_process_id": stringProp("Owner process id; defaults to the caller."), + }, []string{"watched", "body"}), + }, + { + Name: "timer_cancel", + Description: "Cancel one pending timer owned by the caller.", + InputSchema: objectSchema(map[string]any{ + "timer_id": stringProp("Timer id returned by a previous timer_* call."), + }, []string{"timer_id"}), + }, + { + Name: "timer_pause", + Description: "Pause one pending timer owned by the caller. Idle-aware timers stop listening to state changes; delay timers preserve their remaining wait.", + InputSchema: objectSchema(map[string]any{ + "timer_id": stringProp("Timer id."), + }, []string{"timer_id"}), + }, + { + Name: "timer_resume", + Description: "Resume one paused timer owned by the caller.", + InputSchema: objectSchema(map[string]any{ + "timer_id": stringProp("Timer id."), + }, []string{"timer_id"}), + }, + { + Name: "timer_list", + Description: "List pending and paused timers owned by the caller.", + InputSchema: objectSchema(nil, nil), + }, { Name: "scratchpad_list", Description: "List shared per-project scratchpad entries.", diff --git a/internal/mcp/tools.go b/internal/mcp/tools.go index ccf4765..cffd2cc 100644 --- a/internal/mcp/tools.go +++ b/internal/mcp/tools.go @@ -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 { diff --git a/internal/preset/preset.go b/internal/preset/preset.go index 39039c1..697e54a 100644 --- a/internal/preset/preset.go +++ b/internal/preset/preset.go @@ -40,9 +40,42 @@ type Preset struct { Shell bool `json:"shell,omitempty"` // Agent-only. SPEC §10. - MCPInjection *MCPInjection `json:"mcp_injection,omitempty"` - ReadySignal *ReadySignal `json:"ready_signal,omitempty"` - ChromeTrimHints []string `json:"chrome_trim_hints,omitempty"` + MCPInjection *MCPInjection `json:"mcp_injection,omitempty"` + ReadySignal *ReadySignal `json:"ready_signal,omitempty"` + ChromeTrimHints []string `json:"chrome_trim_hints,omitempty"` + IdleDetection *IdleDetection `json:"idle_detection,omitempty"` +} + +// IdleDetection configures steady-state idle classification for an +// agent preset. Independent of ReadySignal (which is startup-only). +// All fields are optional; when the whole block is nil the runtime +// falls back to output_activity with a 2s threshold. +// +// Strategy selects the primary signal: +// - "output_activity": ms since last PTY output (Claude, OpenCode). +// - "osc_title_stability": ms since last OSC 0/2 title change +// (Codex, Amp — title changes mean activity). +// - "osc_title_status": substring-match the current title against +// TitleStatusMap (Gemini — title carries a status word). +// +// Promoter patterns are applied on top of the strategy. They run +// against the recent ring-buffer tail; the first match wins in +// error > permission > thinking precedence and promotes the state +// over whatever the strategy returned. +type IdleDetection struct { + Strategy string `json:"strategy,omitempty"` + IdleThresholdMS int `json:"idle_threshold_ms,omitempty"` + + // TitleStatusMap maps a (case-insensitive) substring of the OSC + // title to a state. Only meaningful for "osc_title_status". + // Allowed values: "idle", "working", "thinking", "permission", "error". + TitleStatusMap map[string]string `json:"title_status_map,omitempty"` + + // Output regex promoters. Compiled at load time; bad patterns are + // surfaced as warnings and skipped. + PermissionPatterns []string `json:"permission_patterns,omitempty"` + ThinkingPatterns []string `json:"thinking_patterns,omitempty"` + ErrorPatterns []string `json:"error_patterns,omitempty"` } // MCPInjection covers the strategies SPEC §10 enumerates plus @@ -196,6 +229,15 @@ func ensureDefaults(base string) error { "argv": ["claude"], "mcp_injection": { "kind": "flag", "flag": "--mcp-config" }, "ready_signal": { "idle_ms": 1000 }, + "idle_detection": { + "strategy": "output_activity", + "idle_threshold_ms": 2000, + "permission_patterns": [ + "Do you want to proceed\\?", + "❯ 1\\. Yes", + "1\\. Yes, and don't ask" + ] + }, "chrome_trim_hints": [ "^Welcome to Claude Code", "^/help for help", @@ -220,6 +262,10 @@ func ensureDefaults(base string) error { "format": "toml" }, "ready_signal": { "idle_ms": 1000 }, + "idle_detection": { + "strategy": "osc_title_stability", + "idle_threshold_ms": 2000 + }, "chrome_trim_hints": [ "^OpenAI Codex", "^\\s*model:", @@ -243,6 +289,10 @@ func ensureDefaults(base string) error { "var": "OPENCODE_CONFIG_CONTENT" }, "ready_signal": { "idle_ms": 1000 }, + "idle_detection": { + "strategy": "output_activity", + "idle_threshold_ms": 2000 + }, "chrome_trim_hints": [ "^\\s*█", "^\\s*opencode v", diff --git a/internal/vt/emulator.go b/internal/vt/emulator.go index dd825bd..9f551ab 100644 --- a/internal/vt/emulator.go +++ b/internal/vt/emulator.go @@ -57,6 +57,11 @@ type Emulator interface { // ActiveScreen reports whether we are on the primary or alternate buffer. ActiveScreen() (Screen, error) + // Title returns the most recently set window title (OSC 0/2). Returns + // an empty string if no title has been set. Used by idle detection + // for the osc_title_stability and osc_title_status strategies. + Title() (string, error) + // ScrollViewportTop moves the viewport to the top of the scrollback. ScrollViewportTop() error diff --git a/internal/vt/ghostty.go b/internal/vt/ghostty.go index 9aaaeda..b8cb376 100644 --- a/internal/vt/ghostty.go +++ b/internal/vt/ghostty.go @@ -544,6 +544,27 @@ func (e *GhosttyEmulator) Cursor() (CursorState, error) { return CursorState{Col: uint16(col), Row: uint16(row), Visible: bool(visible)}, nil } +// Title returns the most recent window title set by OSC 0/2 escape +// sequences. The libghostty-vt API hands back a borrowed pointer that +// stays valid only until the next vt_write/reset, so we copy out to a +// Go string under the same mutex that gates writes. An empty string +// (len=0) means no title has been set. +func (e *GhosttyEmulator) Title() (string, error) { + e.mu.Lock() + defer e.mu.Unlock() + if e.closed { + return "", errors.New("vt: emulator closed") + } + var s C.GhosttyString + if rc := C.ghostty_terminal_get(e.term, C.GHOSTTY_TERMINAL_DATA_TITLE, unsafe.Pointer(&s)); rc != C.GHOSTTY_SUCCESS { + return "", fmt.Errorf("vt: get title failed: %s", ghosttyResultStr(rc)) + } + if s.ptr == nil || s.len == 0 { + return "", nil + } + return C.GoStringN((*C.char)(unsafe.Pointer(s.ptr)), C.int(s.len)), nil +} + func (e *GhosttyEmulator) ActiveScreen() (Screen, error) { e.mu.Lock() defer e.mu.Unlock() diff --git a/internal/vt/ghostty_nocgo.go b/internal/vt/ghostty_nocgo.go index 435523a..f31129f 100644 --- a/internal/vt/ghostty_nocgo.go +++ b/internal/vt/ghostty_nocgo.go @@ -24,6 +24,7 @@ func (e *GhosttyEmulator) SerializeVT() ([]byte, error) { return nil, errStub func (e *GhosttyEmulator) StyledScreenVT() ([]byte, error) { return nil, errStub } func (e *GhosttyEmulator) Cursor() (CursorState, error) { return CursorState{}, errStub } func (e *GhosttyEmulator) ActiveScreen() (Screen, error) { return 0, errStub } +func (e *GhosttyEmulator) Title() (string, error) { return "", errStub } func (e *GhosttyEmulator) ScrollViewportTop() error { return errStub } func (e *GhosttyEmulator) ScrollViewportBottom() error { return errStub } func (e *GhosttyEmulator) ScrollViewportDelta(int) error { return errStub }