Skip to content

Commit 1115341

Browse files
authored
Stabilize tmux workspace activity detection (#165)
* Stabilize tmux workspace activity detection * Seed fresh-tag activity baseline and bump Go patch
1 parent 80f217e commit 1115341

15 files changed

Lines changed: 1374 additions & 232 deletions

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
module github.com/andyrewlee/amux
22

3-
go 1.24.12
3+
go 1.24.13
44

55
require (
66
charm.land/bubbles/v2 v2.0.0-rc.1.0.20260109112849-ae99f46cec66

internal/app/app_core.go

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -108,17 +108,19 @@ type App struct {
108108
prefixActive bool
109109
prefixToken int
110110

111-
tmuxSyncToken int
112-
tmuxActivityToken int
113-
tmuxOptions tmux.Options
114-
tmuxAvailable bool
115-
tmuxCheckDone bool
116-
projectsLoaded bool
117-
tmuxInstallHint string
118-
tmuxActiveWorkspaceIDs map[string]bool
119-
sessionActivityStates map[string]*sessionActivityState // Per-session hysteresis state
120-
instanceID string
121-
lastTerminalGCRun time.Time
111+
tmuxSyncToken int
112+
tmuxActivityToken int
113+
tmuxActivityScanInFlight bool
114+
tmuxActivityRescanPending bool
115+
tmuxOptions tmux.Options
116+
tmuxAvailable bool
117+
tmuxCheckDone bool
118+
projectsLoaded bool
119+
tmuxInstallHint string
120+
tmuxActiveWorkspaceIDs map[string]bool
121+
sessionActivityStates map[string]*sessionActivityState // Per-session hysteresis state
122+
instanceID string
123+
lastTerminalGCRun time.Time
122124

123125
// Workspace persistence debounce
124126
dirtyWorkspaces map[string]bool

internal/app/app_tmux_activity.go

Lines changed: 49 additions & 206 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import (
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

7480
func (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

346189
func (a *App) handleTmuxAvailableResult(msg tmuxAvailableResult) []tea.Cmd {
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package app
2+
3+
import (
4+
"strings"
5+
"time"
6+
7+
"github.com/andyrewlee/amux/internal/tmux"
8+
)
9+
10+
// seedFreshTagFallbackBaseline initializes hysteresis state for sessions that
11+
// are currently active via fresh tags, so stale fallback doesn't treat them as
12+
// brand-new sessions and blip active on unchanged content.
13+
func seedFreshTagFallbackBaseline(
14+
sessionName string,
15+
states map[string]*sessionActivityState,
16+
updated map[string]*sessionActivityState,
17+
opts tmux.Options,
18+
captureFn func(sessionName string, lines int, opts tmux.Options) (string, bool),
19+
hashFn func(content string) [16]byte,
20+
) {
21+
if states == nil || updated == nil || strings.TrimSpace(sessionName) == "" {
22+
return
23+
}
24+
state := states[sessionName]
25+
if state != nil && state.initialized {
26+
return
27+
}
28+
if state == nil {
29+
state = &sessionActivityState{}
30+
states[sessionName] = state
31+
}
32+
if content, ok := captureFn(sessionName, activityCaptureTail, opts); ok {
33+
state.lastHash = hashFn(content)
34+
}
35+
state.initialized = true
36+
state.score = 0
37+
state.lastActiveAt = time.Time{}
38+
updated[sessionName] = state
39+
}

0 commit comments

Comments
 (0)