@@ -5,6 +5,66 @@ import Vision
55import CoreImage
66import Diagnostics
77
8+ // MARK: - Window Cache Management
9+
10+ /// A more robust window cache that tracks both claimed windows and instance-to-window mappings
11+ @MainActor
12+ private final class WindowCache {
13+ private var windowToInstance : [ String : UUID ] = [ : ]
14+ private var instanceToWindow : [ UUID : String ] = [ : ]
15+ private let logger = Logger ( category: . supervision)
16+
17+ /// Check if a window is available for an instance
18+ func isWindowAvailable( _ windowID: String , for instanceID: UUID ) -> Bool {
19+ if let claimedBy = windowToInstance [ windowID] {
20+ return claimedBy == instanceID
21+ }
22+ return true
23+ }
24+
25+ /// Check if an instance already has a window
26+ func hasWindow( instanceID: UUID ) -> Bool {
27+ return instanceToWindow [ instanceID] != nil
28+ }
29+
30+ /// Get the window ID for an instance
31+ func getWindow( for instanceID: UUID ) -> String ? {
32+ return instanceToWindow [ instanceID]
33+ }
34+
35+ /// Claim a window for an instance
36+ func claimWindow( _ windowID: String , for instanceID: UUID ) {
37+ // If instance already has a window, release it first
38+ if let existingWindow = instanceToWindow [ instanceID] {
39+ windowToInstance. removeValue ( forKey: existingWindow)
40+ logger. debug ( " Released previous window ' \( existingWindow) ' from instance \( instanceID) " )
41+ }
42+
43+ // If window is already claimed by another instance, don't allow
44+ if let existingInstance = windowToInstance [ windowID] , existingInstance != instanceID {
45+ logger. warning ( " Window ' \( windowID) ' is already claimed by instance \( existingInstance) , cannot claim for \( instanceID) " )
46+ return
47+ }
48+
49+ // Claim the window
50+ windowToInstance [ windowID] = instanceID
51+ instanceToWindow [ instanceID] = windowID
52+ logger. debug ( " Claimed window ' \( windowID) ' for instance \( instanceID) " )
53+ }
54+
55+ /// Clear all mappings
56+ func clear( ) {
57+ logger. debug ( " Clearing window cache with \( windowToInstance. count) windows and \( instanceToWindow. count) instances " )
58+ windowToInstance. removeAll ( )
59+ instanceToWindow. removeAll ( )
60+ }
61+
62+ /// Get debug info
63+ var debugDescription : String {
64+ return " WindowCache: \( windowToInstance. count) windows mapped to \( instanceToWindow. count) instances "
65+ }
66+ }
67+
868// MARK: - Claude Status Extraction Service
969
1070/// Dedicated service for extracting live Claude status from terminal windows
@@ -21,6 +81,11 @@ final class ClaudeStatusExtractor: ObservableObject, Loggable {
2181 static let supportedTerminals = [ " ghostty " , " terminal " , " iterm " , " warp " ]
2282 }
2383
84+ // MARK: - Private State
85+
86+ /// Robust window cache to prevent duplicate matches
87+ private let windowCache = WindowCache ( )
88+
2489 // MARK: - Public API
2590
2691 /// Extract Claude status for a specific instance using multiple methods
@@ -46,6 +111,11 @@ final class ClaudeStatusExtractor: ObservableObject, Loggable {
46111 return . idle
47112 }
48113
114+ /// Clear the window cache (should be called at the start of each monitoring cycle)
115+ func clearWindowCache( ) {
116+ windowCache. clear ( )
117+ }
118+
49119 // MARK: - Accessibility-based Extraction
50120
51121 private nonisolated func extractViaAccessibility( instance: ClaudeInstance ) async -> String ? {
@@ -60,7 +130,7 @@ final class ClaudeStatusExtractor: ObservableObject, Loggable {
60130 }
61131
62132 for terminalApp in terminalApps {
63- if let content = getTerminalContent ( terminalPID: terminalApp. processIdentifier, instance: instance) {
133+ if let content = await getTerminalContent ( terminalPID: terminalApp. processIdentifier, instance: instance) {
64134 if let parsedStatus = parseClaudeStatus ( from: content) {
65135 return parsedStatus
66136 }
@@ -70,7 +140,7 @@ final class ClaudeStatusExtractor: ObservableObject, Loggable {
70140 return nil
71141 }
72142
73- private nonisolated func getTerminalContent( terminalPID: pid_t , instance: ClaudeInstance ) -> String ? {
143+ private nonisolated func getTerminalContent( terminalPID: pid_t , instance: ClaudeInstance ) async -> String ? {
74144 let app = AXUIElementCreateApplication ( terminalPID)
75145
76146 var windowsRef : CFTypeRef ?
@@ -81,7 +151,18 @@ final class ClaudeStatusExtractor: ObservableObject, Loggable {
81151 return nil
82152 }
83153
154+ // Check if instance already has a window
155+ let hasExistingWindow = await MainActor . run { self . windowCache. hasWindow ( instanceID: instance. id) }
156+ if hasExistingWindow {
157+ // If instance already has a window, try to use the same window
158+ if let existingWindowID = await MainActor . run { self . windowCache. getWindow ( for: instance. id) } {
159+ logger. debug ( " Instance \( instance. folderName) already has window \( existingWindowID) , checking if still valid " )
160+ }
161+ }
162+
84163 // Try to find the window that matches this specific Claude instance
164+ var bestMatch : ( window: AXUIElement , content: String , score: Int ) ?
165+
85166 for window in windowsArray {
86167 // Get window title to help with matching
87168 var titleRef : CFTypeRef ?
@@ -91,24 +172,81 @@ final class ClaudeStatusExtractor: ObservableObject, Loggable {
91172 windowTitle = title
92173 }
93174
94- // Check if this window matches our instance
95- if windowMatchesInstance ( title: windowTitle, instance: instance) {
175+ // Create window ID for cache checking
176+ let windowID = " \( Unmanaged . passUnretained ( window) . toOpaque ( ) ) _ \( windowTitle) "
177+
178+ // Skip if already claimed by another instance
179+ let isAvailable = await MainActor . run { self . windowCache. isWindowAvailable ( windowID, for: instance. id) }
180+ if !isAvailable {
181+ continue
182+ }
183+
184+ // Calculate match score
185+ var score = 0
186+
187+ // TTY match is highest priority - should be unique
188+ if !instance. ttyPath. isEmpty && !windowTitle. isEmpty {
189+ let ttyName = URL ( fileURLWithPath: instance. ttyPath) . lastPathComponent
190+ if windowTitle. contains ( ttyName) {
191+ score += 1000 // Very high score for TTY match
192+ logger. debug ( " Window ' \( windowTitle) ' matches TTY \( ttyName) for instance \( instance. folderName) " )
193+ }
194+ }
195+
196+ // Exact working directory match (more specific than partial)
197+ if windowTitle == instance. workingDirectory {
198+ score += 200
199+ } else if windowTitle. contains ( instance. workingDirectory) {
200+ // Prefer longer matches (more specific paths)
201+ let matchRatio = Double ( instance. workingDirectory. count) / Double( windowTitle. count)
202+ score += Int ( 50 * matchRatio)
203+ }
204+
205+ // Folder name match - but only if it's specific enough
206+ if instance. folderName. count > 3 && instance. folderName != " / " && windowTitle. contains ( instance. folderName) {
207+ // Check if it's a complete word match (not partial)
208+ let components = windowTitle. components ( separatedBy: CharacterSet ( charactersIn: " / " ) )
209+ if components. contains ( instance. folderName) {
210+ score += 30
211+ } else {
212+ score += 10
213+ }
214+ }
215+
216+ // Only consider windows with some match
217+ if score > 0 {
218+ // Check if this window has relevant content
96219 if let content = extractTextFromWindow ( window) ,
97220 let recentContent = extractRecentTerminalContent ( content) ,
98221 recentContent. contains ( " esc to interrupt " ) {
99- logger. debug ( " Found matching window for instance \( instance. folderName) by title with recent activity " )
100- return recentContent
222+
223+ // Keep track of the best match
224+ if bestMatch == nil || score > bestMatch!. score {
225+ bestMatch = ( window, recentContent, score)
226+ }
101227 }
102228 }
229+ }
230+
231+ // Return the best match if found
232+ if let match = bestMatch {
233+ logger. debug ( " Found best matching window for instance \( instance. folderName) with score \( match. score) " )
234+
235+ // Get window title for cache key
236+ var titleRef : CFTypeRef ?
237+ var windowTitle = " "
238+ if AXUIElementCopyAttributeValue ( match. window, kAXTitleAttribute as CFString , & titleRef) == . success,
239+ let title = titleRef as? String {
240+ windowTitle = title
241+ }
103242
104- // Also check window content for matching
105- if let content = extractTextFromWindow ( window) ,
106- windowContainsInstance ( content: content, instance: instance) ,
107- let recentContent = extractRecentTerminalContent ( content) ,
108- recentContent. contains ( " esc to interrupt " ) {
109- logger. debug ( " Found matching window for instance \( instance. folderName) by content with recent activity " )
110- return recentContent
243+ // Update cache on main actor with consistent window ID
244+ let windowID = " \( Unmanaged . passUnretained ( match. window) . toOpaque ( ) ) _ \( windowTitle) "
245+ await MainActor . run {
246+ self . windowCache. claimWindow ( windowID, for: instance. id)
111247 }
248+
249+ return match. content
112250 }
113251
114252 return nil
@@ -180,15 +318,30 @@ final class ClaudeStatusExtractor: ObservableObject, Loggable {
180318 private nonisolated func extractViaScreenCapture( instance: ClaudeInstance ) async -> String ? {
181319 logger. debug ( " Attempting ScreenCaptureKit extraction for \( instance. folderName) with TTY: \( instance. ttyPath) " )
182320
321+ // Check if instance already has a window from previous cycle
322+ let hasExistingWindow = await MainActor . run { self . windowCache. hasWindow ( instanceID: instance. id) }
323+
183324 do {
184325 let content = try await SCShareableContent . excludingDesktopWindows ( false , onScreenWindowsOnly: true )
185326
186- // Sort windows by how well they match the instance
187- let matchingWindows = content. windows. compactMap { window -> ( window: SCWindow , score: Int ) ? in
327+ // Pre-filter terminal windows
328+ var candidateWindows : [ ( window: SCWindow , score: Int , windowID: String ) ] = [ ]
329+
330+ for window in content. windows {
188331 guard let windowTitle = window. title,
189332 let appName = window. owningApplication? . applicationName,
190333 isTerminalApp ( appName) else {
191- return nil
334+ continue
335+ }
336+
337+ // Create a unique window ID
338+ let windowID = " \( window. windowID) _ \( windowTitle) "
339+
340+ // Check if this window is already claimed by another instance
341+ let isAvailable = await MainActor . run { self . windowCache. isWindowAvailable ( windowID, for: instance. id) }
342+ if !isAvailable {
343+ logger. debug ( " Window ' \( windowTitle) ' already claimed by another instance, skipping " )
344+ continue
192345 }
193346
194347 var score = 0
@@ -201,30 +354,53 @@ final class ClaudeStatusExtractor: ObservableObject, Loggable {
201354 }
202355 }
203356
204- // Full working directory match
205- if windowTitle. contains ( instance. workingDirectory) {
357+ // Full working directory match (exact match preferred)
358+ if windowTitle == instance. workingDirectory {
359+ score += 80
360+ } else if windowTitle. contains ( instance. workingDirectory) {
206361 score += 50
207362 }
208363
209- // Folder name match (less specific)
210- if instance. folderName. count > 3 && windowTitle. contains ( instance. folderName) {
211- score += 10
364+ // Folder name match (less specific, require word boundaries)
365+ if instance. folderName. count > 3 && instance. folderName != " / " {
366+ // Check for exact folder name match with word boundaries
367+ let components = windowTitle. components ( separatedBy: " / " )
368+ if components. contains ( instance. folderName) {
369+ score += 20
370+ } else if windowTitle. contains ( instance. folderName) {
371+ score += 10
372+ }
212373 }
213374
214- return score > 0 ? ( window, score) : nil
215- } . sorted { $0. score > $1. score }
375+ if score > 0 {
376+ candidateWindows. append ( ( window, score, windowID) )
377+ }
378+ }
379+
380+ // Sort by score
381+ let matchingWindows = candidateWindows. sorted { $0. score > $1. score }
216382
217383 // Try the best matching window first
218- for (window, score) in matchingWindows {
384+ for (window, score, windowID ) in matchingWindows {
219385 logger. debug ( " Trying window ' \( window. title ?? " " ) ' with match score \( score) for instance \( instance. folderName) " )
220386
221387 guard let windowTitle = window. title else {
222388 continue
223389 }
224390
391+ // Only proceed if score is high enough to be confident
392+ if score < 20 {
393+ logger. debug ( " Score too low ( \( score) ) for window ' \( windowTitle) ', skipping " )
394+ continue
395+ }
396+
225397 logger. debug ( " Found matching terminal window: ' \( windowTitle) ' for \( instance. folderName) " )
226398
227399 if let statusText = await captureAndExtractText ( from: window) {
400+ // Update cache on main actor
401+ await MainActor . run {
402+ self . windowCache. claimWindow ( windowID, for: instance. id)
403+ }
228404 return statusText
229405 }
230406 }
0 commit comments