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 := stripANSIBytes(nil, c.tailBytes(classifierTailBytes)) var screen []byte if em := c.Emulator(); em != nil { if txt, err := em.ScreenText(); err == nil { screen = []byte(txt) } } state, reason := classify(c.idleDetection, exited, exitNonZero, idleMS, titleIdleMS, title, tail, screen) 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 }