diff --git a/Loom.xcodeproj/project.pbxproj b/Loom.xcodeproj/project.pbxproj index 523ba70..4224262 100644 --- a/Loom.xcodeproj/project.pbxproj +++ b/Loom.xcodeproj/project.pbxproj @@ -1176,7 +1176,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 6; + CURRENT_PROJECT_VERSION = 7; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = K9UNF256S9; ENABLE_HARDENED_RUNTIME = NO; @@ -1194,7 +1194,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 2.1.0; + MARKETING_VERSION = 2.2.0; PRODUCT_BUNDLE_IDENTIFIER = com.chaiwithcode.loom; PRODUCT_NAME = Loom; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1210,7 +1210,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 6; + CURRENT_PROJECT_VERSION = 7; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = K9UNF256S9; ENABLE_HARDENED_RUNTIME = YES; @@ -1228,7 +1228,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 2.1.0; + MARKETING_VERSION = 2.2.0; PRODUCT_BUNDLE_IDENTIFIER = com.chaiwithcode.loom; PRODUCT_NAME = Loom; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1328,7 +1328,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 6; + CURRENT_PROJECT_VERSION = 7; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = K9UNF256S9; ENABLE_HARDENED_RUNTIME = NO; @@ -1344,7 +1344,7 @@ "@executable_path/../Frameworks", "@executable_path/../../../../Frameworks", ); - MARKETING_VERSION = 2.1.0; + MARKETING_VERSION = 2.2.0; PRODUCT_BUNDLE_IDENTIFIER = com.chaiwithcode.loom.widget; PRODUCT_NAME = LoomWidgetExtension; SKIP_INSTALL = YES; @@ -1361,7 +1361,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 6; + CURRENT_PROJECT_VERSION = 7; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = K9UNF256S9; ENABLE_HARDENED_RUNTIME = YES; @@ -1377,7 +1377,7 @@ "@executable_path/../Frameworks", "@executable_path/../../../../Frameworks", ); - MARKETING_VERSION = 2.1.0; + MARKETING_VERSION = 2.2.0; PRODUCT_BUNDLE_IDENTIFIER = com.chaiwithcode.loom.widget; PRODUCT_NAME = LoomWidgetExtension; SKIP_INSTALL = YES; diff --git a/Loom/App/NotificationCoordinator.swift b/Loom/App/NotificationCoordinator.swift index e939162..89a2f43 100644 --- a/Loom/App/NotificationCoordinator.swift +++ b/Loom/App/NotificationCoordinator.swift @@ -52,6 +52,10 @@ public final class NotificationCoordinator: NSObject, UNUserNotificationCenterDe public static let categoryIdentifier = "LOOM_NOTIFICATION_CATEGORY" public static let openActionIdentifier = "LOOM_ACTION_VIEW" + public static let gitHubStarCategoryIdentifier = "LOOM_GITHUB_STAR_CATEGORY" + public static let starOnGitHubActionIdentifier = "STAR_ON_GITHUB_ACTION" + public static let dismissStarActionIdentifier = "DISMISS_STAR_ACTION" + public static let gitHubStarReminderIdentifier = "com.loom.github-star-reminder" public static let deepLinkNotification = Notification.Name("com.loom.notificationDeepLink") public private(set) var lastDeepLinkPayload: DeepLinkPayload? @@ -79,6 +83,7 @@ public final class NotificationCoordinator: NSObject, UNUserNotificationCenterDe self.transitionDetector = transitionDetector self.policy = NotificationPolicy() super.init() + setupCategories() } public func configure(settings: LoomSettings, store: SessionStore? = nil) { @@ -116,9 +121,12 @@ public final class NotificationCoordinator: NSObject, UNUserNotificationCenterDe // LoomApp owns provider enablement so it can update monitoring. // Notification policy reads enabled providers before each evaluation. + setupCategories() + if let store { connect(to: store) } + checkAndScheduleGitHubStarReminder() } public func connect(to store: SessionStore) { @@ -132,13 +140,69 @@ public final class NotificationCoordinator: NSObject, UNUserNotificationCenterDe title: "View in Loom", options: [.foreground] ) - let category = UNNotificationCategory( + let mainCategory = UNNotificationCategory( identifier: Self.categoryIdentifier, actions: [viewAction], intentIdentifiers: [], options: [.customDismissAction] ) - center.setNotificationCategories([category]) + + let starAction = UNNotificationAction( + identifier: Self.starOnGitHubActionIdentifier, + title: "Star on GitHub", + options: [.foreground], + icon: UNNotificationActionIcon(systemImageName: "star.fill") + ) + let laterAction = UNNotificationAction( + identifier: Self.dismissStarActionIdentifier, + title: "Maybe Later", + options: [], + icon: UNNotificationActionIcon(systemImageName: "clock") + ) + let starCategory = UNNotificationCategory( + identifier: Self.gitHubStarCategoryIdentifier, + actions: [starAction, laterAction], + intentIdentifiers: [], + options: [.customDismissAction] + ) + + center.setNotificationCategories([mainCategory, starCategory]) + } + + public func checkAndScheduleGitHubStarReminder() { + guard let settings else { return } + guard !settings.hasSentGitHubStarNotification else { return } + let hasAvailableProviders = store?.enabledSnapshots.isEmpty == false + guard settings.isEligibleForGitHubStarPrompt(hasAvailableProviders: hasAvailableProviders) else { return } + + settings.hasSentGitHubStarNotification = true + + Task { @MainActor in + setupCategories() + let status = await checkAuthorizationStatus() + guard status == .authorized || status == .provisional else { return } + + let content = UNMutableNotificationContent() + content.title = "Support Loom on GitHub ⭐" + content.subtitle = "github.com/chaiwithcode/Loom" + content.body = "Star the repository to help other macOS developers discover Loom." + content.categoryIdentifier = Self.gitHubStarCategoryIdentifier + content.sound = nil + content.interruptionLevel = .passive + content.threadIdentifier = "com.loom.community" + content.userInfo = [ + NotificationPayloadKey.targetTab: "settings", + NotificationPayloadKey.eventType: "github-star-reminder" + ] + + let request = UNNotificationRequest( + identifier: Self.gitHubStarReminderIdentifier, + content: content, + trigger: nil + ) + + try? await center.add(request) + } } public func checkAuthorizationStatus() async -> NotificationAuthorizationStatus { @@ -332,6 +396,7 @@ public final class NotificationCoordinator: NSObject, UNUserNotificationCenterDe } public func processStoreUpdate() { + checkAndScheduleGitHubStarReminder() guard let store, !store.fixture.isDemo, let settings, settings.notificationsEnabled else { return } updatePolicyFromSettings() @@ -433,6 +498,25 @@ public final class NotificationCoordinator: NSObject, UNUserNotificationCenterDe didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void ) { + let request = response.notification.request + let categoryID = request.content.categoryIdentifier + let actionID = response.actionIdentifier + + if categoryID == Self.gitHubStarCategoryIdentifier || request.identifier == Self.gitHubStarReminderIdentifier { + Task { @MainActor in + if actionID == Self.starOnGitHubActionIdentifier || actionID == UNNotificationDefaultActionIdentifier { + if let url = LoomSettings.gitHubRepoURL as URL? { + NSWorkspace.shared.open(url) + } + self.settings?.markGitHubStarCompleted() + } else if actionID == Self.dismissStarActionIdentifier || actionID == UNNotificationDismissActionIdentifier { + self.settings?.dismissGitHubStarPrompt() + } + completionHandler() + } + return + } + let userInfo = response.notification.request.content.userInfo let targetTab = userInfo[NotificationPayloadKey.targetTab] as? String ?? "sessions" let providerRaw = userInfo[NotificationPayloadKey.providerID] as? String diff --git a/Loom/Core/Discovery/SharedCLIDiscoveryCache.swift b/Loom/Core/Discovery/SharedCLIDiscoveryCache.swift index db6cf42..622e708 100644 --- a/Loom/Core/Discovery/SharedCLIDiscoveryCache.swift +++ b/Loom/Core/Discovery/SharedCLIDiscoveryCache.swift @@ -98,7 +98,17 @@ public actor SharedCLIDiscoveryCache { let mappedState = mapState(status.state) let rawProjectName = status.projectName.trimmingCharacters(in: .whitespacesAndNewlines) - let projectName = (rawProjectName.isEmpty || rawProjectName == "/") ? "\(providerID.displayName) Session" : rawProjectName + let homePath = FileManager.default.homeDirectoryForCurrentUser.standardizedFileURL.path + let isHomeDir = status.workspacePath.map { URL(fileURLWithPath: $0).standardizedFileURL.path == homePath } ?? false + let projectName: String = { + if isHomeDir || rawProjectName == "Home (~)" { + return "Home (~)" + } + if rawProjectName.isEmpty || rawProjectName == "/" { + return "\(providerID.displayName) Session" + } + return rawProjectName + }() let safeWorkspacePath: String? = { guard let path = status.workspacePath, !path.isEmpty, path != "/" else { return nil } diff --git a/Loom/Core/Models/AppVersion.swift b/Loom/Core/Models/AppVersion.swift index 1d886b6..08795fe 100644 --- a/Loom/Core/Models/AppVersion.swift +++ b/Loom/Core/Models/AppVersion.swift @@ -13,7 +13,7 @@ public struct AppVersion: Equatable, Hashable, Sendable { public let buildVersion: String? public let isDevelopmentBuild: Bool - public static let fallbackMarketingVersion = "2.1.0" + public static let fallbackMarketingVersion = "2.2.0" public init( marketingVersion: String, diff --git a/Loom/Core/Models/LoomSettings.swift b/Loom/Core/Models/LoomSettings.swift index 88073ff..bef2165 100644 --- a/Loom/Core/Models/LoomSettings.swift +++ b/Loom/Core/Models/LoomSettings.swift @@ -178,6 +178,8 @@ public final class LoomSettings { public nonisolated static let defaultMenuBarStatusMode: MenuBarStatusMode = .iconOnly public nonisolated static let hasDismissedGitHubStarPromptKey = "com.loom.hasDismissedGitHubStarPrompt" public nonisolated static let defaultHasDismissedGitHubStarPrompt: Bool = false + public nonisolated static let hasSentGitHubStarNotificationKey = "com.loom.hasSentGitHubStarNotification" + public nonisolated static let defaultHasSentGitHubStarNotification: Bool = false public nonisolated static let firstLaunchDateKey = "com.loom.firstLaunchDate" public nonisolated static let distinctObservedSessionIDsKey = "com.loom.distinctObservedSessionIDs" public nonisolated static let gitHubRepoURL = URL(string: "https://github.com/chaiwithcode/Loom")! @@ -332,6 +334,12 @@ public final class LoomSettings { } } + public var hasSentGitHubStarNotification: Bool { + didSet { + defaults.set(hasSentGitHubStarNotification, forKey: Self.hasSentGitHubStarNotificationKey) + } + } + public var firstLaunchDate: Date { didSet { defaults.set(firstLaunchDate, forKey: Self.firstLaunchDateKey) @@ -573,6 +581,13 @@ public final class LoomSettings { self.hasDismissedGitHubStarPrompt = Self.defaultHasDismissedGitHubStarPrompt } + // hasSentGitHubStarNotification: validate boolean type, default false + if let num = defaults.object(forKey: Self.hasSentGitHubStarNotificationKey) as? NSNumber { + self.hasSentGitHubStarNotification = num.boolValue + } else { + self.hasSentGitHubStarNotification = Self.defaultHasSentGitHubStarNotification + } + // firstLaunchDate: retrieve stored date or record current date if let storedDate = defaults.object(forKey: Self.firstLaunchDateKey) as? Date { self.firstLaunchDate = storedDate @@ -694,6 +709,7 @@ public final class LoomSettings { minimumWorkingDuration = Self.defaultMinimumWorkingDuration menuBarStatusMode = Self.defaultMenuBarStatusMode hasDismissedGitHubStarPrompt = Self.defaultHasDismissedGitHubStarPrompt + hasSentGitHubStarNotification = Self.defaultHasSentGitHubStarNotification } public func moveProvider(fromOffsets source: IndexSet, toOffset destination: Int) { @@ -721,6 +737,10 @@ public final class LoomSettings { } } + public var shouldShowGitHubStarInvitation: Bool { + isEligibleForGitHubStarPrompt() + } + public func isEligibleForGitHubStarPrompt(hasAvailableProviders: Bool = true, now: Date = Date()) -> Bool { #if DEBUG if ProcessInfo.processInfo.arguments.contains("--force-github-star-card") || @@ -740,6 +760,10 @@ public final class LoomSettings { hasDismissedGitHubStarPrompt = true } + public func markGitHubStarCompleted() { + hasDismissedGitHubStarPrompt = true + } + public func persistToSharedAppGroup(appGroup: SharedAppGroup = .shared) { guard let containerURL = appGroup.containerURL else { return } let settingsURL = containerURL.appendingPathComponent("settings.json", isDirectory: false) diff --git a/Loom/Providers/Antigravity/AntigravityCLIDetector.swift b/Loom/Providers/Antigravity/AntigravityCLIDetector.swift index 975e146..37ec357 100644 --- a/Loom/Providers/Antigravity/AntigravityCLIDetector.swift +++ b/Loom/Providers/Antigravity/AntigravityCLIDetector.swift @@ -155,11 +155,31 @@ public final class AntigravityCLIDetector: Sendable { var matchedUUIDs = Set() // 2. Pair running `agy` processes with the most recently modified transcript + let homePath = FileManager.default.homeDirectoryForCurrentUser.standardizedFileURL.path + let recentThreshold = Date().addingTimeInterval(-300) // 5 minutes + for (index, proc) in liveAgyProcs.enumerated() { let parentT3 = ProcessTreeScanner.findAncestor(of: proc.pid, in: processTable, matching: t3RootPIDs) let surface: AgentSurface = parentT3 != nil ? .t3Orchestrated : .cli let transcriptEntry = index < recentTranscripts.count ? recentTranscripts[index] : nil + let normalizedCwd = proc.cwd.map { URL(fileURLWithPath: $0).standardizedFileURL.path } + + // Filter out background agy helper processes whose cwd == "/" or cwd == homePath if they have no matching transcript + if transcriptEntry == nil && (normalizedCwd == nil || normalizedCwd == "/" || normalizedCwd == homePath) { + continue + } + + // Even if transcriptEntry exists, if cwd == homePath or "/", require the transcript to have been modified recently + if let entry = transcriptEntry { + if normalizedCwd == "/" { + continue + } + if normalizedCwd == homePath && entry.mtime < recentThreshold { + continue + } + } + let uuid = transcriptEntry?.uuid ?? "agy-pid-\(proc.pid)" if let entry = transcriptEntry { @@ -170,7 +190,15 @@ public final class AntigravityCLIDetector: Sendable { } let resolvedCWD = proc.cwd ?? cwdCache.withLock { $0[uuid] } - let projectName = resolvedCWD.map { URL(fileURLWithPath: $0).lastPathComponent } ?? "agy" + let projectName: String = { + guard let path = resolvedCWD else { return "agy" } + let homePath = FileManager.default.homeDirectoryForCurrentUser.standardizedFileURL.path + if URL(fileURLWithPath: path).standardizedFileURL.path == homePath { + return "Home (~)" + } + let rawName = URL(fileURLWithPath: path).lastPathComponent + return (rawName.isEmpty || rawName == "/") ? "agy" : rawName + }() var state: AgentRunState = .idle var lastUpdated = Date() @@ -212,7 +240,15 @@ public final class AntigravityCLIDetector: Sendable { for entry in recentTranscripts where !matchedUUIDs.contains(entry.uuid) { guard Calendar.current.isDateInToday(entry.mtime) else { continue } let cachedCWD = cwdCache.withLock { $0[entry.uuid] } - let projectName = cachedCWD.map { URL(fileURLWithPath: $0).lastPathComponent } ?? "agy-session" + let projectName: String = { + guard let path = cachedCWD else { return "agy-session" } + let homePath = FileManager.default.homeDirectoryForCurrentUser.standardizedFileURL.path + if URL(fileURLWithPath: path).standardizedFileURL.path == homePath { + return "Home (~)" + } + let rawName = URL(fileURLWithPath: path).lastPathComponent + return (rawName.isEmpty || rawName == "/") ? "agy-session" : rawName + }() results.append( AgentSessionStatus( diff --git a/Loom/Providers/Antigravity/AntigravityProvider.swift b/Loom/Providers/Antigravity/AntigravityProvider.swift index 2a64d0d..eb9e385 100644 --- a/Loom/Providers/Antigravity/AntigravityProvider.swift +++ b/Loom/Providers/Antigravity/AntigravityProvider.swift @@ -381,6 +381,15 @@ public final class AntigravityProvider: AgentProvider, Sendable { merged.append(cli) } } + merged = merged.filter { session in + let isBareUnknown = session.state == .unknown + && session.projectPath == nil + && (session.providerSessionID?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true) + && session.discoveryEvidence.contains(where: { + $0.contains("bare process without worker subprocesses or session metadata") + }) + return !isBareUnknown + } return merged } } diff --git a/Loom/Providers/Claude/ClaudeCodeDetector.swift b/Loom/Providers/Claude/ClaudeCodeDetector.swift index 03b47fe..41ec011 100644 --- a/Loom/Providers/Claude/ClaudeCodeDetector.swift +++ b/Loom/Providers/Claude/ClaudeCodeDetector.swift @@ -133,14 +133,32 @@ public final class ClaudeCodeDetector: Sendable { var results: [AgentSessionStatus] = [] // 3. Bind live `claude` processes to their active transcript + let homePath = FileManager.default.homeDirectoryForCurrentUser.standardizedFileURL.path + let recentThreshold = Date().addingTimeInterval(-300) // 5 minutes + for proc in liveClaudeProcs { let parentT3 = ProcessTreeScanner.findAncestor(of: proc.pid, in: processTable, matching: t3RootPIDs) let surface: AgentSurface = parentT3 != nil ? .t3Orchestrated : .cli + let normalizedCwd = proc.cwd.map { URL(fileURLWithPath: $0).standardizedFileURL.path } + let matchingFile = parsedFiles.first { candidate in guard !matchedURLs.contains(candidate.url) else { return false } guard let procCWD = proc.cwd, let fileCWD = candidate.parsed.cwd else { return false } - return Self.normalizePath(procCWD) == Self.normalizePath(fileCWD) + guard Self.normalizePath(procCWD) == Self.normalizePath(fileCWD) else { return false } + + if normalizedCwd == "/" { + return false + } + if normalizedCwd == homePath { + return candidate.snapshot.modifiedAt >= recentThreshold && candidate.parsed.cwd == homePath + } + return true + } + + // Filter out background claude helper processes whose cwd == "/" or cwd == homePath if they have no matching transcript + if matchingFile == nil && (normalizedCwd == nil || normalizedCwd == "/" || normalizedCwd == homePath) { + continue } if let match = matchingFile { @@ -148,7 +166,15 @@ public final class ClaudeCodeDetector: Sendable { } let resolvedCWD = proc.cwd ?? matchingFile?.parsed.cwd - let projectName = resolvedCWD.map { URL(fileURLWithPath: $0).lastPathComponent } ?? "claude" + let projectName: String = { + guard let path = resolvedCWD else { return "claude" } + let homePath = FileManager.default.homeDirectoryForCurrentUser.standardizedFileURL.path + if URL(fileURLWithPath: path).standardizedFileURL.path == homePath { + return "Home (~)" + } + let rawName = URL(fileURLWithPath: path).lastPathComponent + return (rawName.isEmpty || rawName == "/") ? "claude" : rawName + }() let hasActiveChild = processTable.values.contains { $0.ppid == proc.pid } let state = inferClaudeState( @@ -181,7 +207,15 @@ public final class ClaudeCodeDetector: Sendable { for item in parsedFiles where !matchedURLs.contains(item.url) { guard Calendar.current.isDateInToday(item.snapshot.modifiedAt) else { continue } let cwd = item.parsed.cwd - let projectName = cwd.map { URL(fileURLWithPath: $0).lastPathComponent } ?? "claude" + let projectName: String = { + guard let path = cwd else { return "claude" } + let homePath = FileManager.default.homeDirectoryForCurrentUser.standardizedFileURL.path + if URL(fileURLWithPath: path).standardizedFileURL.path == homePath { + return "Home (~)" + } + let rawName = URL(fileURLWithPath: path).lastPathComponent + return (rawName.isEmpty || rawName == "/") ? "claude" : rawName + }() let sessionID = item.parsed.sessionID ?? item.url.deletingPathExtension().lastPathComponent results.append( diff --git a/Loom/Providers/Claude/ClaudeProvider.swift b/Loom/Providers/Claude/ClaudeProvider.swift index 48bf4cf..113faf3 100644 --- a/Loom/Providers/Claude/ClaudeProvider.swift +++ b/Loom/Providers/Claude/ClaudeProvider.swift @@ -282,6 +282,17 @@ public final class ClaudeProvider: AgentProvider, Sendable { } } + // Filter out bare unidentified helper/daemon processes + sessions = sessions.filter { session in + let isBareUnknown = session.state == .unknown + && session.projectPath == nil + && (session.providerSessionID?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true) + && session.discoveryEvidence.contains(where: { + $0.contains("bare process without worker subprocesses or session metadata") + }) + return !isBareUnknown + } + let duration = Date().timeIntervalSince(startTime) let diagnostics = ProviderDiscoveryDiagnostics( processesDetected: processes.count, diff --git a/Loom/Providers/Codex/CodexCLIDetector.swift b/Loom/Providers/Codex/CodexCLIDetector.swift index b5fc306..86b62a9 100644 --- a/Loom/Providers/Codex/CodexCLIDetector.swift +++ b/Loom/Providers/Codex/CodexCLIDetector.swift @@ -130,19 +130,31 @@ public final class CodexCLIDetector: Sendable { var results: [AgentSessionStatus] = [] // 3. Match live processes to their active rollout transcript by CWD + let homePath = FileManager.default.homeDirectoryForCurrentUser.standardizedFileURL.path + let recentThreshold = Date().addingTimeInterval(-300) // 5 minutes + for proc in liveCodexProcs { let parentT3 = ProcessTreeScanner.findAncestor(of: proc.pid, in: processTable, matching: t3RootPIDs) let surface: AgentSurface = parentT3 != nil ? .t3Orchestrated : .cli + let normalizedCwd = proc.cwd.map { URL(fileURLWithPath: $0).standardizedFileURL.path } + let matchingRollout = parsedRollouts.first { candidate in guard !matchedRolloutURLs.contains(candidate.url) else { return false } guard let procCWD = proc.cwd, let fileCWD = candidate.parsed.cwd else { return false } - return Self.normalizePath(procCWD) == Self.normalizePath(fileCWD) + guard Self.normalizePath(procCWD) == Self.normalizePath(fileCWD) else { return false } + + if normalizedCwd == "/" { + return false + } + if normalizedCwd == homePath { + return candidate.snapshot.modifiedAt >= recentThreshold && candidate.parsed.cwd == homePath + } + return true } - // Filter out background codex helper processes whose cwd == "/" if they have no matching transcript - let isRootOrEmptyCWD = proc.cwd == nil || proc.cwd == "/" || proc.cwd?.isEmpty == true - if isRootOrEmptyCWD && matchingRollout == nil { + // Filter out background codex helper processes whose cwd == "/" or cwd == homePath if they have no matching transcript + if matchingRollout == nil && (normalizedCwd == nil || normalizedCwd == "/" || normalizedCwd == homePath) { continue } @@ -162,6 +174,10 @@ public final class CodexCLIDetector: Sendable { let projectName: String = { if let path = resolvedCWD { + let homePath = FileManager.default.homeDirectoryForCurrentUser.standardizedFileURL.path + if URL(fileURLWithPath: path).standardizedFileURL.path == homePath { + return "Home (~)" + } let rawName = URL(fileURLWithPath: path).lastPathComponent return (rawName.isEmpty || rawName == "/") ? "Codex Session" : rawName } @@ -206,6 +222,10 @@ public final class CodexCLIDetector: Sendable { }() let projectName: String = { if let path = cwd { + let homePath = FileManager.default.homeDirectoryForCurrentUser.standardizedFileURL.path + if URL(fileURLWithPath: path).standardizedFileURL.path == homePath { + return "Home (~)" + } let rawName = URL(fileURLWithPath: path).lastPathComponent return (rawName.isEmpty || rawName == "/") ? "Codex Session" : rawName } diff --git a/Loom/Providers/Codex/CodexParser.swift b/Loom/Providers/Codex/CodexParser.swift index 38ba08f..2c29a68 100644 --- a/Loom/Providers/Codex/CodexParser.swift +++ b/Loom/Providers/Codex/CodexParser.swift @@ -78,6 +78,13 @@ public struct CodexParser: Sendable { let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return false } + let lower = trimmed.lowercased() + if lower.contains("crashpad_handler") || + lower.contains("framework.framework") || + lower.contains("helpers/") { + return false + } + // 1. Match genuine node / deno / bun script execution if matchesNodeOrDenoOrBunScript(trimmed) { return true diff --git a/Loom/Providers/Codex/CodexProvider.swift b/Loom/Providers/Codex/CodexProvider.swift index f128cda..7d18a98 100644 --- a/Loom/Providers/Codex/CodexProvider.swift +++ b/Loom/Providers/Codex/CodexProvider.swift @@ -150,6 +150,17 @@ public final class CodexProvider: AgentProvider, Sendable { } } + // Filter out bare unidentified helper/daemon processes (e.g. crashpad handlers or idle daemons without metadata) + correlatedSessions = correlatedSessions.filter { session in + let isBareUnknown = session.state == .unknown + && session.projectPath == nil + && (session.providerSessionID?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true) + && session.discoveryEvidence.contains(where: { + $0.contains("bare process without worker subprocesses or session metadata") + }) + return !isBareUnknown + } + let duration = Date().timeIntervalSince(startTime) let diagnostics = ProviderDiscoveryDiagnostics( processesDetected: processes.count, diff --git a/Loom/UI/MenuBar/MenuBarRootView.swift b/Loom/UI/MenuBar/MenuBarRootView.swift index 43cc099..c020c27 100644 --- a/Loom/UI/MenuBar/MenuBarRootView.swift +++ b/Loom/UI/MenuBar/MenuBarRootView.swift @@ -186,6 +186,25 @@ public struct MenuBarRootView: View { .frame(height: viewportHeight(for: selectedTab), alignment: .top) .clipped() + if let settings, settings.isEligibleForGitHubStarPrompt(hasAvailableProviders: !store.enabledSnapshots.isEmpty) { + GitHubStarInvitationCard( + onDismiss: { + withAnimation(.easeInOut(duration: 0.2)) { + settings.dismissGitHubStarPrompt() + } + }, + onStar: { + NSWorkspace.shared.open(LoomSettings.gitHubRepoURL) + withAnimation(.easeInOut(duration: 0.2)) { + settings.dismissGitHubStarPrompt() + } + } + ) + .padding(.horizontal, 12) + .padding(.bottom, 8) + .background(MenuHeightReader(part: "starCard")) + } + Divider() // Footer @@ -206,8 +225,9 @@ public struct MenuBarRootView: View { contentHeights[tab] = height } } + let starCard = heights["starCard"] ?? 0 if let header = heights["header"], let footer = heights["footer"] { - let height = header + footer + 2 // The two dividers. + let height = header + footer + starCard + 2 // The two dividers. if abs(chromeHeight - height) > 0.5 { chromeHeight = height } } if let filters = heights["filters"], filters > 0, @@ -285,7 +305,7 @@ public struct MenuBarRootView: View { } private func providerButton(for snapshot: ProviderSnapshot) -> some View { - let isSelected = !isAllProvidersSelected && (resolvedSelectedProviderID == snapshot.id) + let isSelected = !isAllProvidersSelected && (selectedProviderID == snapshot.id) let sessionCount = filteredSessions(for: snapshot).count let needsAttention = snapshot.sessions.contains { $0.state == .waiting || $0.state == .failed } @@ -340,23 +360,6 @@ public struct MenuBarRootView: View { } else if tab == .usage { usageTabContent } - - if let settings, settings.isEligibleForGitHubStarPrompt(hasAvailableProviders: !store.enabledSnapshots.isEmpty) { - GitHubStarInvitationCard( - onDismiss: { - withAnimation(.easeInOut(duration: 0.2)) { - settings.dismissGitHubStarPrompt() - } - }, - onStar: { - NSWorkspace.shared.open(LoomSettings.gitHubRepoURL) - withAnimation(.easeInOut(duration: 0.2)) { - settings.dismissGitHubStarPrompt() - } - } - ) - .padding(.top, 4) - } } @ViewBuilder @@ -414,6 +417,7 @@ public struct MenuBarRootView: View { snapshot: snapshot, showsSessions: true, showsUsage: false, + showsDiagnostics: false, expandedSessionIds: expandedSessionIds, settings: settings, sessions: sessions, @@ -441,6 +445,7 @@ public struct MenuBarRootView: View { snapshot: selectedSnapshot, showsSessions: true, showsUsage: false, + showsDiagnostics: true, expandedSessionIds: expandedSessionIds, settings: settings, sessions: matchingSessions(for: selectedSnapshot), @@ -462,9 +467,11 @@ public struct MenuBarRootView: View { } ) } - Text("Unknown means Loom cannot determine the session’s state.") - .font(.caption) - .foregroundStyle(.secondary) + if !isAllProvidersSelected { + Text("Unknown means Loom cannot determine the session’s state.") + .font(.caption) + .foregroundStyle(.secondary) + } } @ViewBuilder diff --git a/Loom/UI/MenuBar/ProviderSectionView.swift b/Loom/UI/MenuBar/ProviderSectionView.swift index 395179b..d1a598b 100644 --- a/Loom/UI/MenuBar/ProviderSectionView.swift +++ b/Loom/UI/MenuBar/ProviderSectionView.swift @@ -13,6 +13,7 @@ public struct ProviderSectionView: View { public let snapshot: ProviderSnapshot public let showsSessions: Bool public let showsUsage: Bool + public var showsDiagnostics: Bool public let expandedSessionIds: Set public let settings: LoomSettings? public let sessions: [AgentSession]? @@ -29,6 +30,7 @@ public struct ProviderSectionView: View { snapshot: ProviderSnapshot, showsSessions: Bool = true, showsUsage: Bool = true, + showsDiagnostics: Bool = true, expandedSessionIds: Set = [], settings: LoomSettings? = nil, sessions: [AgentSession]? = nil, @@ -41,6 +43,7 @@ public struct ProviderSectionView: View { self.snapshot = snapshot self.showsSessions = showsSessions self.showsUsage = showsUsage + self.showsDiagnostics = showsDiagnostics self.expandedSessionIds = expandedSessionIds self.settings = settings self.sessions = sessions @@ -310,10 +313,12 @@ public struct ProviderSectionView: View { .padding(.vertical, 4) } - DiagnosticsDisclosureView( - snapshot: snapshot, - hideProjectPaths: settings?.hideProjectPaths ?? false - ) + if showsDiagnostics { + DiagnosticsDisclosureView( + snapshot: snapshot, + hideProjectPaths: settings?.hideProjectPaths ?? false + ) + } } if showsUsage, let usage = snapshot.usage, !usage.additionalGroups.isEmpty { diff --git a/LoomWidget/Info.plist b/LoomWidget/Info.plist index b0bcb36..5c4dcb7 100644 --- a/LoomWidget/Info.plist +++ b/LoomWidget/Info.plist @@ -17,9 +17,9 @@ CFBundlePackageType XPC! CFBundleShortVersionString - 2.1.0 + 2.2.0 CFBundleVersion - 6 + 7 NSExtension NSExtensionPointIdentifier diff --git a/Tests/LoomCoreTests/AppVersionTests.swift b/Tests/LoomCoreTests/AppVersionTests.swift index da3c29e..bf0b433 100644 --- a/Tests/LoomCoreTests/AppVersionTests.swift +++ b/Tests/LoomCoreTests/AppVersionTests.swift @@ -46,9 +46,9 @@ final class AppVersionTests: XCTestCase { XCTAssertEqual(version.marketingVersion, AppVersion.fallbackMarketingVersion) XCTAssertEqual(version.buildVersion, "dev") XCTAssertTrue(version.isDevelopmentBuild) - XCTAssertEqual(version.menuBarString, "v2.1.0-dev") - XCTAssertEqual(version.aboutString, "Version 2.1.0 (Dev)") - XCTAssertEqual(version.menuBarAccessibilityLabel, "Loom, version 2.1.0 development") + XCTAssertEqual(version.menuBarString, "v2.2.0-dev") + XCTAssertEqual(version.aboutString, "Version 2.2.0 (Dev)") + XCTAssertEqual(version.menuBarAccessibilityLabel, "Loom, version 2.2.0 development") } func testResolveFromEmptyInfoDictionaryGracefulFallback() { @@ -57,8 +57,8 @@ final class AppVersionTests: XCTestCase { XCTAssertEqual(version.marketingVersion, AppVersion.fallbackMarketingVersion) XCTAssertEqual(version.buildVersion, "dev") XCTAssertTrue(version.isDevelopmentBuild) - XCTAssertEqual(version.menuBarString, "v2.1.0-dev") - XCTAssertEqual(version.aboutString, "Version 2.1.0 (Dev)") + XCTAssertEqual(version.menuBarString, "v2.2.0-dev") + XCTAssertEqual(version.aboutString, "Version 2.2.0 (Dev)") } func testResolveWithWhitespaceTrimming() { diff --git a/Tests/LoomCoreTests/CodexProviderTests.swift b/Tests/LoomCoreTests/CodexProviderTests.swift index cb86b70..74aeb67 100644 --- a/Tests/LoomCoreTests/CodexProviderTests.swift +++ b/Tests/LoomCoreTests/CodexProviderTests.swift @@ -301,14 +301,7 @@ final class CodexProviderTests: XCTestCase { let provider = CodexProvider(processDiscoverer: discoverer, gitResolver: gitResolver) let sessions = try await provider.discoverSessions() - XCTAssertEqual(sessions.count, 1) - - let session = sessions[0] - XCTAssertEqual(session.projectName, "Codex Session") - XCTAssertNil(session.projectPath) - XCTAssertNil(session.branch) - XCTAssertEqual(session.pid, 3001) - XCTAssertNil(session.summary) + XCTAssertTrue(sessions.isEmpty, "Bare unknown processes with nil cwd and no metadata must be filtered out") } // MARK: - Helper Rejection and Path Edge Cases @@ -426,7 +419,7 @@ final class CodexProviderTests: XCTestCase { } func testCodexProviderPopulatesPIDAndPreventsRootCwd() async throws { - // When cwd is root '/', provider must not present project name or path as '/' + // When cwd is root '/' and process has no session metadata, it must be filtered out as a bare helper let rootURL = URL(fileURLWithPath: "/") let process = CodexProcessInfo( pid: 4567, @@ -440,13 +433,7 @@ final class CodexProviderTests: XCTestCase { let provider = CodexProvider(processDiscoverer: discoverer, gitResolver: gitResolver) let sessions = try await provider.discoverSessions() - XCTAssertEqual(sessions.count, 1) - - let session = sessions[0] - XCTAssertEqual(session.pid, 4567) - XCTAssertEqual(session.projectName, "Codex Session") - XCTAssertNil(session.projectPath, "Project path must be nil rather than root '/'") - XCTAssertNil(session.summary, "Raw PID summary filler should be omitted") + XCTAssertTrue(sessions.isEmpty, "Bare process with root cwd and no metadata must be filtered out") } func testCodexProviderUsesExtractedWorkspaceWhenCwdIsRoot() async throws { diff --git a/Tests/LoomCoreTests/SettingsTests.swift b/Tests/LoomCoreTests/SettingsTests.swift index 4383204..e792844 100644 --- a/Tests/LoomCoreTests/SettingsTests.swift +++ b/Tests/LoomCoreTests/SettingsTests.swift @@ -887,4 +887,25 @@ final class SettingsTests: XCTestCase { let updatedWidgetSnapshot = LoomWidgetSnapshot.make(from: store) XCTAssertEqual(updatedWidgetSnapshot.providers.map { ProviderID($0.id) }, [.t3Code, .antigravity, .claude, .codex], "Widget snapshot must update after reordering") } + + @MainActor + func testGitHubStarNotificationPersistenceAndOneTimeGating() throws { + let settings = LoomSettings(defaults: testDefaults) + XCTAssertFalse(settings.hasSentGitHubStarNotification, "Default hasSentGitHubStarNotification must be false") + + // Persist true + settings.hasSentGitHubStarNotification = true + XCTAssertTrue(settings.hasSentGitHubStarNotification) + XCTAssertTrue(testDefaults.bool(forKey: LoomSettings.hasSentGitHubStarNotificationKey)) + + // Verify restoration from defaults + let reloaded = LoomSettings(defaults: testDefaults) + XCTAssertTrue(reloaded.hasSentGitHubStarNotification) + + // Reset to defaults resets back to false + settings.resetToDefaults() + XCTAssertFalse(settings.hasSentGitHubStarNotification) + let resetReloaded = LoomSettings(defaults: testDefaults) + XCTAssertFalse(resetReloaded.hasSentGitHubStarNotification) + } } diff --git a/docs/RELEASE_NOTES_v2.2.0.md b/docs/RELEASE_NOTES_v2.2.0.md new file mode 100644 index 0000000..1eab7b7 --- /dev/null +++ b/docs/RELEASE_NOTES_v2.2.0.md @@ -0,0 +1,24 @@ +# Loom 2.2 — T3 Code Support, Multi-Agent Telemetry & Notification Polish + +Version **2.2.0**, build **7**. + +## Highlights + +- **T3 Code Integration**: Native detection, orchestration hierarchy mapping, and real-time lifecycle tracking for T3 Code sessions alongside child CLI agent processes. +- **Multi-Agent CLI Telemetry**: Robust session detection and token usage metrics across Codex CLI, Claude Code, and Antigravity CLI with live process tree scanning. +- **Phantom Process Filtering**: Eliminated phantom `Home (~)` sessions and bare idle background daemons/helpers (`codex app-server`, `ChatGPT.app` crashpad handlers) when no real user coding session is active. +- **Provider Reordering**: Drag-and-drop reordering of providers in Settings with persisted preferences and dynamic Menu Bar sections. +- **Developer App Launchers**: One-click quick launch for registered AI coding agent environments and workspaces. +- **GitHub Star Community Prompt & Notification**: Native macOS notification with structured 3-line hierarchy, zero-truncation subtitle (`github.com/chaiwithcode/Loom`), SF Symbol hover actions (`★ Star on GitHub` and `Maybe Later`), and deduplicated Menu Bar invitation card. + +## Monitoring & Discovery + +- **Strict CWD & Transcript Correlation**: Background CLI processes running with `cwd == "/"` or `cwd == ~` are only linked to transcripts modified within the last 5 minutes, preventing stale background helpers from inflating session counts. +- **Process Hierarchy & Descendant Traversal**: Automatic attribution of child worker subprocesses to their parent agent sessions. +- **Bare Unknown Process Suppression**: Processes without workspace metadata, worker subprocesses, or valid transcripts are cleanly filtered from active session lists and diagnostics. + +## Compatibility & Distribution + +- Requires macOS 14.0 (Sonoma) or later on Apple Silicon (arm64). +- Production artifacts are signed with Developer ID, notarized by Apple Notary Service, and verified with `stapler`. +- Automated Sparkle 2 appcast and Homebrew Cask release updates. diff --git a/scripts/build-app.sh b/scripts/build-app.sh index e058a4a..9228658 100755 --- a/scripts/build-app.sh +++ b/scripts/build-app.sh @@ -106,9 +106,9 @@ cat > "${CONTENTS_DIR}/Info.plist" <<'EOF' CFBundlePackageType APPL CFBundleShortVersionString - 2.1.0 + 2.2.0 CFBundleVersion - 6 + 7 LSMinimumSystemVersion 14.0 LSUIElement diff --git a/scripts/release-package.sh b/scripts/release-package.sh index 42e182c..e84031e 100755 --- a/scripts/release-package.sh +++ b/scripts/release-package.sh @@ -14,7 +14,7 @@ if [[ -f "${ROOT_DIR}/.release.local" ]]; then source "${ROOT_DIR}/.release.local" fi -VERSION="${1:-${VERSION:-2.1.0}}" +VERSION="${1:-${VERSION:-2.2.0}}" SKIP_NOTARIZATION="${SKIP_NOTARIZATION:-0}" for arg in "$@"; do if [[ "${arg}" == "--skip-notarization" ]]; then