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 delete(m.timers, id) 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 delete(m.timers, id) 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 var firedIDs []string 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, }) firedIDs = append(firedIDs, t.id) 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, }) firedIDs = append(firedIDs, t.id) } } } for _, id := range firedIDs { delete(m.timers, id) } m.mu.Unlock() for _, f := range fires { m.fireFn(f.owner, f.body, f.label) } } // onChildClosed drops pending timer references to childID. Called // from Session.Close (and the terminal-corpse cleanup in reapChild) // via the session listener bus — a deliberate signal from the host // that childID is gone and the parent is not waiting on it anymore. // // Semantics: // - timers owned by childID are cancelled and deleted: their owner // is gone, so even if defaultFireFn's IsLive guard would no-op // the delivery, the entry has no business surviving a close. // - timers watching childID have childID pruned from t.watched // (and t.idleBaseline). If t.watched becomes empty the timer is // cancelled and deleted; we deliberately do NOT synthesise a // fire here. The parent already received any legitimate idle // transition before close_process — see allWatchedIdleLocked's // "treat as satisfied" comment, which only applies to a // concurrent re-evaluation, not to this explicit-removal hook. // // The natural-exit path (reapChild → emitExit for agent/command // kinds) is NOT routed through here: the classifier emits a final // idle transition on exit, which fires and deletes any watching // timers exactly once. Cancelling on exit would swallow that // legitimate fire and leave the parent never notified. func (m *timerManager) onChildClosed(childID string) { m.mu.Lock() defer m.mu.Unlock() for id, t := range m.timers { if t.ownerID == childID { if t.rt != nil { t.rt.Stop() t.rt = nil } t.status = timerStatusCanceled delete(m.timers, id) continue } if !contains(t.watched, childID) { continue } pruned := t.watched[:0] for _, w := range t.watched { if w != childID { pruned = append(pruned, w) } } t.watched = pruned if t.idleBaseline != nil { delete(t.idleBaseline, childID) } if len(t.watched) == 0 { if t.rt != nil { t.rt.Stop() t.rt = nil } t.status = timerStatusCanceled delete(m.timers, id) } } } // 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 delete(m.timers, id) 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. // // Idle-* timers also re-check their satisfaction condition immediately // on resume: idle transitions that occurred while paused are otherwise // missed (onChildStateChanged only sees future flips), so a child that // went idle during the pause window would never fire the timer. For // idle_any we look for any non-baseline watched child currently idle; // for idle_all we check whether every watched child is now idle. func (m *timerManager) TimerResume(ownerID, id string) error { m.mu.Lock() t, ok := m.timers[id] if !ok { m.mu.Unlock() 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 { m.mu.Unlock() return mcp.Errorf(mcp.ErrorKindRoleForbidden, "timer %q is not owned by caller", id) } if t.status != timerStatusPaused { m.mu.Unlock() 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 } // For idle-* timers, evaluate the condition right now in case a // watched child went idle while paused. var fireNow bool var owner *Child var body, label string switch t.kind { case timerKindIdleAny: for _, wid := range t.watched { if t.idleBaseline[wid] { continue } c := m.sess.FindChild(wid) if c != nil && isIdleState(c.IdleState()) { fireNow = true break } } case timerKindIdleAll: if m.allWatchedIdleLocked(t) { fireNow = true } } if fireNow { t.status = timerStatusFired if t.rt != nil { t.rt.Stop() t.rt = nil } owner = m.sess.FindChild(t.ownerID) body, label = t.body, t.label delete(m.timers, id) } m.mu.Unlock() if fireNow { m.fireFn(owner, body, label) } 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 }