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

Commit 4c731a7

Browse files
committed
Work on Claude detection
1 parent 636eace commit 4c731a7

6 files changed

Lines changed: 411 additions & 253 deletions

File tree

Features/Monitoring/Domain/Services/ClaudeProcessDetector.swift

Lines changed: 40 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,8 @@ final class ClaudeProcessDetector: Loggable, @unchecked Sendable {
234234
// Format: [4 bytes argc][executable path\0][padding\0s][arg0\0][arg1\0]...
235235
let components = arguments.split(separator: "\0", omittingEmptySubsequences: false)
236236

237+
logger.debug("Checking \(components.count) components for Claude indicators")
238+
237239
var foundNode = false
238240
var nodeIndex = -1
239241

@@ -245,49 +247,66 @@ final class ClaudeProcessDetector: Loggable, @unchecked Sendable {
245247
if componentStr.contains("/bin/node") || componentStr.hasSuffix("/node") || componentStr == "node" {
246248
foundNode = true
247249
nodeIndex = index
250+
logger.debug("Found node at index \(index): \(componentStr)")
248251
break
249252
}
250253
}
251254

252-
// If it's not a Node process, it's not Claude CLI
255+
// If it's not a Node process, check if it's a direct claude command
253256
if !foundNode {
257+
// Check if it's the claude command being run directly
258+
for component in components {
259+
let componentStr = String(component).trimmingCharacters(in: .whitespaces)
260+
if componentStr == "claude" || componentStr.hasSuffix("/claude") {
261+
logger.debug("Found direct claude command: \(componentStr)")
262+
return true
263+
}
264+
}
254265
return false
255266
}
256267

257-
// Look for Claude CLI being executed directly after node
258-
if nodeIndex >= 0 && nodeIndex + 1 < components.count {
259-
// Find the next non-empty component after node
268+
// Look for Claude CLI being executed after node
269+
if nodeIndex >= 0 {
270+
// Check all arguments after node (not just the immediate next one)
260271
for nextIndex in (nodeIndex + 1)..<components.count {
261272
let nextComponent = String(components[nextIndex]).trimmingCharacters(in: .whitespaces)
262273
if !nextComponent.isEmpty {
263-
// Check if this is the Claude CLI script
264-
if nextComponent.hasSuffix("/.claude/local/node_modules/.bin/claude") ||
265-
nextComponent.hasSuffix("\\.claude\\local\\node_modules\\.bin\\claude") {
266-
logger.debug("Found Claude CLI executable: \(nextComponent)")
274+
// Check if this is the Claude CLI script or command
275+
if nextComponent == "claude" ||
276+
nextComponent.hasSuffix("/claude") ||
277+
nextComponent.hasSuffix("/.bin/claude") ||
278+
nextComponent.contains("/.claude/") ||
279+
nextComponent.contains("\\.claude\\") {
280+
logger.debug("Found Claude after node: \(nextComponent)")
281+
return true
282+
}
283+
284+
// Also check for direct invocation patterns
285+
if nextComponent.contains("node_modules") &&
286+
(nextComponent.contains("claude") || nextComponent.contains("@anthropic")) {
287+
logger.debug("Found Claude via node_modules pattern: \(nextComponent)")
267288
return true
268289
}
269-
// If the first argument after node isn't Claude CLI, it's likely not a Claude process
270-
break
271290
}
272291
}
273292
}
274293

275-
// Strict check: Only accept specific Claude installation path patterns
294+
// Check for specific Claude installation path patterns anywhere in arguments
276295
for pattern in Configuration.claudePathPatterns {
277296
if arguments.contains(pattern) {
278-
// Additional validation: ensure it's actually being executed, not just referenced
279-
let components = arguments.components(separatedBy: pattern)
280-
if components.count > 1 {
281-
// Check if pattern appears early in the arguments (likely being executed)
282-
let beforePattern = components[0]
283-
if beforePattern.count < 1000 { // Arbitrary but reasonable limit
284-
logger.debug("Found Claude via path pattern: \(pattern)")
285-
return true
286-
}
287-
}
297+
logger.debug("Found Claude via path pattern: \(pattern)")
298+
return true
288299
}
289300
}
290301

302+
// Check if arguments contain claude-specific flags or commands
303+
if lowercasedArgs.contains("claude chat") ||
304+
lowercasedArgs.contains("claude code") ||
305+
lowercasedArgs.contains("--dangerously-skip-permissions") {
306+
logger.debug("Found Claude via command pattern")
307+
return true
308+
}
309+
291310
return false
292311
}
293312

Features/Monitoring/Domain/Services/ClaudeStatusExtractor.swift

Lines changed: 105 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -81,11 +81,30 @@ final class ClaudeStatusExtractor: ObservableObject, Loggable {
8181
return nil
8282
}
8383

84-
// Find window that matches our specific Claude instance
84+
// Try to find the window that matches this specific Claude instance
8585
for window in windowsArray {
86+
// Get window title to help with matching
87+
var titleRef: CFTypeRef?
88+
var windowTitle = ""
89+
if AXUIElementCopyAttributeValue(window, kAXTitleAttribute as CFString, &titleRef) == .success,
90+
let title = titleRef as? String {
91+
windowTitle = title
92+
}
93+
94+
// Check if this window matches our instance
95+
if windowMatchesInstance(title: windowTitle, instance: instance) {
96+
if let content = extractTextFromWindow(window),
97+
content.contains("esc to interrupt") {
98+
logger.debug("Found matching window for instance \(instance.folderName) by title")
99+
return content
100+
}
101+
}
102+
103+
// Also check window content for matching
86104
if let content = extractTextFromWindow(window),
87105
windowContainsInstance(content: content, instance: instance),
88106
content.contains("esc to interrupt") {
107+
logger.debug("Found matching window for instance \(instance.folderName) by content")
89108
return content
90109
}
91110
}
@@ -157,16 +176,47 @@ final class ClaudeStatusExtractor: ObservableObject, Loggable {
157176
// MARK: - ScreenCaptureKit-based Extraction
158177

159178
private nonisolated func extractViaScreenCapture(instance: ClaudeInstance) async -> String? {
160-
logger.debug("Attempting ScreenCaptureKit extraction for \(instance.folderName)")
179+
logger.debug("Attempting ScreenCaptureKit extraction for \(instance.folderName) with TTY: \(instance.ttyPath)")
161180

162181
do {
163182
let content = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true)
164183

165-
for window in content.windows {
184+
// Sort windows by how well they match the instance
185+
let matchingWindows = content.windows.compactMap { window -> (window: SCWindow, score: Int)? in
166186
guard let windowTitle = window.title,
167187
let appName = window.owningApplication?.applicationName,
168-
isTerminalApp(appName),
169-
windowMatchesInstance(title: windowTitle, instance: instance) else {
188+
isTerminalApp(appName) else {
189+
return nil
190+
}
191+
192+
var score = 0
193+
194+
// TTY match is most specific (highest score)
195+
if !instance.ttyPath.isEmpty {
196+
let ttyName = URL(fileURLWithPath: instance.ttyPath).lastPathComponent
197+
if windowTitle.contains(ttyName) {
198+
score += 100
199+
}
200+
}
201+
202+
// Full working directory match
203+
if windowTitle.contains(instance.workingDirectory) {
204+
score += 50
205+
}
206+
207+
// Folder name match (less specific)
208+
if instance.folderName.count > 3 && windowTitle.contains(instance.folderName) {
209+
score += 10
210+
}
211+
212+
return score > 0 ? (window, score) : nil
213+
}.sorted { $0.score > $1.score }
214+
215+
// Try the best matching window first
216+
for (window, score) in matchingWindows {
217+
logger.debug("Trying window '\(window.title ?? "")' with match score \(score) for instance \(instance.folderName)")
218+
219+
guard let windowTitle = window.title else {
170220
continue
171221
}
172222

@@ -323,13 +373,59 @@ final class ClaudeStatusExtractor: ObservableObject, Loggable {
323373
}
324374

325375
private nonisolated func windowContainsInstance(content: String, instance: ClaudeInstance) -> Bool {
326-
content.contains(instance.workingDirectory) || content.contains(instance.folderName)
376+
// First check if TTY is mentioned in the content
377+
if !instance.ttyPath.isEmpty {
378+
let ttyName = URL(fileURLWithPath: instance.ttyPath).lastPathComponent
379+
if content.contains(ttyName) {
380+
logger.debug("Window content matches instance by TTY: \(ttyName)")
381+
return true
382+
}
383+
}
384+
385+
// Then check for specific working directory (more specific than just folder name)
386+
if content.contains(instance.workingDirectory) {
387+
logger.debug("Window content matches instance by working directory: \(instance.workingDirectory)")
388+
return true
389+
}
390+
391+
// Last resort: check folder name, but only if it's reasonably unique
392+
if instance.folderName.count > 3 && instance.folderName != "/" &&
393+
content.contains(instance.folderName) {
394+
logger.debug("Window content matches instance by folder name: \(instance.folderName)")
395+
return true
396+
}
397+
398+
return false
327399
}
328400

329401
private nonisolated func windowMatchesInstance(title: String, instance: ClaudeInstance) -> Bool {
330-
title.contains(instance.folderName) ||
331-
title.contains(instance.workingDirectory) ||
332-
title.contains("Claude")
402+
// First check if TTY is in the title (most specific)
403+
if !instance.ttyPath.isEmpty {
404+
let ttyName = URL(fileURLWithPath: instance.ttyPath).lastPathComponent
405+
if title.contains(ttyName) {
406+
logger.debug("Window title matches instance by TTY: \(ttyName)")
407+
return true
408+
}
409+
}
410+
411+
// Check for full working directory path in title
412+
if title.contains(instance.workingDirectory) {
413+
logger.debug("Window title matches instance by working directory: \(instance.workingDirectory)")
414+
return true
415+
}
416+
417+
// Check folder name only if it's specific enough
418+
if instance.folderName.count > 3 && instance.folderName != "/" &&
419+
title.contains(instance.folderName) {
420+
// Make sure it's not just a partial match
421+
let components = title.components(separatedBy: "/")
422+
if components.contains(instance.folderName) {
423+
logger.debug("Window title matches instance by folder name: \(instance.folderName)")
424+
return true
425+
}
426+
}
427+
428+
return false
333429
}
334430

335431
private nonisolated func isTerminalApp(_ appName: String) -> Bool {

Features/StatusBar/Infrastructure/MenuBarIconManager.swift

Lines changed: 47 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -113,53 +113,61 @@ class MenuBarIconManager: ObservableObject {
113113
// MARK: - Private Methods
114114

115115
private func setupDiagnosticsObserver() {
116-
diagnosticsManager.$windowStates
117-
.receive(on: DispatchQueue.main)
118-
.sink { [weak self] windowStates in
119-
guard let self else { return }
116+
// Combined publisher for both Cursor windows and Claude instances
117+
Publishers.CombineLatest(
118+
diagnosticsManager.$windowStates,
119+
ClaudeMonitorService.shared.$instances
120+
)
121+
.receive(on: DispatchQueue.main)
122+
.sink { [weak self] windowStates, claudeInstances in
123+
guard let self else { return }
124+
125+
if !Defaults[.isGlobalMonitoringEnabled] {
126+
self.setState(.paused)
127+
return
128+
}
120129

121-
if !Defaults[.isGlobalMonitoringEnabled] {
122-
self.setState(.paused)
123-
return
130+
var workingCount = 0
131+
var notWorkingCount = 0
132+
var unknownCount = 0
133+
134+
// Count Cursor windows
135+
for (_, windowInfo) in windowStates where windowInfo.isLiveWatchingEnabled {
136+
switch windowInfo.lastAIAnalysisStatus {
137+
case .working:
138+
workingCount += 1
139+
case .notWorking, .error:
140+
notWorkingCount += 1
141+
case .unknown:
142+
unknownCount += 1
143+
case .pending:
144+
unknownCount += 1
145+
case .off:
146+
// Don't count .off status
147+
break
124148
}
125-
126-
var workingCount = 0
127-
var notWorkingCount = 0
128-
var unknownCount = 0
129-
var activeAICount = 0
130-
131-
for (_, windowInfo) in windowStates where windowInfo.isLiveWatchingEnabled {
132-
activeAICount += 1
133-
switch windowInfo.lastAIAnalysisStatus {
134-
case .working:
149+
}
150+
151+
// Count Claude instances if enabled
152+
if Defaults[.enableClaudeMonitoring] {
153+
for instance in claudeInstances {
154+
if instance.currentActivity.type != .idle {
135155
workingCount += 1
136-
case .notWorking:
156+
} else {
137157
notWorkingCount += 1
138-
case .unknown:
139-
unknownCount += 1
140-
case .pending, .error, .off:
141-
// Potentially count pending/error as unknown for icon simplicity
142-
// or handle them distinctly if the icon design allows.
143-
// For now, let's treat pending as unknown for the icon.
144-
if windowInfo.lastAIAnalysisStatus == .pending {
145-
unknownCount += 1
146-
}
147-
// .error and .off don't contribute to active counts for icon display
148158
}
149159
}
160+
}
150161

151-
if activeAICount == 0 && Defaults[.isGlobalMonitoringEnabled] {
152-
self.setState(.idle) // No windows actively AI-watched, but monitoring is on
153-
} else if workingCount > 0 || notWorkingCount > 0 || unknownCount > 0 {
154-
self.setState(.aiStatus(working: workingCount, notWorking: notWorkingCount, unknown: unknownCount))
155-
} else if Defaults[.isGlobalMonitoringEnabled] {
156-
// AI watching enabled, but no specific statuses yet (e.g. all off or error)
157-
self.setState(.idle) // Or a more specific "no AI targets" state
158-
} else {
159-
self.setState(.paused) // Fallback if global monitoring is off
160-
}
162+
if workingCount == 0 && notWorkingCount == 0 && unknownCount == 0 {
163+
self.setState(.idle) // No instances detected, but monitoring is on
164+
} else if workingCount > 0 || notWorkingCount > 0 || unknownCount > 0 {
165+
self.setState(.aiStatus(working: workingCount, notWorking: notWorkingCount, unknown: unknownCount))
166+
} else {
167+
self.setState(.idle)
161168
}
162-
.store(in: &cancellables)
169+
}
170+
.store(in: &cancellables)
163171
}
164172

165173
private func updateIconAttributedString(for state: StatusIconState) {

0 commit comments

Comments
 (0)