diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index e1aa8b7b14e8..8c82a0f8a2a0 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -3,7 +3,7 @@ # Reduce counts as files shrink. CI fails if tracked files exceed this budget. 34344 CLI/cmux.swift 17606 Sources/AppDelegate.swift -16021 Sources/ContentView.swift +16038 Sources/ContentView.swift 14100 Sources/TerminalController.swift 12695 Sources/Workspace.swift 12144 cmuxTests/AppDelegateShortcutRoutingTests.swift diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index b0fd1de3749b..b0edfb93b35f 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -2029,7 +2029,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent auth.start() ensureMobileWorkspaceListObserver(for: tabManager) MobileTerminalRenderObserver.shared.start() - agentChatTranscriptService.start { TerminalController.shared.adoptDetectedAgentSessions(workspaceID: $0) } + agentChatTranscriptService.start { TerminalController.shared.adoptDetectedAgentSession(titleChange: $0) } installMobileHostSettingsObserver() scheduleGhosttyCrashBreadcrumbIfNeeded(notificationStore: notificationStore) startPaneMemoryGuardrailIfNeeded(notificationStore: notificationStore) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 6745b04fc862..47fc41ecc5b6 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -12112,7 +12112,6 @@ struct VerticalTabsSidebar: View { notificationStore: notificationStore, tab: tab, index: index, - isActive: tabManager.selectedTabId == tab.id, workspaceShortcutDigit: WorkspaceShortcutMapper.digitForWorkspace( at: index, workspaceCount: renderContext.workspaceCount @@ -12914,7 +12913,6 @@ struct TabItemView: View, Equatable { nonisolated static func == (lhs: TabItemView, rhs: TabItemView) -> Bool { lhs.tab === rhs.tab && lhs.index == rhs.index && - lhs.isActive == rhs.isActive && lhs.workspaceShortcutDigit == rhs.workspaceShortcutDigit && lhs.workspaceShortcutModifierSymbol == rhs.workspaceShortcutModifierSymbol && lhs.canCloseWorkspace == rhs.canCloseWorkspace && @@ -12943,7 +12941,6 @@ struct TabItemView: View, Equatable { @Environment(\.colorScheme) private var colorScheme let tab: Tab let index: Int - let isActive: Bool let workspaceShortcutDigit: Int? let workspaceShortcutModifierSymbol: String let canCloseWorkspace: Bool @@ -12987,6 +12984,8 @@ struct TabItemView: View, Equatable { let onContextMenuAppear: () -> Void let onContextMenuDisappear: () -> Void @State private var workspaceSnapshotStorage: SidebarWorkspaceSnapshotBuilder.Snapshot? + // Row-local selection projection: selectedTabId changes update only rows whose boolean flips. + @State private var observedIsActive: Bool? @StateObject private var contextMenuState = SidebarTabItemContextMenuState() @State private var rowInteractionState = SidebarWorkspaceRowInteractionState() @State private var rowHeight: CGFloat = 1 @@ -13047,6 +13046,10 @@ struct TabItemView: View, Equatable { settings.activeTabIndicatorStyle } + private var isActive: Bool { + observedIsActive ?? (tabManager.selectedTabId == tab.id) + } + private var sidebarSelectionColorHex: String? { settings.selectionColorHex } @@ -13697,8 +13700,17 @@ struct TabItemView: View, Equatable { ) } .onAppear { + updateObservedActiveState(tabManager.selectedTabId == tab.id) refreshWorkspaceSnapshot(force: true) } + .onReceive( + tabManager.selectedTabIdPublisher + .map { $0 == tab.id } + .removeDuplicates() + .receive(on: RunLoop.main) + ) { isSelected in + updateObservedActiveState(isSelected) + } .task(id: workspaceFinderDirectoryOpenRequest) { guard let request = workspaceFinderDirectoryOpenRequest else { return } await WorkspaceFinderDirectoryOpener.openInFinder(request.directoryURL) @@ -13782,6 +13794,11 @@ struct TabItemView: View, Equatable { } } + private func updateObservedActiveState(_ isActive: Bool) { + guard observedIsActive != isActive else { return } + observedIsActive = isActive + } + private func refreshWorkspaceSnapshot(force: Bool = false) { let nextSnapshot = makeWorkspaceSnapshot() let decision = SidebarWorkspaceSnapshotRefreshPolicy.decision( diff --git a/Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift b/Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift index 8df480a293bc..55f18f8d6f4d 100644 --- a/Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift +++ b/Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift @@ -7,6 +7,7 @@ import Foundation @MainActor final class AgentChatSessionRegistry { private var records: [String: AgentChatSessionRecord] = [:] + private var liveSessionIDBySurfaceID: [String: String] = [:] private let hookStore: AgentChatHookSessionStore /// Called after a record mutation with the previous value (nil for a @@ -59,6 +60,27 @@ final class AgentChatSessionRegistry { records[sessionID] } + /// The current live session bound to a terminal surface, if any. + /// + /// - Parameter surfaceID: Terminal surface UUID string. + /// - Returns: A non-ended record bound to the surface, or `nil`. + func liveSession(surfaceID: String) -> AgentChatSessionRecord? { + while let sessionID = liveSessionIDBySurfaceID[surfaceID] { + guard let record = records[sessionID], + record.surfaceID == surfaceID, + record.state != .ended else { + liveSessionIDBySurfaceID.removeValue(forKey: surfaceID) + return nil + } + if let pid = record.pid, processIsDead(pid) { + update(sessionID: sessionID) { $0.state = .ended } + continue + } + return record + } + return nil + } + /// Every session id the registry already tracks. Title-detected adoption /// passes this to the transcript resolver so a second hook-bypassed claude /// in the same directory resolves to a *different* (unclaimed) transcript @@ -98,6 +120,7 @@ final class AgentChatSessionRegistry { var record = previous mutate(&record) records[sessionID] = record + updateLiveSessionIndex(previous: previous, current: record) onRecordChanged?(record, previous) } @@ -127,7 +150,7 @@ final class AgentChatSessionRegistry { for entry in hookStore.entries(agentSource: source) { guard records[entry.sessionID] == nil else { continue } let alive = entry.pid.map { kill(pid_t($0), 0) == 0 } ?? false - records[entry.sessionID] = AgentChatSessionRecord( + let record = AgentChatSessionRecord( sessionID: entry.sessionID, agentKind: kind, workspaceID: entry.workspaceID, @@ -139,6 +162,8 @@ final class AgentChatSessionRegistry { title: nil, pid: entry.pid ) + records[entry.sessionID] = record + updateLiveSessionIndex(previous: nil, current: record) } } } @@ -168,7 +193,7 @@ final class AgentChatSessionRegistry { at timestamp: Date ) -> AgentChatSessionRecord { if let existing = records[sessionID] { return existing } - if let bound = records.values.first(where: { $0.surfaceID == surfaceID && $0.state != .ended }) { + if let bound = liveSession(surfaceID: surfaceID) { return bound } let record = AgentChatSessionRecord( @@ -184,6 +209,7 @@ final class AgentChatSessionRegistry { pid: nil ) records[sessionID] = record + updateLiveSessionIndex(previous: nil, current: record) onRecordChanged?(record, nil) return record } @@ -249,10 +275,58 @@ final class AgentChatSessionRegistry { let previous = records[sessionID] record.state = Self.nextState(previous: record.state, event: event) records[sessionID] = record + updateLiveSessionIndex(previous: previous, current: record) onRecordChanged?(record, previous) return record } + private func updateLiveSessionIndex( + previous: AgentChatSessionRecord?, + current: AgentChatSessionRecord + ) { + let previousSurfaceID = Self.liveSurfaceID(previous) + let currentSurfaceID = Self.liveSurfaceID(current) + if let previousSurfaceID, + previousSurfaceID != currentSurfaceID, + liveSessionIDBySurfaceID[previousSurfaceID] == previous?.sessionID { + liveSessionIDBySurfaceID.removeValue(forKey: previousSurfaceID) + rebuildLiveSessionIndex(surfaceID: previousSurfaceID) + } + guard let currentSurfaceID else { return } + guard let indexedSessionID = liveSessionIDBySurfaceID[currentSurfaceID], + let indexed = records[indexedSessionID], + indexed.surfaceID == currentSurfaceID, + indexed.state != .ended else { + liveSessionIDBySurfaceID[currentSurfaceID] = current.sessionID + return + } + if indexed.sessionID == current.sessionID || current.lastActivityAt >= indexed.lastActivityAt { + liveSessionIDBySurfaceID[currentSurfaceID] = current.sessionID + } + } + + private func rebuildLiveSessionIndex(surfaceID: String?) { + guard let surfaceID else { return } + if let newest = records.values + .filter({ $0.surfaceID == surfaceID && $0.state != .ended }) + .max(by: { $0.lastActivityAt < $1.lastActivityAt }) { + liveSessionIDBySurfaceID[surfaceID] = newest.sessionID + } else { + liveSessionIDBySurfaceID.removeValue(forKey: surfaceID) + } + } + + private static func liveSurfaceID(_ record: AgentChatSessionRecord?) -> String? { + guard let record, record.state != .ended else { + return nil + } + return record.surfaceID + } + + private func processIsDead(_ pid: Int) -> Bool { + kill(pid_t(pid), 0) != 0 && errno == ESRCH + } + /// Strips an agent-name prefix from prefixed workstream ids /// (`claude-`); raw hook ids pass through. private static func normalizedSessionID(_ id: String, source: String) -> String { diff --git a/Sources/Mobile/AgentChat/AgentChatTranscriptResolver.swift b/Sources/Mobile/AgentChat/AgentChatTranscriptResolver.swift index abd499758901..19742824c932 100644 --- a/Sources/Mobile/AgentChat/AgentChatTranscriptResolver.swift +++ b/Sources/Mobile/AgentChat/AgentChatTranscriptResolver.swift @@ -6,16 +6,17 @@ import Foundation /// Preference order: the hook store's recorded `transcriptPath`, then the /// agent-specific conventional location (claude: encoded-cwd project dir; /// codex: rollout filename containing the session id). -struct AgentChatTranscriptResolver { +struct AgentChatTranscriptResolver: Sendable { private let homeDirectory: URL - private let fileManager: FileManager + private static let claudeTranscriptTitleReadLimit = 1_048_576 + private static let claudeTranscriptTitleChunkSize = 64 * 1024 + private static let claudeTranscriptTitleMaxLineBytes = 256 * 1024 /// Creates a resolver. /// /// - Parameter homeDirectory: Injectable home directory for tests. init(homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser) { self.homeDirectory = homeDirectory - self.fileManager = FileManager.default } /// Resolves the transcript path for a session. @@ -24,6 +25,7 @@ struct AgentChatTranscriptResolver { /// - record: The session's registry record. /// - Returns: An existing transcript path, or `nil` when none is found. func transcriptPath(for record: AgentChatSessionRecord) -> String? { + let fileManager = FileManager.default if let recorded = record.transcriptPath { let expanded = (recorded as NSString).expandingTildeInPath if fileManager.fileExists(atPath: expanded) { @@ -61,6 +63,8 @@ struct AgentChatTranscriptResolver { excludingSessionIDs: Set = [], titleHint: String? = nil ) -> (sessionID: String, path: String)? { + guard !Task.isCancelled else { return nil } + let fileManager = FileManager.default // The home project dir is a junk drawer of every home-rooted claude // conversation, so newest-by-mtime there is almost never *this* // terminal's session. Refuse title-detected adoption from $HOME; a @@ -75,6 +79,7 @@ struct AgentChatTranscriptResolver { .filter { URL(fileURLWithPath: $0).resolvingSymlinksInPath().path != home } let normalizedTitleHint = Self.normalizedClaudeTitle(titleHint) for cwd in candidates { + guard !Task.isCancelled else { return nil } let projectDir = RestorableAgentSessionIndex.encodeClaudeProjectDir(cwd) let dir = homeDirectory .appendingPathComponent(".claude", isDirectory: true) @@ -85,24 +90,30 @@ struct AgentChatTranscriptResolver { includingPropertiesForKeys: [.contentModificationDateKey], options: [.skipsHiddenFiles] ) else { continue } - let transcriptCandidates = entries - .filter { - $0.pathExtension == "jsonl" - && !excludingSessionIDs.contains($0.deletingPathExtension().lastPathComponent) - } - .map { url in - ( - url: url, - date: (try? url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast, - title: Self.claudeTranscriptTitle(at: url) - ) - } + var transcriptCandidates: [(url: URL, date: Date, title: String?)] = [] + for url in entries where url.pathExtension == "jsonl" { + guard !Task.isCancelled else { return nil } + let sessionID = url.deletingPathExtension().lastPathComponent + guard !excludingSessionIDs.contains(sessionID) else { continue } + transcriptCandidates.append(( + url: url, + date: (try? url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast, + title: Self.claudeTranscriptTitle(at: url) + )) + } let newest: URL? if let normalizedTitleHint { - newest = transcriptCandidates - .filter { Self.normalizedClaudeTitle($0.title) == normalizedTitleHint || $0.title == nil } - .max { $0.date < $1.date }? - .url + let exactTitleCandidates = transcriptCandidates + .filter { Self.normalizedClaudeTitle($0.title) == normalizedTitleHint } + let untitledCandidates = transcriptCandidates.filter { $0.title == nil } + let newestExact = exactTitleCandidates.max { $0.date < $1.date } + let newestUntitled = untitledCandidates.max { $0.date < $1.date } + if let newestExact, + let newestUntitled, + newestUntitled.date > newestExact.date { + return nil + } + newest = (newestExact ?? newestUntitled)?.url } else { // A generic "Claude Code" title cannot identify one of several // same-cwd sessions. Avoid stealing a transcript that already @@ -148,6 +159,7 @@ struct AgentChatTranscriptResolver { } private func claudeFallbackPath(record: AgentChatSessionRecord) -> String? { + let fileManager = FileManager.default guard let cwd = record.workingDirectory else { return nil } let projectDir = RestorableAgentSessionIndex.encodeClaudeProjectDir(cwd) let path = homeDirectory @@ -163,6 +175,7 @@ struct AgentChatTranscriptResolver { /// under `~/.codex/sessions/YYYY/MM/DD/`; scan recent day directories for /// the session id. private func codexFallbackPath(sessionID: String) -> String? { + let fileManager = FileManager.default let root = homeDirectory .appendingPathComponent(".codex", isDirectory: true) .appendingPathComponent("sessions", isDirectory: true) @@ -200,17 +213,53 @@ struct AgentChatTranscriptResolver { } private static func claudeTranscriptTitle(at url: URL) -> String? { - guard let contents = try? String(contentsOf: url, encoding: .utf8) else { + guard let handle = try? FileHandle(forReadingFrom: url) else { return nil } - for line in contents.split(separator: "\n") where line.contains(#""ai-title""#) { - guard let data = line.data(using: .utf8), - let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - object["type"] as? String == "ai-title" else { - continue + defer { try? handle.close() } + + var buffered = Data() + var bytesRead = 0 + var droppingOversizedLine = false + while bytesRead < claudeTranscriptTitleReadLimit { + guard !Task.isCancelled else { return nil } + let readSize = min(claudeTranscriptTitleChunkSize, claudeTranscriptTitleReadLimit - bytesRead) + guard let chunk = try? handle.read(upToCount: readSize), + !chunk.isEmpty else { + break + } + bytesRead += chunk.count + buffered.append(chunk) + + while let newlineIndex = buffered.firstIndex(of: 0x0A) { + let lineData = Data(buffered[.. claudeTranscriptTitleMaxLineBytes { + buffered.removeAll(keepingCapacity: true) + droppingOversizedLine = true } - return object["aiTitle"] as? String } - return nil + guard !droppingOversizedLine else { + return nil + } + return claudeTranscriptTitle(in: buffered) + } + + private static func claudeTranscriptTitle(in lineData: Data) -> String? { + guard lineData.range(of: Data(#""ai-title""#.utf8)) != nil, + let object = try? JSONSerialization.jsonObject(with: lineData) as? [String: Any], + object["type"] as? String == "ai-title" else { + return nil + } + return object["aiTitle"] as? String } } diff --git a/Sources/Mobile/AgentChat/AgentChatTranscriptService+TitleDetection.swift b/Sources/Mobile/AgentChat/AgentChatTranscriptService+TitleDetection.swift new file mode 100644 index 000000000000..1b49d50a6a49 --- /dev/null +++ b/Sources/Mobile/AgentChat/AgentChatTranscriptService+TitleDetection.swift @@ -0,0 +1,242 @@ +import Foundation + +extension AgentChatTranscriptService { + func scheduleTitleDetectedAdoption(_ change: GhosttyTitleChange) { + let surfaceID = change.surfaceId.uuidString + guard let titleKey = Self.claudeTitleDetectionKey(change.title) else { + clearTitleDetectionState(surfaceID: surfaceID) + return + } + if pendingTitleChanges[surfaceID]?.titleKey == titleKey { + return + } + if pendingTitleChanges[surfaceID] == nil, + deliveredTitleKeys[surfaceID] == titleKey, + registry.liveSession(surfaceID: surfaceID)?.transcriptPath != nil { + return + } + + pendingTitleChanges[surfaceID] = (change: change, titleKey: titleKey) + titleChangeCoalescer.signal { [weak self] in + self?.flushTitleDetectedAdoptions() + } + } + + func flushTitleDetectedAdoptions() { + guard !pendingTitleChanges.isEmpty else { + return + } + let pendingBySurface = pendingTitleChanges + pendingTitleChanges.removeAll(keepingCapacity: true) + for (surfaceID, pending) in pendingBySurface { + if titleAdoptionHandler?(pending.change) == true, + registry.liveSession(surfaceID: surfaceID)?.transcriptPath != nil { + deliveredTitleKeys[surfaceID] = pending.titleKey + } + } + } + + func clearTitleDetectionState( + surfaceID: String, + releaseTranscriptClaims: Bool = false + ) { + pendingTitleChanges.removeValue(forKey: surfaceID) + deliveredTitleKeys.removeValue(forKey: surfaceID) + transcriptResolutionTasks[surfaceID]?.cancel() + transcriptResolutionTasks[surfaceID] = nil + transcriptResolutionKeys.removeValue(forKey: surfaceID) + transcriptResolutionForcedRetryCounts.removeValue(forKey: surfaceID) + if releaseTranscriptClaims { + claimedDetectedTranscriptSessionIDsBySurfaceID.removeValue(forKey: surfaceID) + } + detectionScanAt.removeValue(forKey: surfaceID) + } + + func scheduleClaudeTranscriptResolution( + workspaceID: String, + workingDirectory: String, + surfaceID: String, + excludingSessionID: String?, + titleHint: String?, + forceScan: Bool + ) { + let now = Date() + if !forceScan, + let lastScan = detectionScanAt[surfaceID], + now.timeIntervalSince(lastScan) < Self.detectionScanThrottle { + return + } + + var claimed = registry.claimedSessionIDs() + .union(activeClaimedDetectedTranscriptSessionIDs(excludingSurfaceID: surfaceID)) + if let excludingSessionID { + claimed.remove(excludingSessionID) + } + let key: ClaudeTranscriptResolutionKey = ( + workingDirectory: workingDirectory, + claimedSessionIDs: claimed, + titleKey: Self.specificClaudeTitleKey(titleHint), + forceScan: forceScan + ) + if let currentKey = transcriptResolutionKeys[surfaceID], + currentKey == key { + return + } + + detectionScanAt[surfaceID] = now + transcriptResolutionKeys[surfaceID] = key + if !forceScan { + transcriptResolutionForcedRetryCounts.removeValue(forKey: surfaceID) + } + transcriptResolutionTasks[surfaceID]?.cancel() + let resolver = self.resolver + #if compiler(>=6.2) + let resolveOperation: @concurrent @Sendable () async -> (sessionID: String, path: String)? = { + [resolver, workingDirectory, claimed, titleHint] in + resolver.newestClaudeTranscript( + workingDirectory: workingDirectory, + excludingSessionIDs: claimed, + titleHint: titleHint + ) + } + #else + let resolveOperation: @Sendable () async -> (sessionID: String, path: String)? = { + [resolver, workingDirectory, claimed, titleHint] in + resolver.newestClaudeTranscript( + workingDirectory: workingDirectory, + excludingSessionIDs: claimed, + titleHint: titleHint + ) + } + #endif + let scanTask = Task.detached(priority: .utility, operation: resolveOperation) + transcriptResolutionTasks[surfaceID] = Task { @MainActor [ + weak self, + scanTask, + key, + workspaceID, + workingDirectory, + surfaceID, + titleHint + ] in + let resolved = await withTaskCancellationHandler { + await scanTask.value + } onCancel: { + scanTask.cancel() + } + guard !Task.isCancelled else { return } + self?.applyClaudeTranscriptResolution( + resolved, + key: key, + workspaceID: workspaceID, + workingDirectory: workingDirectory, + surfaceID: surfaceID, + titleHint: titleHint + ) + } + } + + func applyClaudeTranscriptResolution( + _ resolved: (sessionID: String, path: String)?, + key: ClaudeTranscriptResolutionKey, + workspaceID: String, + workingDirectory: String, + surfaceID: String, + titleHint: String? + ) { + guard let currentKey = transcriptResolutionKeys[surfaceID], + currentKey == key else { + return + } + transcriptResolutionTasks[surfaceID] = nil + transcriptResolutionKeys[surfaceID] = nil + + guard let resolved else { return } + guard !activeClaimedDetectedTranscriptSessionIDs(excludingSurfaceID: surfaceID).contains(resolved.sessionID) else { + scheduleForcedClaudeTranscriptRetry( + workspaceID: workspaceID, + workingDirectory: workingDirectory, + surfaceID: surfaceID, + excludingSessionID: registry.liveSession(surfaceID: surfaceID)?.sessionID, + titleHint: titleHint + ) + return + } + if let claimed = registry.record(sessionID: resolved.sessionID), + claimed.surfaceID != nil, + claimed.surfaceID != surfaceID { + scheduleForcedClaudeTranscriptRetry( + workspaceID: workspaceID, + workingDirectory: workingDirectory, + surfaceID: surfaceID, + excludingSessionID: registry.liveSession(surfaceID: surfaceID)?.sessionID, + titleHint: titleHint + ) + return + } + + detectionScanAt.removeValue(forKey: surfaceID) + if let bound = registry.liveSession(surfaceID: surfaceID) { + guard bound.transcriptPath == nil else { return } + registry.update(sessionID: bound.sessionID) { record in + record.workspaceID = workspaceID + record.surfaceID = surfaceID + record.workingDirectory = workingDirectory + record.transcriptPath = resolved.path + } + claimDetectedTranscriptSessionID(resolved.sessionID, surfaceID: surfaceID) + transcriptResolutionForcedRetryCounts.removeValue(forKey: surfaceID) + return + } + + let adopted = registry.adoptDetectedSession( + sessionID: resolved.sessionID, + agentKind: .claude, + workspaceID: workspaceID, + surfaceID: surfaceID, + workingDirectory: workingDirectory, + transcriptPath: resolved.path, + at: Date() + ) + if adopted.surfaceID == surfaceID, + adopted.transcriptPath == resolved.path { + claimDetectedTranscriptSessionID(resolved.sessionID, surfaceID: surfaceID) + transcriptResolutionForcedRetryCounts.removeValue(forKey: surfaceID) + } + } + + func scheduleForcedClaudeTranscriptRetry( + workspaceID: String, + workingDirectory: String, + surfaceID: String, + excludingSessionID: String?, + titleHint: String? + ) { + let retryCount = transcriptResolutionForcedRetryCounts[surfaceID, default: 0] + guard retryCount < Self.maxTranscriptResolutionForcedRetries else { + return + } + transcriptResolutionForcedRetryCounts[surfaceID] = retryCount + 1 + scheduleClaudeTranscriptResolution( + workspaceID: workspaceID, + workingDirectory: workingDirectory, + surfaceID: surfaceID, + excludingSessionID: excludingSessionID, + titleHint: titleHint, + forceScan: true + ) + } + + func activeClaimedDetectedTranscriptSessionIDs(excludingSurfaceID surfaceID: String) -> Set { + var claimed = Set() + for (claimedSurfaceID, sessionIDs) in claimedDetectedTranscriptSessionIDsBySurfaceID + where claimedSurfaceID != surfaceID { + claimed.formUnion(sessionIDs) + } + return claimed + } + + func claimDetectedTranscriptSessionID(_ sessionID: String, surfaceID: String) { + claimedDetectedTranscriptSessionIDsBySurfaceID[surfaceID, default: []].insert(sessionID) + } +} diff --git a/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift b/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift index a9579b7bf546..45a242c7ebea 100644 --- a/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift +++ b/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift @@ -10,8 +10,8 @@ final class AgentChatTranscriptService { /// The push topic chat clients subscribe to. static let eventTopic = "chat.message" - private let registry: AgentChatSessionRegistry - private let resolver: AgentChatTranscriptResolver + let registry: AgentChatSessionRegistry + let resolver: AgentChatTranscriptResolver private let coding = ChatWireCoding() private var tailers: [String: AgentChatTranscriptTailer] = [:] /// Sessions whose transcript could not be resolved; skipped until an @@ -19,13 +19,22 @@ final class AgentChatTranscriptService { /// failures don't rescan the filesystem during tool storms. private var failedResolutions: Set = [] /// Last time `adoptDetectedClaudeSession` ran a filesystem scan for a - /// surface that had no session yet, keyed by surface id. Bounds the - /// main-actor directory walk to once per `detectionScanThrottle` while a + /// surface that had no session yet, keyed by surface id. Bounds transcript + /// resolution scheduling to once per `detectionScanThrottle` while a /// title-detected claude has not yet written its transcript; a successful /// adoption removes the entry. - private var detectionScanAt: [String: Date] = [:] + var detectionScanAt: [String: Date] = [:] private var ghosttyTitleSubscription: GhosttyTitleChangeSubscription? - private static let detectionScanThrottle: TimeInterval = 4 + var pendingTitleChanges: [String: PendingTitleChange] = [:] + var deliveredTitleKeys: [String: String] = [:] + var transcriptResolutionTasks: [String: Task] = [:] + var transcriptResolutionKeys: [String: ClaudeTranscriptResolutionKey] = [:] + var transcriptResolutionForcedRetryCounts: [String: Int] = [:] + var claimedDetectedTranscriptSessionIDsBySurfaceID: [String: Set] = [:] + var titleAdoptionHandler: (@MainActor (GhosttyTitleChange) -> Bool)? + let titleChangeCoalescer = NotificationBurstCoalescer(delay: 0.25) + static let detectionScanThrottle: TimeInterval = 4 + static let maxTranscriptResolutionForcedRetries = 3 private static let provisionalClaudeSessionIDPrefix = "detected-claude-surface-" /// Creates the service with a hook-store-backed registry. @@ -54,12 +63,14 @@ final class AgentChatTranscriptService { /// Seeds the session registry from the on-disk hook stores. Call once /// at app startup. /// - /// - Parameter adoptDetectedAgentSessions: Composition-root callback that - /// adopts a title-detected agent for the workspace whose title changed. - func start(adoptDetectedAgentSessions: @escaping @MainActor (String) -> Void) { + /// - Parameter adoptDetectedAgentSession: Composition-root callback that + /// adopts a title-detected agent for the surface whose title changed, + /// returning whether the surface was resolved and adoption was queued. + func start(adoptDetectedAgentSession: @escaping @MainActor (GhosttyTitleChange) -> Bool) { guard ghosttyTitleSubscription == nil else { return } + titleAdoptionHandler = adoptDetectedAgentSession registry.seedFromHookStores() - observeAgentTitleChanges(adoptDetectedAgentSessions: adoptDetectedAgentSessions) + observeAgentTitleChanges() } /// Watches terminal title changes so a coding agent launched without a @@ -67,12 +78,9 @@ final class AgentChatTranscriptService { /// adopted the instant its terminal title becomes the agent's (e.g. /// "✳ Claude Code"), not only when the workspace is next opened. Adoption /// emits a descriptor change, which pushes the toggle to listening phones. - private func observeAgentTitleChanges(adoptDetectedAgentSessions: @escaping @MainActor (String) -> Void) { - ghosttyTitleSubscription = GhosttyTitleChangeSubscription { change in - guard change.title.lowercased().contains("claude") else { - return - } - adoptDetectedAgentSessions(change.tabId.uuidString) + private func observeAgentTitleChanges() { + ghosttyTitleSubscription = GhosttyTitleChangeSubscription { [weak self] change in + self?.scheduleTitleDetectedAdoption(change) } } @@ -134,55 +142,35 @@ final class AgentChatTranscriptService { workingDirectory: String, titleHint: String? = nil ) -> Bool { - if let bound = registry.sessions(workspaceID: nil) - .first(where: { $0.surfaceID == surfaceID && $0.state != .ended }) { - registry.update(sessionID: bound.sessionID) { record in - record.workspaceID = workspaceID - record.surfaceID = surfaceID - record.workingDirectory = workingDirectory + if let bound = registry.liveSession(surfaceID: surfaceID) { + if bound.workspaceID != workspaceID + || bound.surfaceID != surfaceID + || bound.workingDirectory != workingDirectory { + registry.update(sessionID: bound.sessionID) { record in + record.workspaceID = workspaceID + record.surfaceID = surfaceID + record.workingDirectory = workingDirectory + } } guard bound.transcriptPath == nil else { return true } - if let resolved = newestClaudeTranscript( + scheduleClaudeTranscriptResolution( + workspaceID: workspaceID, workingDirectory: workingDirectory, surfaceID: surfaceID, excludingSessionID: bound.sessionID, titleHint: titleHint, forceScan: Self.isSpecificClaudeTitle(titleHint) - ) { - detectionScanAt.removeValue(forKey: surfaceID) - registry.update(sessionID: bound.sessionID) { record in - record.transcriptPath = resolved.path - record.workingDirectory = workingDirectory - } - } + ) return true } // A claude detected by title before it has written its transcript jsonl // (the launch race) resolves to nothing. List-level adoption runs this // on every workspace-list RPC and every "claude" title change across - // ALL workspaces, so without a throttle an un-resolvable surface drives - // a fresh main-actor directory walk on each call during a title burst. - // Bound the filesystem scan to once per surface per window; a success - // clears the entry (and `alreadyBound` short-circuits forever after). - if let resolved = newestClaudeTranscript( - workingDirectory: workingDirectory, - surfaceID: surfaceID, - excludingSessionID: nil, - titleHint: titleHint, - forceScan: false - ) { - detectionScanAt.removeValue(forKey: surfaceID) - registry.adoptDetectedSession( - sessionID: resolved.sessionID, - agentKind: .claude, - workspaceID: workspaceID, - surfaceID: surfaceID, - workingDirectory: workingDirectory, - transcriptPath: resolved.path, - at: Date() - ) - return true - } + // ALL workspaces, so without a throttle an un-resolvable surface would + // schedule fresh transcript resolution on each call during a title + // burst. Bound the off-main resolution to once per surface per window; + // a success clears the entry (and `liveSession` short-circuits forever + // after once the transcript is bound). registry.adoptDetectedSession( sessionID: Self.provisionalClaudeSessionID(surfaceID: surfaceID), agentKind: .claude, @@ -192,6 +180,14 @@ final class AgentChatTranscriptService { transcriptPath: nil, at: Date() ) + scheduleClaudeTranscriptResolution( + workspaceID: workspaceID, + workingDirectory: workingDirectory, + surfaceID: surfaceID, + excludingSessionID: nil, + titleHint: titleHint, + forceScan: false + ) return true } @@ -276,6 +272,14 @@ final class AgentChatTranscriptService { // MARK: - Internals + typealias PendingTitleChange = (change: GhosttyTitleChange, titleKey: String) + typealias ClaudeTranscriptResolutionKey = ( + workingDirectory: String, + claimedSessionIDs: Set, + titleKey: String?, + forceScan: Bool + ) + @discardableResult private func ensureTailer(for record: AgentChatSessionRecord) -> AgentChatTranscriptTailer? { if let existing = tailers[record.sessionID] { @@ -345,13 +349,17 @@ final class AgentChatTranscriptService { private func handleRecordChange(_ record: AgentChatSessionRecord, previous: AgentChatSessionRecord?) { let stateChanged = previous?.state != record.state let transcriptBecameAvailable = previous?.transcriptPath == nil && record.transcriptPath != nil - if stateChanged, record.state == .ended, - let tailer = tailers.removeValue(forKey: record.sessionID) { - // The transcript can no longer grow; release the file watcher - // and cache instead of holding them until app quit. Evicting - // only on the TRANSITION keeps unrelated record updates (title - // discovery while paging an ended session) from churning it. - Task { await tailer.stop() } + if stateChanged, record.state == .ended { + if let surfaceID = record.surfaceID { + clearTitleDetectionState(surfaceID: surfaceID, releaseTranscriptClaims: true) + } + if let tailer = tailers.removeValue(forKey: record.sessionID) { + // The transcript can no longer grow; release the file watcher + // and cache instead of holding them until app quit. Evicting + // only on the TRANSITION keeps unrelated record updates (title + // discovery while paging an ended session) from churning it. + Task { await tailer.stop() } + } } guard MobileHostService.hasEventSubscribers(topic: Self.eventTopic) else { return } if transcriptBecameAvailable, record.state != .ended { @@ -397,6 +405,7 @@ final class AgentChatTranscriptService { } detectionScanAt[surfaceID] = now var claimed = registry.claimedSessionIDs() + .union(activeClaimedDetectedTranscriptSessionIDs(excludingSurfaceID: surfaceID)) if let excludingSessionID { claimed.remove(excludingSessionID) } @@ -415,19 +424,37 @@ final class AgentChatTranscriptService { sessionID.hasPrefix(provisionalClaudeSessionIDPrefix) } - private static func isSpecificClaudeTitle(_ title: String?) -> Bool { + static func claudeTitleDetectionKey(_ title: String?) -> String? { + guard let title else { + return nil + } + let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines) + guard title.lowercased().contains("claude") || trimmed.hasPrefix("✳") else { + return nil + } + return specificClaudeTitleKey(title) ?? "generic:claude" + } + + static func specificClaudeTitleKey(_ title: String?) -> String? { guard var title = title?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty else { - return false + return nil } while let first = title.first, !first.isLetter && !first.isNumber { title.removeFirst() title = title.trimmingCharacters(in: .whitespacesAndNewlines) } let normalized = title.lowercased() - return !normalized.isEmpty - && normalized != "claude code" - && !normalized.hasPrefix("claude ·") + guard !normalized.isEmpty, + normalized != "claude code", + !normalized.hasPrefix("claude ·") else { + return nil + } + return "specific:\(normalized)" + } + + static func isSpecificClaudeTitle(_ title: String?) -> Bool { + specificClaudeTitleKey(title) != nil } /// Encodes a wire value into the `[String: Any]` payload shape the diff --git a/Sources/TerminalController+MobileChat.swift b/Sources/TerminalController+MobileChat.swift index d17efc273c06..7f1857adf58f 100644 --- a/Sources/TerminalController+MobileChat.swift +++ b/Sources/TerminalController+MobileChat.swift @@ -94,6 +94,29 @@ extension TerminalController { adoptDetectedAgentSessions(workspace: resolved.workspace) } + /// Surface-scoped title-change adoption path. Unlike the workspace-list + /// sweep, this handles live terminal title churn and must avoid rescanning + /// every terminal in the workspace. + @discardableResult + func adoptDetectedAgentSession(titleChange: GhosttyTitleChange) -> Bool { + guard let resolved = mobileResolveWorkspaceAndSurface( + params: [ + "workspace_id": titleChange.tabId.uuidString, + "surface_id": titleChange.surfaceId.uuidString, + ], + requireTerminal: false + ), + let surfaceId = resolved.surfaceId, + let panel = resolved.workspace.terminalPanel(for: surfaceId) else { + return false + } + return adoptDetectedAgentSession( + workspace: resolved.workspace, + panel: panel, + title: titleChange.title + ) + } + /// Workspace-typed core of ``adoptDetectedAgentSessions(workspaceID:)``, /// for callers that already hold the `Workspace` (the workspace-list RPC /// enumerates every workspace and adopts inline, so the toggle is known @@ -102,32 +125,40 @@ extension TerminalController { /// once the surface has a session, so a repeat scan of an already-adopted /// workspace touches no filesystem. func adoptDetectedAgentSessions(workspace: Workspace) { - let workspaceID = workspace.id.uuidString - guard let service = agentChatTranscriptService else { return } for panel in workspace.panels.values.compactMap({ $0 as? TerminalPanel }) { - let context = WorkspaceContentView.terminalAgentContext(panel: panel, workspace: workspace) let title = workspace.panelTitle(panelId: panel.id) ?? panel.displayTitle - let normalizedTitle = title.lowercased() - // Claude is the case the wrapper-launched workflow hits; detect by - // launch metadata (hook PID key / initial command) or the live - // terminal title claude sets ("✳ Claude Code", then "✳ "). - let isClaude = TextBoxAgentDetection.isClaudeCode(context: context) - || normalizedTitle.contains("claude") - || title.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("✳") - guard isClaude else { continue } - let cwd = workspace.panelDirectories[panel.id] - ?? (panel.directory.isEmpty ? nil : panel.directory) - ?? (workspace.currentDirectory.isEmpty ? nil : workspace.currentDirectory) - guard let cwd, !cwd.isEmpty else { continue } - service.adoptDetectedClaudeSession( - workspaceID: workspaceID, - surfaceID: panel.id.uuidString, - workingDirectory: cwd, - titleHint: title - ) + adoptDetectedAgentSession(workspace: workspace, panel: panel, title: title) } } + @discardableResult + private func adoptDetectedAgentSession( + workspace: Workspace, + panel: TerminalPanel, + title: String + ) -> Bool { + guard let service = agentChatTranscriptService else { return false } + let context = WorkspaceContentView.terminalAgentContext(panel: panel, workspace: workspace) + let normalizedTitle = title.lowercased() + // Claude is the case the wrapper-launched workflow hits; detect by + // launch metadata (hook PID key / initial command) or the live + // terminal title claude sets ("✳ Claude Code", then "✳ "). + let isClaude = TextBoxAgentDetection.isClaudeCode(context: context) + || normalizedTitle.contains("claude") + || title.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("✳") + guard isClaude else { return false } + let cwd = workspace.panelDirectories[panel.id] + ?? (panel.directory.isEmpty ? nil : panel.directory) + ?? (workspace.currentDirectory.isEmpty ? nil : workspace.currentDirectory) + guard let cwd, !cwd.isEmpty else { return false } + return service.adoptDetectedClaudeSession( + workspaceID: workspace.id.uuidString, + surfaceID: panel.id.uuidString, + workingDirectory: cwd, + titleHint: title + ) + } + /// `mobile.chat.history`: one transcript page for a session. func v2MobileChatHistory(params: [String: Any]) async -> V2CallResult { guard let sessionID = v2RawString(params, "session_id") else { diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index bda597722ce7..e45b36fceba4 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -14,6 +14,7 @@ ACA7C4A70000000000000007 /* AgentChatSessionRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACA7C4A70000000000000008 /* AgentChatSessionRegistry.swift */; }; ACA7C4A70000000000000005 /* AgentChatTranscriptResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACA7C4A70000000000000006 /* AgentChatTranscriptResolver.swift */; }; A9E020000000000000000010 /* AgentChatTranscriptResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9E010000000000000000010 /* AgentChatTranscriptResolverTests.swift */; }; + ACA7C4A70000000000000101 /* AgentChatTranscriptService+TitleDetection.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACA7C4A70000000000000102 /* AgentChatTranscriptService+TitleDetection.swift */; }; ACA7C4A7000000000000000B /* AgentChatTranscriptService.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACA7C4A7000000000000000C /* AgentChatTranscriptService.swift */; }; ACA7C4A70000000000000009 /* AgentChatTranscriptTailer.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACA7C4A7000000000000000A /* AgentChatTranscriptTailer.swift */; }; A9F200000000000000000001 /* AgentExecutableResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9F100000000000000000001 /* AgentExecutableResolver.swift */; }; @@ -1060,6 +1061,7 @@ ACA7C4A70000000000000008 /* AgentChatSessionRegistry.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "AgentChatSessionRegistry.swift"; sourceTree = ""; }; ACA7C4A70000000000000006 /* AgentChatTranscriptResolver.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "AgentChatTranscriptResolver.swift"; sourceTree = ""; }; A9E010000000000000000010 /* AgentChatTranscriptResolverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentChatTranscriptResolverTests.swift; sourceTree = ""; }; + ACA7C4A70000000000000102 /* AgentChatTranscriptService+TitleDetection.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "AgentChatTranscriptService+TitleDetection.swift"; sourceTree = ""; }; ACA7C4A7000000000000000C /* AgentChatTranscriptService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "AgentChatTranscriptService.swift"; sourceTree = ""; }; ACA7C4A7000000000000000A /* AgentChatTranscriptTailer.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "AgentChatTranscriptTailer.swift"; sourceTree = ""; }; A9F100000000000000000001 /* AgentExecutableResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentExecutableResolver.swift; sourceTree = ""; }; @@ -2151,6 +2153,7 @@ ACA7C4A70000000000000006 /* AgentChatTranscriptResolver.swift */, ACA7C4A70000000000000008 /* AgentChatSessionRegistry.swift */, ACA7C4A7000000000000000A /* AgentChatTranscriptTailer.swift */, + ACA7C4A70000000000000102 /* AgentChatTranscriptService+TitleDetection.swift */, ACA7C4A7000000000000000C /* AgentChatTranscriptService.swift */, ); name = AgentChat; @@ -3499,6 +3502,7 @@ ACA7C4A70000000000000001 /* AgentChatSessionRecord.swift in Sources */, ACA7C4A70000000000000007 /* AgentChatSessionRegistry.swift in Sources */, ACA7C4A70000000000000005 /* AgentChatTranscriptResolver.swift in Sources */, + ACA7C4A70000000000000101 /* AgentChatTranscriptService+TitleDetection.swift in Sources */, ACA7C4A7000000000000000B /* AgentChatTranscriptService.swift in Sources */, ACA7C4A70000000000000009 /* AgentChatTranscriptTailer.swift in Sources */, A9F200000000000000000001 /* AgentExecutableResolver.swift in Sources */,