Skip to content
This repository was archived by the owner on Nov 17, 2025. It is now read-only.

Commit 489f552

Browse files
committed
fixes
1 parent e9d9490 commit 489f552

4 files changed

Lines changed: 253 additions & 37 deletions

File tree

Features/Monitoring/Domain/Services/ClaudeMonitorService.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,16 +132,27 @@ public final class ClaudeMonitorService: ObservableObject, Sendable, Loggable {
132132
private func performMonitoringCycle() async {
133133
logger.debug("Starting monitoring cycle")
134134

135+
// Clear the window cache to ensure fresh matching
136+
statusExtractor.clearWindowCache()
137+
135138
// Step 1: Detect running Claude processes
136139
let detectedInstances = await processDetector.detectClaudeInstances()
137140
logger.debug("Process detector found \(detectedInstances.count) instances")
138141

139142
// Step 2: Extract current activity status for each instance
140143
var updatedInstances: [ClaudeInstance] = []
141144

145+
// Log all detected instances for debugging
146+
logger.info("Detected instances:")
147+
for (index, instance) in detectedInstances.enumerated() {
148+
logger.info(" [\(index)] PID: \(instance.pid), TTY: \(instance.ttyPath), Dir: \(instance.workingDirectory), Folder: \(instance.folderName)")
149+
}
150+
142151
for instance in detectedInstances {
143152
let currentActivity = await statusExtractor.extractStatus(for: instance)
144153

154+
logger.info("Instance \(instance.folderName) (PID: \(instance.pid)) status: '\(currentActivity.text)'")
155+
145156
let updatedInstance = ClaudeInstance(
146157
id: instance.id,
147158
pid: instance.pid,

Features/Monitoring/Domain/Services/ClaudeProcessDetector.swift

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -388,14 +388,27 @@ final class ClaudeProcessDetector: Loggable, @unchecked Sendable {
388388
guard let output = String(data: data, encoding: .utf8) else { return "" }
389389

390390
// Look for lines starting with "n" that contain tty/pts
391+
var ttyPaths: [String] = []
391392
for line in output.components(separatedBy: "\n") {
392393
if line.hasPrefix("n") && (line.contains("/dev/ttys") || line.contains("/dev/pts/") || line.contains("/dev/tty")) {
393394
let ttyPath = String(line.dropFirst()) // Remove the "n" prefix
394-
if ttyPath.hasPrefix("/dev/") {
395-
return ttyPath
395+
if ttyPath.hasPrefix("/dev/") && !ttyPath.contains("tty.") && !ttyPath.contains("ptmx") { // Exclude serial ports and ptmx
396+
ttyPaths.append(ttyPath)
396397
}
397398
}
398399
}
400+
401+
// Return the first TTY that looks like a terminal (prefer ttys over pts)
402+
if let ttys = ttyPaths.first(where: { $0.contains("/dev/ttys") }) {
403+
logger.debug("Found TTY for PID \(pid): \(ttys)")
404+
return ttys
405+
} else if let pts = ttyPaths.first(where: { $0.contains("/dev/pts/") }) {
406+
logger.debug("Found PTS for PID \(pid): \(pts)")
407+
return pts
408+
} else if let tty = ttyPaths.first {
409+
logger.debug("Found TTY for PID \(pid): \(tty)")
410+
return tty
411+
}
399412
} catch {
400413
logger.debug("lsof failed for PID \(pid): \(error)")
401414
}

Features/Monitoring/Domain/Services/ClaudeStatusExtractor.swift

Lines changed: 200 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,66 @@ import Vision
55
import CoreImage
66
import 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

Comments
 (0)