77 tea "charm.land/bubbletea/v2"
88
99 "github.com/andyrewlee/amux/internal/logging"
10- "github.com/andyrewlee/amux/internal/tmux"
1110 "github.com/andyrewlee/amux/internal/ui/common"
1211)
1312
@@ -30,6 +29,13 @@ const (
3029 // decays below threshold naturally.
3130 activityScoreThreshold = 3 // Score needed to be considered active
3231 activityScoreMax = 6 // Maximum score (prevents runaway accumulation)
32+
33+ // activityOutputWindow is how recently output must have occurred to be "active".
34+ activityOutputWindow = 2 * time .Second
35+ // activityInputEchoWindow treats output immediately after input as likely local echo.
36+ activityInputEchoWindow = 400 * time .Millisecond
37+ // activityInputSuppressWindow suppresses fallback capture right after user input.
38+ activityInputSuppressWindow = 2 * time .Second
3339)
3440
3541// sessionActivityState tracks per-session activity using screen-delta hysteresis.
@@ -72,6 +78,12 @@ func (a *App) triggerTmuxActivityScan() tea.Cmd {
7278}
7379
7480func (a * App ) scanTmuxActivityNow () tea.Cmd {
81+ if a .tmuxActivityScanInFlight {
82+ a .tmuxActivityRescanPending = true
83+ return nil
84+ }
85+ a .tmuxActivityScanInFlight = true
86+ a .tmuxActivityRescanPending = false
7587 a .tmuxActivityToken ++
7688 scanToken := a .tmuxActivityToken
7789 infoBySession := a .tabSessionInfoByName ()
@@ -85,11 +97,16 @@ func (a *App) scanTmuxActivityNow() tea.Cmd {
8597 if svc == nil {
8698 return tmuxActivityResult {Token : scanToken , Err : errTmuxUnavailable }
8799 }
88- sessions , err := svc . ActiveAgentSessionsByActivity ( tmuxActivityPrefilter , opts )
100+ sessions , err := fetchTaggedSessions ( svc , infoBySession , opts )
89101 if err != nil {
90102 return tmuxActivityResult {Token : scanToken , Err : err }
91103 }
92- active , updatedStates := activeWorkspaceIDsWithHysteresis (infoBySession , sessions , statesSnapshot , opts , svc .CapturePaneTail , svc .ContentHash )
104+ recentActivityBySession , err := fetchRecentlyActiveAgentSessionsByWindow (svc , opts )
105+ if err != nil {
106+ logging .Warn ("tmux activity prefilter failed; using unbounded stale-tag fallback: %v" , err )
107+ recentActivityBySession = nil
108+ }
109+ active , updatedStates := activeWorkspaceIDsFromTags (infoBySession , sessions , recentActivityBySession , statesSnapshot , opts , svc .CapturePaneTail , svc .ContentHash )
93110 return tmuxActivityResult {Token : scanToken , ActiveWorkspaceIDs : active , UpdatedStates : updatedStates }
94111 }
95112}
@@ -101,6 +118,12 @@ func (a *App) handleTmuxActivityTick(msg tmuxActivityTick) []tea.Cmd {
101118 if ! a .tmuxAvailable {
102119 return []tea.Cmd {a .scheduleTmuxActivityTick ()}
103120 }
121+ if a .tmuxActivityScanInFlight {
122+ a .tmuxActivityRescanPending = true
123+ return []tea.Cmd {a .scheduleTmuxActivityTick ()}
124+ }
125+ a .tmuxActivityScanInFlight = true
126+ a .tmuxActivityRescanPending = false
104127 // Increment token for this scan so out-of-order results are rejected.
105128 // Each scan gets a unique token; only the most recent result is applied.
106129 a .tmuxActivityToken ++
@@ -116,11 +139,16 @@ func (a *App) handleTmuxActivityTick(msg tmuxActivityTick) []tea.Cmd {
116139 if svc == nil {
117140 return tmuxActivityResult {Token : scanToken , Err : errTmuxUnavailable }
118141 }
119- sessions , err := svc . ActiveAgentSessionsByActivity ( tmuxActivityPrefilter , opts )
142+ sessions , err := fetchTaggedSessions ( svc , sessionInfo , opts )
120143 if err != nil {
121144 return tmuxActivityResult {Token : scanToken , Err : err }
122145 }
123- active , updatedStates := activeWorkspaceIDsWithHysteresis (sessionInfo , sessions , statesSnapshot , opts , svc .CapturePaneTail , svc .ContentHash )
146+ recentActivityBySession , err := fetchRecentlyActiveAgentSessionsByWindow (svc , opts )
147+ if err != nil {
148+ logging .Warn ("tmux activity prefilter failed; using unbounded stale-tag fallback: %v" , err )
149+ recentActivityBySession = nil
150+ }
151+ active , updatedStates := activeWorkspaceIDsFromTags (sessionInfo , sessions , recentActivityBySession , statesSnapshot , opts , svc .CapturePaneTail , svc .ContentHash )
124152 return tmuxActivityResult {Token : scanToken , ActiveWorkspaceIDs : active , UpdatedStates : updatedStates }
125153 }}
126154 return cmds
@@ -131,216 +159,31 @@ func (a *App) handleTmuxActivityResult(msg tmuxActivityResult) []tea.Cmd {
131159 // Stale result from an older scan; ignore to avoid overwriting newer state
132160 return nil
133161 }
162+ a .tmuxActivityScanInFlight = false
134163 var cmds []tea.Cmd
135164 if msg .Err != nil {
136165 logging .Warn ("tmux activity scan failed: %v" , msg .Err )
137- return cmds
138- }
139- if msg .ActiveWorkspaceIDs == nil {
140- msg .ActiveWorkspaceIDs = make (map [string ]bool )
141- }
142- // Merge updated hysteresis states back into the main map (on main thread)
143- for name , state := range msg .UpdatedStates {
144- a .sessionActivityStates [name ] = state
145- }
146- a .tmuxActiveWorkspaceIDs = msg .ActiveWorkspaceIDs
147- a .syncActiveWorkspacesToDashboard ()
148- if cmd := a .dashboard .StartSpinnerIfNeeded (); cmd != nil {
149- cmds = append (cmds , cmd )
150- }
151- return cmds
152- }
153-
154- type tabSessionInfo struct {
155- Status string
156- WorkspaceID string
157- Assistant string
158- IsChat bool
159- }
160-
161- // Concurrency safety: builds the map synchronously in the Update loop.
162- // Goroutine closures capture only the returned map, never accessing
163- // a.projects or ws.OpenTabs directly.
164- func (a * App ) tabSessionInfoByName () map [string ]tabSessionInfo {
165- infoBySession := make (map [string ]tabSessionInfo )
166- assistants := map [string ]struct {}{}
167- if a .config != nil {
168- for name := range a .config .Assistants {
169- assistants [name ] = struct {}{}
166+ } else {
167+ if msg .ActiveWorkspaceIDs == nil {
168+ msg .ActiveWorkspaceIDs = make (map [string ]bool )
170169 }
171- }
172- for _ , project := range a .projects {
173- for i := range project .Workspaces {
174- ws := & project .Workspaces [i ]
175- for _ , tab := range ws .OpenTabs {
176- name := strings .TrimSpace (tab .SessionName )
177- if name == "" {
178- continue
179- }
180- status := strings .ToLower (strings .TrimSpace (tab .Status ))
181- if status == "" {
182- status = "running"
183- }
184- assistant := strings .TrimSpace (tab .Assistant )
185- _ , isChat := assistants [assistant ]
186- infoBySession [name ] = tabSessionInfo {
187- Status : status ,
188- WorkspaceID : string (ws .ID ()),
189- Assistant : assistant ,
190- IsChat : isChat ,
191- }
192- }
170+ // Merge updated hysteresis states back into the main map (on main thread)
171+ for name , state := range msg .UpdatedStates {
172+ a .sessionActivityStates [name ] = state
193173 }
194- }
195- return infoBySession
196- }
197-
198- // activeWorkspaceIDsWithHysteresis uses screen-delta detection with hysteresis
199- // to determine which workspaces have actively working agents. This prevents
200- // false positives from periodic terminal refreshes (like sponsor messages).
201- // Returns both the active workspace IDs and the updated session states.
202- func activeWorkspaceIDsWithHysteresis (
203- infoBySession map [string ]tabSessionInfo ,
204- sessions []tmux.SessionActivity ,
205- states map [string ]* sessionActivityState ,
206- opts tmux.Options ,
207- captureFn func (sessionName string , lines int , opts tmux.Options ) (string , bool ),
208- hashFn func (content string ) [16 ]byte ,
209- ) (map [string ]bool , map [string ]* sessionActivityState ) {
210- active := make (map [string ]bool )
211- updatedStates := make (map [string ]* sessionActivityState )
212- now := time .Now ()
213-
214- // Track which sessions we see in this scan
215- seenSessions := make (map [string ]bool , len (sessions ))
216-
217- for _ , session := range sessions {
218- seenSessions [session .Name ] = true
219- info , ok := infoBySession [session .Name ]
220- if ! isChatSession (session , info , ok ) {
221- continue
222- }
223-
224- // Get or create state for this session
225- state := states [session .Name ]
226- if state == nil {
227- state = & sessionActivityState {}
228- }
229-
230- // Capture pane content and compute hash
231- content , captureOK := captureFn (session .Name , activityCaptureTail , opts )
232- if captureOK {
233- hash := hashFn (content )
234-
235- // Update hysteresis score based on content change
236- if ! state .initialized {
237- // First time seeing this session — treat as active immediately.
238- // If it stops generating output, hysteresis decay will clear it.
239- state .lastHash = hash
240- state .initialized = true
241- state .score = activityScoreThreshold
242- state .lastActiveAt = now
243- } else if hash != state .lastHash {
244- // Content changed - bump score
245- state .score += 2
246- if state .score > activityScoreMax {
247- state .score = activityScoreMax
248- }
249- state .lastHash = hash
250- // Only update lastActiveAt when crossing the active threshold,
251- // so hold duration doesn't apply to single changes below threshold
252- if state .score >= activityScoreThreshold {
253- state .lastActiveAt = now
254- }
255- } else {
256- // No change - decay score
257- state .score --
258- if state .score < 0 {
259- state .score = 0
260- }
261- }
262- } else {
263- // Capture failed - decay score to prevent stale "active" states
264- // from persisting when capture keeps failing
265- state .score --
266- if state .score < 0 {
267- state .score = 0
268- }
269- }
270-
271- // Track updated state for merging back on main thread
272- updatedStates [session .Name ] = state
273-
274- // Determine if session is active based on score and hold duration
275- isActive := state .score >= activityScoreThreshold
276- if ! isActive && ! state .lastActiveAt .IsZero () {
277- // Check hold duration - stay active for a bit after last change
278- if now .Sub (state .lastActiveAt ) < activityHoldDuration {
279- isActive = true
280- }
281- }
282-
283- if isActive {
284- workspaceID := strings .TrimSpace (session .WorkspaceID )
285- if workspaceID == "" && ok {
286- workspaceID = strings .TrimSpace (info .WorkspaceID )
287- }
288- if workspaceID == "" {
289- workspaceID = workspaceIDFromSessionName (session .Name )
290- }
291- if workspaceID != "" {
292- active [workspaceID ] = true
293- }
174+ a .tmuxActiveWorkspaceIDs = msg .ActiveWorkspaceIDs
175+ a .syncActiveWorkspacesToDashboard ()
176+ if cmd := a .dashboard .StartSpinnerIfNeeded (); cmd != nil {
177+ cmds = append (cmds , cmd )
294178 }
295179 }
296-
297- // Decay/reset states for sessions not seen in this scan.
298- // This prevents stale scores from persisting when a session falls out of
299- // the prefilter window (>120s idle) and then reappears with a single refresh.
300- for name , state := range states {
301- if seenSessions [name ] {
302- continue // Already processed above
180+ if a .tmuxActivityRescanPending && a .tmuxAvailable {
181+ a .tmuxActivityRescanPending = false
182+ if scanCmd := a .scanTmuxActivityNow (); scanCmd != nil {
183+ cmds = append (cmds , scanCmd )
303184 }
304- // Reset score and baseline so stale hashes/hold timers don't trigger
305- // false positives when a session re-enters the prefilter window.
306- state .score = 0
307- state .lastActiveAt = time.Time {}
308- state .initialized = false
309- state .lastHash = [16 ]byte {}
310- updatedStates [name ] = state
311- }
312-
313- return active , updatedStates
314- }
315-
316- // isChatSession determines whether a tmux session represents an active AI agent.
317- //
318- // Detection priority:
319- // 1. Session tag (@amux_type == "agent") — authoritative, set at creation time.
320- // 2. Stored tab metadata (info.IsChat) — from assistant config lookup.
321- // 3. Name heuristic (legacy fallback) — matches "amux-*-tab-*" sessions,
322- // excluding terminal tabs ("term-tab-"). Only used for sessions tagged
323- // with @amux but missing @amux_type (older versions), to avoid false
324- // positives from unrelated tmux sessions.
325- func isChatSession (session tmux.SessionActivity , info tabSessionInfo , hasInfo bool ) bool {
326- if session .Type != "" {
327- return session .Type == "agent"
328- }
329- if hasInfo {
330- return info .IsChat
331- }
332- if ! session .Tagged {
333- return false
334185 }
335- // Legacy fallback for untagged sessions (pre-tagging era).
336- name := session .Name
337- if ! strings .HasPrefix (name , "amux-" ) {
338- return false
339- }
340- if strings .Contains (name , "term-tab-" ) {
341- return false
342- }
343- return strings .Contains (name , "-tab-" )
186+ return cmds
344187}
345188
346189func (a * App ) handleTmuxAvailableResult (msg tmuxAvailableResult ) []tea.Cmd {
0 commit comments