From 56e2ce64edc7243631590e3ae26048d4bf51c935 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 19 Jun 2026 15:42:22 -0700 Subject: [PATCH 01/18] fix: coalesce title-based transcript adoption --- Sources/AppDelegate.swift | 2 +- .../AgentChat/AgentChatSessionRegistry.swift | 9 + .../AgentChatTranscriptResolver.swift | 8 +- .../AgentChatTranscriptService.swift | 267 ++++++++++++++---- Sources/TerminalController+MobileChat.swift | 73 +++-- 5 files changed, 280 insertions(+), 79 deletions(-) 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/Mobile/AgentChat/AgentChatSessionRegistry.swift b/Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift index 8df480a293bc..42993377d76c 100644 --- a/Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift +++ b/Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift @@ -59,6 +59,15 @@ 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? { + sweepDeadProcesses() + return records.values.first { $0.surfaceID == surfaceID && $0.state != .ended } + } + /// 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 diff --git a/Sources/Mobile/AgentChat/AgentChatTranscriptResolver.swift b/Sources/Mobile/AgentChat/AgentChatTranscriptResolver.swift index abd499758901..13fee58d26a4 100644 --- a/Sources/Mobile/AgentChat/AgentChatTranscriptResolver.swift +++ b/Sources/Mobile/AgentChat/AgentChatTranscriptResolver.swift @@ -6,16 +6,14 @@ 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 /// 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 +22,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 +60,7 @@ struct AgentChatTranscriptResolver { excludingSessionIDs: Set = [], titleHint: String? = nil ) -> (sessionID: String, path: String)? { + 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 @@ -148,6 +148,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 +164,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) diff --git a/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift b/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift index a9579b7bf546..2a3c247c4d1e 100644 --- a/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift +++ b/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift @@ -19,13 +19,20 @@ 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] = [:] private var ghosttyTitleSubscription: GhosttyTitleChangeSubscription? + private var pendingTitleChanges: [String: PendingTitleChange] = [:] + private var titleChangeTasks: [String: Task] = [:] + private var deliveredTitleKeys: [String: String] = [:] + private var transcriptResolutionTasks: [String: Task] = [:] + private var transcriptResolutionKeys: [String: ClaudeTranscriptResolutionKey] = [:] + private var titleAdoptionHandler: (@MainActor (GhosttyTitleChange) -> Bool)? private static let detectionScanThrottle: TimeInterval = 4 + private static let titleChangeDebounceNanoseconds: UInt64 = 250_000_000 private static let provisionalClaudeSessionIDPrefix = "detected-claude-surface-" /// Creates the service with a hook-store-backed registry. @@ -54,12 +61,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 +76,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 +140,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 +178,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 +270,156 @@ final class AgentChatTranscriptService { // MARK: - Internals + private struct PendingTitleChange { + let change: GhosttyTitleChange + let titleKey: String + } + + private struct ClaudeTranscriptResolutionKey: Equatable, Sendable { + let workingDirectory: String + let claimedSessionIDs: Set + let titleKey: String? + let forceScan: Bool + } + + private func scheduleTitleDetectedAdoption(_ change: GhosttyTitleChange) { + guard let titleKey = Self.claudeTitleDetectionKey(change.title) else { return } + let surfaceID = change.surfaceId.uuidString + if pendingTitleChanges[surfaceID]?.titleKey == titleKey { + return + } + if pendingTitleChanges[surfaceID] == nil, + deliveredTitleKeys[surfaceID] == titleKey { + return + } + + pendingTitleChanges[surfaceID] = PendingTitleChange(change: change, titleKey: titleKey) + titleChangeTasks[surfaceID]?.cancel() + titleChangeTasks[surfaceID] = Task { @MainActor [weak self] in + do { + // Bounded debounce for terminal title bursts; replaced by newer titles for this surface. + try await Task.sleep(nanoseconds: Self.titleChangeDebounceNanoseconds) + } catch { + return + } + self?.flushTitleDetectedAdoption(surfaceID: surfaceID) + } + } + + private func flushTitleDetectedAdoption(surfaceID: String) { + titleChangeTasks[surfaceID] = nil + guard let pending = pendingTitleChanges.removeValue(forKey: surfaceID) else { + return + } + if titleAdoptionHandler?(pending.change) == true { + deliveredTitleKeys[surfaceID] = pending.titleKey + } + } + + private 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() + if let excludingSessionID { + claimed.remove(excludingSessionID) + } + let key = ClaudeTranscriptResolutionKey( + workingDirectory: workingDirectory, + claimedSessionIDs: claimed, + titleKey: Self.specificClaudeTitleKey(titleHint), + forceScan: forceScan + ) + guard transcriptResolutionKeys[surfaceID] != key else { + return + } + + detectionScanAt[surfaceID] = now + transcriptResolutionKeys[surfaceID] = key + transcriptResolutionTasks[surfaceID]?.cancel() + let resolver = self.resolver + transcriptResolutionTasks[surfaceID] = Task { @MainActor [ + weak self, + resolver, + key, + workspaceID, + workingDirectory, + surfaceID, + titleHint, + claimed + ] in + let resolved = await Task.detached(priority: .utility) { + resolver.newestClaudeTranscript( + workingDirectory: workingDirectory, + excludingSessionIDs: claimed, + titleHint: titleHint + ) + }.value + guard !Task.isCancelled else { return } + self?.applyClaudeTranscriptResolution( + resolved, + key: key, + workspaceID: workspaceID, + workingDirectory: workingDirectory, + surfaceID: surfaceID + ) + } + } + + private func applyClaudeTranscriptResolution( + _ resolved: (sessionID: String, path: String)?, + key: ClaudeTranscriptResolutionKey, + workspaceID: String, + workingDirectory: String, + surfaceID: String + ) { + guard transcriptResolutionKeys[surfaceID] == key else { + return + } + transcriptResolutionTasks[surfaceID] = nil + transcriptResolutionKeys[surfaceID] = nil + + guard let resolved else { return } + if let claimed = registry.record(sessionID: resolved.sessionID), + claimed.surfaceID != nil, + claimed.surfaceID != surfaceID { + 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 + } + return + } + + registry.adoptDetectedSession( + sessionID: resolved.sessionID, + agentKind: .claude, + workspaceID: workspaceID, + surfaceID: surfaceID, + workingDirectory: workingDirectory, + transcriptPath: resolved.path, + at: Date() + ) + } + @discardableResult private func ensureTailer(for record: AgentChatSessionRecord) -> AgentChatTranscriptTailer? { if let existing = tailers[record.sessionID] { @@ -415,19 +559,34 @@ final class AgentChatTranscriptService { sessionID.hasPrefix(provisionalClaudeSessionIDPrefix) } - private static func isSpecificClaudeTitle(_ title: String?) -> Bool { + private static func claudeTitleDetectionKey(_ title: String?) -> String? { + guard let title, + title.lowercased().contains("claude") else { + return nil + } + return specificClaudeTitleKey(title) ?? "generic:claude" + } + + private 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)" + } + + private 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 { From 27d747d2f151d87ff7fd140ce9afbc27b2efdb78 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 19 Jun 2026 15:42:25 -0700 Subject: [PATCH 02/18] fix: scope sidebar row selection updates --- Sources/ContentView.swift | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) 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( From 3ea86daa4c87c7d3452f4aaa041e657f6c9e58d9 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 19 Jun 2026 15:46:16 -0700 Subject: [PATCH 03/18] ci: update swift file length budget --- .github/swift-file-length-budget.tsv | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index a0160e767e9a..2a41ebfd5e29 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -1,15 +1,15 @@ # cmux-owned Swift file length budget. # Format: max_linesrelative path # Reduce counts as files shrink. CI fails if tracked files exceed this budget. -34323 CLI/cmux.swift +34330 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 11841 Sources/GhosttyTerminalView.swift 11411 Sources/Panels/BrowserPanel.swift -9339 cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift +9335 cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift 7988 Sources/Panels/BrowserPanelView.swift 7355 cmuxTests/WorkspaceUnitTests.swift 7218 cmuxTests/WorkspaceRemoteConnectionTests.swift @@ -164,6 +164,7 @@ 608 Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Coordinators/WorkspaceGroupCoordinator.swift 606 Sources/SettingsNavigation.swift 604 Packages/macOS/CmuxCommandPalette/Tests/CmuxCommandPaletteTests/CommandPaletteNucleoFFITests.swift +601 Sources/Mobile/AgentChat/AgentChatTranscriptService.swift 599 Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizerPrimaryPolicies.swift 596 cmuxTests/CmuxEventBusTests.swift 594 Sources/SessionIndexModels.swift @@ -211,6 +212,7 @@ 518 Packages/macOS/CmuxSwiftRender/Tests/CmuxSwiftRenderTests/Corpus/stress-git-review-queue-command-deck.swift 517 Sources/PaneMemoryGuardrail.swift 516 Sources/TerminalImageTransfer.swift +515 Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Sizing.swift 514 Packages/macOS/CmuxSwiftRender/Sources/CmuxSwiftRender/ExpressionEvaluator.swift 514 cmuxUITests/UpdatePillUITests.swift 509 Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizerAdditionalPolicies.swift From e065090f7226896d2591dad8aec3431c499c5686 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 19 Jun 2026 15:46:59 -0700 Subject: [PATCH 04/18] fix: claim resolved detected transcript ids --- .github/swift-file-length-budget.tsv | 2 +- Sources/Mobile/AgentChat/AgentChatTranscriptService.swift | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 2a41ebfd5e29..851698b00c57 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -162,9 +162,9 @@ 614 Sources/PortScanner.swift 612 cmuxUITests/FeedSidebarUITests.swift 608 Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Coordinators/WorkspaceGroupCoordinator.swift +606 Sources/Mobile/AgentChat/AgentChatTranscriptService.swift 606 Sources/SettingsNavigation.swift 604 Packages/macOS/CmuxCommandPalette/Tests/CmuxCommandPaletteTests/CommandPaletteNucleoFFITests.swift -601 Sources/Mobile/AgentChat/AgentChatTranscriptService.swift 599 Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizerPrimaryPolicies.swift 596 cmuxTests/CmuxEventBusTests.swift 594 Sources/SessionIndexModels.swift diff --git a/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift b/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift index 2a3c247c4d1e..4facb00c424e 100644 --- a/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift +++ b/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift @@ -30,6 +30,7 @@ final class AgentChatTranscriptService { private var deliveredTitleKeys: [String: String] = [:] private var transcriptResolutionTasks: [String: Task] = [:] private var transcriptResolutionKeys: [String: ClaudeTranscriptResolutionKey] = [:] + private var claimedDetectedTranscriptSessionIDs: Set = [] private var titleAdoptionHandler: (@MainActor (GhosttyTitleChange) -> Bool)? private static let detectionScanThrottle: TimeInterval = 4 private static let titleChangeDebounceNanoseconds: UInt64 = 250_000_000 @@ -331,7 +332,7 @@ final class AgentChatTranscriptService { return } - var claimed = registry.claimedSessionIDs() + var claimed = registry.claimedSessionIDs().union(claimedDetectedTranscriptSessionIDs) if let excludingSessionID { claimed.remove(excludingSessionID) } @@ -391,6 +392,9 @@ final class AgentChatTranscriptService { transcriptResolutionKeys[surfaceID] = nil guard let resolved else { return } + guard !claimedDetectedTranscriptSessionIDs.contains(resolved.sessionID) else { + return + } if let claimed = registry.record(sessionID: resolved.sessionID), claimed.surfaceID != nil, claimed.surfaceID != surfaceID { @@ -398,6 +402,7 @@ final class AgentChatTranscriptService { } detectionScanAt.removeValue(forKey: surfaceID) + claimedDetectedTranscriptSessionIDs.insert(resolved.sessionID) if let bound = registry.liveSession(surfaceID: surfaceID) { guard bound.transcriptPath == nil else { return } registry.update(sessionID: bound.sessionID) { record in From c070f7a897c29677ca36f7f5d9d676ec3e84090f Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 19 Jun 2026 15:52:21 -0700 Subject: [PATCH 05/18] fix: retry detected transcript collisions --- .github/swift-file-length-budget.tsv | 2 +- .../AgentChat/AgentChatSessionRegistry.swift | 4 +++- .../AgentChatTranscriptService.swift | 24 ++++++++++++++++--- 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 851698b00c57..db8b2a1d6133 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -155,6 +155,7 @@ 641 cmuxTests/CommandPaletteNucleoFFITests.swift 636 Sources/TerminalController+ControlPaneContext.swift 630 Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutWhenClause.swift +624 Sources/Mobile/AgentChat/AgentChatTranscriptService.swift 621 cmuxUITests/RightSidebarChromeHeightUITests.swift 620 cmuxTests/FinderFileDropRegressionTests.swift 620 cmuxTests/TerminalNotificationQueueTests.swift @@ -162,7 +163,6 @@ 614 Sources/PortScanner.swift 612 cmuxUITests/FeedSidebarUITests.swift 608 Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Coordinators/WorkspaceGroupCoordinator.swift -606 Sources/Mobile/AgentChat/AgentChatTranscriptService.swift 606 Sources/SettingsNavigation.swift 604 Packages/macOS/CmuxCommandPalette/Tests/CmuxCommandPaletteTests/CommandPaletteNucleoFFITests.swift 599 Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizerPrimaryPolicies.swift diff --git a/Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift b/Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift index 42993377d76c..9faf4f6b1c2c 100644 --- a/Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift +++ b/Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift @@ -65,7 +65,9 @@ final class AgentChatSessionRegistry { /// - Returns: A non-ended record bound to the surface, or `nil`. func liveSession(surfaceID: String) -> AgentChatSessionRecord? { sweepDeadProcesses() - return records.values.first { $0.surfaceID == surfaceID && $0.state != .ended } + return records.values + .filter { $0.surfaceID == surfaceID && $0.state != .ended } + .max { $0.lastActivityAt < $1.lastActivityAt } } /// Every session id the registry already tracks. Title-detected adoption diff --git a/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift b/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift index 4facb00c424e..f31b5e0334f9 100644 --- a/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift +++ b/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift @@ -373,7 +373,8 @@ final class AgentChatTranscriptService { key: key, workspaceID: workspaceID, workingDirectory: workingDirectory, - surfaceID: surfaceID + surfaceID: surfaceID, + titleHint: titleHint ) } } @@ -383,7 +384,8 @@ final class AgentChatTranscriptService { key: ClaudeTranscriptResolutionKey, workspaceID: String, workingDirectory: String, - surfaceID: String + surfaceID: String, + titleHint: String? ) { guard transcriptResolutionKeys[surfaceID] == key else { return @@ -393,11 +395,27 @@ final class AgentChatTranscriptService { guard let resolved else { return } guard !claimedDetectedTranscriptSessionIDs.contains(resolved.sessionID) else { + scheduleClaudeTranscriptResolution( + workspaceID: workspaceID, + workingDirectory: workingDirectory, + surfaceID: surfaceID, + excludingSessionID: registry.liveSession(surfaceID: surfaceID)?.sessionID, + titleHint: titleHint, + forceScan: true + ) return } if let claimed = registry.record(sessionID: resolved.sessionID), claimed.surfaceID != nil, claimed.surfaceID != surfaceID { + scheduleClaudeTranscriptResolution( + workspaceID: workspaceID, + workingDirectory: workingDirectory, + surfaceID: surfaceID, + excludingSessionID: registry.liveSession(surfaceID: surfaceID)?.sessionID, + titleHint: titleHint, + forceScan: true + ) return } @@ -545,7 +563,7 @@ final class AgentChatTranscriptService { return nil } detectionScanAt[surfaceID] = now - var claimed = registry.claimedSessionIDs() + var claimed = registry.claimedSessionIDs().union(claimedDetectedTranscriptSessionIDs) if let excludingSessionID { claimed.remove(excludingSessionID) } From f127bbe8929061e3a6120132e33ba0b0bb65e5fb Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 19 Jun 2026 15:54:39 -0700 Subject: [PATCH 06/18] fix: reset detected title cache by surface --- .github/swift-file-length-budget.tsv | 2 +- .../AgentChat/AgentChatSessionRegistry.swift | 32 ++++++++++++++++--- .../AgentChatTranscriptService.swift | 30 ++++++++++++----- 3 files changed, 51 insertions(+), 13 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index db8b2a1d6133..c96b1593b364 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -153,9 +153,9 @@ 653 Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Import/Detection/BrowserInstalledBrowserDetector.swift 650 Sources/Panels/MarkdownRemoteImageLoader.swift 641 cmuxTests/CommandPaletteNucleoFFITests.swift +638 Sources/Mobile/AgentChat/AgentChatTranscriptService.swift 636 Sources/TerminalController+ControlPaneContext.swift 630 Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutWhenClause.swift -624 Sources/Mobile/AgentChat/AgentChatTranscriptService.swift 621 cmuxUITests/RightSidebarChromeHeightUITests.swift 620 cmuxTests/FinderFileDropRegressionTests.swift 620 cmuxTests/TerminalNotificationQueueTests.swift diff --git a/Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift b/Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift index 9faf4f6b1c2c..65163d24babe 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 @@ -65,9 +66,14 @@ final class AgentChatSessionRegistry { /// - Returns: A non-ended record bound to the surface, or `nil`. func liveSession(surfaceID: String) -> AgentChatSessionRecord? { sweepDeadProcesses() - return records.values - .filter { $0.surfaceID == surfaceID && $0.state != .ended } - .max { $0.lastActivityAt < $1.lastActivityAt } + guard let sessionID = liveSessionIDBySurfaceID[surfaceID], + let record = records[sessionID], + record.surfaceID == surfaceID, + record.state != .ended else { + refreshLiveSessionIndex(surfaceID: surfaceID) + return liveSessionIDBySurfaceID[surfaceID].flatMap { records[$0] } + } + return record } /// Every session id the registry already tracks. Title-detected adoption @@ -109,6 +115,8 @@ final class AgentChatSessionRegistry { var record = previous mutate(&record) records[sessionID] = record + refreshLiveSessionIndex(surfaceID: previous.surfaceID) + refreshLiveSessionIndex(surfaceID: record.surfaceID) onRecordChanged?(record, previous) } @@ -138,7 +146,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, @@ -150,6 +158,8 @@ final class AgentChatSessionRegistry { title: nil, pid: entry.pid ) + records[entry.sessionID] = record + refreshLiveSessionIndex(surfaceID: record.surfaceID) } } } @@ -195,6 +205,7 @@ final class AgentChatSessionRegistry { pid: nil ) records[sessionID] = record + refreshLiveSessionIndex(surfaceID: surfaceID) onRecordChanged?(record, nil) return record } @@ -260,10 +271,23 @@ final class AgentChatSessionRegistry { let previous = records[sessionID] record.state = Self.nextState(previous: record.state, event: event) records[sessionID] = record + refreshLiveSessionIndex(surfaceID: previous?.surfaceID) + refreshLiveSessionIndex(surfaceID: record.surfaceID) onRecordChanged?(record, previous) return record } + private func refreshLiveSessionIndex(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) + } + } + /// 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/AgentChatTranscriptService.swift b/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift index f31b5e0334f9..add6af664595 100644 --- a/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift +++ b/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift @@ -284,8 +284,11 @@ final class AgentChatTranscriptService { } private func scheduleTitleDetectedAdoption(_ change: GhosttyTitleChange) { - guard let titleKey = Self.claudeTitleDetectionKey(change.title) else { return } let surfaceID = change.surfaceId.uuidString + guard let titleKey = Self.claudeTitleDetectionKey(change.title) else { + clearTitleDetectionState(surfaceID: surfaceID) + return + } if pendingTitleChanges[surfaceID]?.titleKey == titleKey { return } @@ -317,6 +320,13 @@ final class AgentChatTranscriptService { } } + private func clearTitleDetectionState(surfaceID: String) { + pendingTitleChanges.removeValue(forKey: surfaceID) + titleChangeTasks[surfaceID]?.cancel() + titleChangeTasks[surfaceID] = nil + deliveredTitleKeys.removeValue(forKey: surfaceID) + } + private func scheduleClaudeTranscriptResolution( workspaceID: String, workingDirectory: String, @@ -512,13 +522,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) + } + 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 { From bedd0313fdf905c58b8a13568135a34127eb4a21 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 19 Jun 2026 15:59:46 -0700 Subject: [PATCH 07/18] fix: bound detected transcript scans --- .github/swift-file-length-budget.tsv | 2 +- .../AgentChatTranscriptResolver.swift | 41 +++++++++++++------ .../AgentChatTranscriptService.swift | 24 ++++++----- 3 files changed, 44 insertions(+), 23 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index c96b1593b364..272032d3b893 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -152,8 +152,8 @@ 654 Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/KeyboardShortcutsSection.swift 653 Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Import/Detection/BrowserInstalledBrowserDetector.swift 650 Sources/Panels/MarkdownRemoteImageLoader.swift +642 Sources/Mobile/AgentChat/AgentChatTranscriptService.swift 641 cmuxTests/CommandPaletteNucleoFFITests.swift -638 Sources/Mobile/AgentChat/AgentChatTranscriptService.swift 636 Sources/TerminalController+ControlPaneContext.swift 630 Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutWhenClause.swift 621 cmuxUITests/RightSidebarChromeHeightUITests.swift diff --git a/Sources/Mobile/AgentChat/AgentChatTranscriptResolver.swift b/Sources/Mobile/AgentChat/AgentChatTranscriptResolver.swift index 13fee58d26a4..c546cb15370b 100644 --- a/Sources/Mobile/AgentChat/AgentChatTranscriptResolver.swift +++ b/Sources/Mobile/AgentChat/AgentChatTranscriptResolver.swift @@ -60,6 +60,7 @@ struct AgentChatTranscriptResolver: Sendable { 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* @@ -75,6 +76,7 @@ struct AgentChatTranscriptResolver: Sendable { .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,18 +87,17 @@ struct AgentChatTranscriptResolver: Sendable { 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 @@ -216,3 +217,19 @@ struct AgentChatTranscriptResolver: Sendable { return nil } } + +actor AgentChatTranscriptScanQueue { + func newestClaudeTranscript( + resolver: AgentChatTranscriptResolver, + workingDirectory: String, + excludingSessionIDs: Set, + titleHint: String? + ) -> (sessionID: String, path: String)? { + guard !Task.isCancelled else { return nil } + return resolver.newestClaudeTranscript( + workingDirectory: workingDirectory, + excludingSessionIDs: excludingSessionIDs, + titleHint: titleHint + ) + } +} diff --git a/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift b/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift index add6af664595..df8e38b6663e 100644 --- a/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift +++ b/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift @@ -12,6 +12,7 @@ final class AgentChatTranscriptService { private let registry: AgentChatSessionRegistry private let resolver: AgentChatTranscriptResolver + private let scanQueue: AgentChatTranscriptScanQueue private let coding = ChatWireCoding() private var tailers: [String: AgentChatTranscriptTailer] = [:] /// Sessions whose transcript could not be resolved; skipped until an @@ -50,10 +51,12 @@ final class AgentChatTranscriptService { /// - resolver: Transcript path resolver. init( registry: AgentChatSessionRegistry, - resolver: AgentChatTranscriptResolver = AgentChatTranscriptResolver() + resolver: AgentChatTranscriptResolver = AgentChatTranscriptResolver(), + scanQueue: AgentChatTranscriptScanQueue = AgentChatTranscriptScanQueue() ) { self.registry = registry self.resolver = resolver + self.scanQueue = scanQueue registry.onRecordChanged = { [weak self] record, previous in self?.handleRecordChange(record, previous: previous) } @@ -293,7 +296,8 @@ final class AgentChatTranscriptService { return } if pendingTitleChanges[surfaceID] == nil, - deliveredTitleKeys[surfaceID] == titleKey { + deliveredTitleKeys[surfaceID] == titleKey, + registry.liveSession(surfaceID: surfaceID)?.transcriptPath != nil { return } @@ -315,7 +319,8 @@ final class AgentChatTranscriptService { guard let pending = pendingTitleChanges.removeValue(forKey: surfaceID) else { return } - if titleAdoptionHandler?(pending.change) == true { + if titleAdoptionHandler?(pending.change) == true, + registry.liveSession(surfaceID: surfaceID)?.transcriptPath != nil { deliveredTitleKeys[surfaceID] = pending.titleKey } } @@ -370,13 +375,12 @@ final class AgentChatTranscriptService { titleHint, claimed ] in - let resolved = await Task.detached(priority: .utility) { - resolver.newestClaudeTranscript( - workingDirectory: workingDirectory, - excludingSessionIDs: claimed, - titleHint: titleHint - ) - }.value + let resolved = await scanQueue.newestClaudeTranscript( + resolver: resolver, + workingDirectory: workingDirectory, + excludingSessionIDs: claimed, + titleHint: titleHint + ) guard !Task.isCancelled else { return } self?.applyClaudeTranscriptResolution( resolved, From 62049d464f6209813bfc02d4490d72c167a00676 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 19 Jun 2026 16:04:37 -0700 Subject: [PATCH 08/18] fix: handle star claude title debounce --- .github/swift-file-length-budget.tsv | 2 +- .../Mobile/AgentChat/AgentChatTranscriptService.swift | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 272032d3b893..52ec9d322644 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -152,7 +152,7 @@ 654 Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/KeyboardShortcutsSection.swift 653 Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Import/Detection/BrowserInstalledBrowserDetector.swift 650 Sources/Panels/MarkdownRemoteImageLoader.swift -642 Sources/Mobile/AgentChat/AgentChatTranscriptService.swift +647 Sources/Mobile/AgentChat/AgentChatTranscriptService.swift 641 cmuxTests/CommandPaletteNucleoFFITests.swift 636 Sources/TerminalController+ControlPaneContext.swift 630 Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutWhenClause.swift diff --git a/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift b/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift index df8e38b6663e..16df8518a400 100644 --- a/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift +++ b/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift @@ -365,9 +365,11 @@ final class AgentChatTranscriptService { transcriptResolutionKeys[surfaceID] = key transcriptResolutionTasks[surfaceID]?.cancel() let resolver = self.resolver + let scanQueue = self.scanQueue transcriptResolutionTasks[surfaceID] = Task { @MainActor [ weak self, resolver, + scanQueue, key, workspaceID, workingDirectory, @@ -601,8 +603,11 @@ final class AgentChatTranscriptService { } private static func claudeTitleDetectionKey(_ title: String?) -> String? { - guard let title, - title.lowercased().contains("claude") else { + 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" From 0e049bce44a652eda8c2e9716af09ed5ece757af Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 19 Jun 2026 16:08:54 -0700 Subject: [PATCH 09/18] fix: cancel cleared title transcript scans --- .github/swift-file-length-budget.tsv | 2 +- Sources/Mobile/AgentChat/AgentChatTranscriptService.swift | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 52ec9d322644..7a85b9ba30ff 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -151,8 +151,8 @@ 655 Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeLifecycle.swift 654 Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/KeyboardShortcutsSection.swift 653 Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Import/Detection/BrowserInstalledBrowserDetector.swift +651 Sources/Mobile/AgentChat/AgentChatTranscriptService.swift 650 Sources/Panels/MarkdownRemoteImageLoader.swift -647 Sources/Mobile/AgentChat/AgentChatTranscriptService.swift 641 cmuxTests/CommandPaletteNucleoFFITests.swift 636 Sources/TerminalController+ControlPaneContext.swift 630 Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutWhenClause.swift diff --git a/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift b/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift index 16df8518a400..b959fa358522 100644 --- a/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift +++ b/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift @@ -330,6 +330,10 @@ final class AgentChatTranscriptService { titleChangeTasks[surfaceID]?.cancel() titleChangeTasks[surfaceID] = nil deliveredTitleKeys.removeValue(forKey: surfaceID) + transcriptResolutionTasks[surfaceID]?.cancel() + transcriptResolutionTasks[surfaceID] = nil + transcriptResolutionKeys.removeValue(forKey: surfaceID) + detectionScanAt.removeValue(forKey: surfaceID) } private func scheduleClaudeTranscriptResolution( From 3559ddcfcc9a46db24546e7fbfb3277571897f82 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 19 Jun 2026 16:13:17 -0700 Subject: [PATCH 10/18] fix: coalesce title adoption without sleep task --- .github/swift-file-length-budget.tsv | 2 +- .../AgentChatTranscriptService.swift | 31 +++++++------------ 2 files changed, 13 insertions(+), 20 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 7a85b9ba30ff..08a03f85b1de 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -151,8 +151,8 @@ 655 Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeLifecycle.swift 654 Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/KeyboardShortcutsSection.swift 653 Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Import/Detection/BrowserInstalledBrowserDetector.swift -651 Sources/Mobile/AgentChat/AgentChatTranscriptService.swift 650 Sources/Panels/MarkdownRemoteImageLoader.swift +644 Sources/Mobile/AgentChat/AgentChatTranscriptService.swift 641 cmuxTests/CommandPaletteNucleoFFITests.swift 636 Sources/TerminalController+ControlPaneContext.swift 630 Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutWhenClause.swift diff --git a/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift b/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift index b959fa358522..82377fd13642 100644 --- a/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift +++ b/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift @@ -27,14 +27,13 @@ final class AgentChatTranscriptService { private var detectionScanAt: [String: Date] = [:] private var ghosttyTitleSubscription: GhosttyTitleChangeSubscription? private var pendingTitleChanges: [String: PendingTitleChange] = [:] - private var titleChangeTasks: [String: Task] = [:] private var deliveredTitleKeys: [String: String] = [:] private var transcriptResolutionTasks: [String: Task] = [:] private var transcriptResolutionKeys: [String: ClaudeTranscriptResolutionKey] = [:] private var claimedDetectedTranscriptSessionIDs: Set = [] private var titleAdoptionHandler: (@MainActor (GhosttyTitleChange) -> Bool)? + private let titleChangeCoalescer = NotificationBurstCoalescer(delay: 0.25) private static let detectionScanThrottle: TimeInterval = 4 - private static let titleChangeDebounceNanoseconds: UInt64 = 250_000_000 private static let provisionalClaudeSessionIDPrefix = "detected-claude-surface-" /// Creates the service with a hook-store-backed registry. @@ -302,33 +301,27 @@ final class AgentChatTranscriptService { } pendingTitleChanges[surfaceID] = PendingTitleChange(change: change, titleKey: titleKey) - titleChangeTasks[surfaceID]?.cancel() - titleChangeTasks[surfaceID] = Task { @MainActor [weak self] in - do { - // Bounded debounce for terminal title bursts; replaced by newer titles for this surface. - try await Task.sleep(nanoseconds: Self.titleChangeDebounceNanoseconds) - } catch { - return - } - self?.flushTitleDetectedAdoption(surfaceID: surfaceID) + titleChangeCoalescer.signal { [weak self] in + self?.flushTitleDetectedAdoptions() } } - private func flushTitleDetectedAdoption(surfaceID: String) { - titleChangeTasks[surfaceID] = nil - guard let pending = pendingTitleChanges.removeValue(forKey: surfaceID) else { + private func flushTitleDetectedAdoptions() { + guard !pendingTitleChanges.isEmpty else { return } - if titleAdoptionHandler?(pending.change) == true, - registry.liveSession(surfaceID: surfaceID)?.transcriptPath != nil { - deliveredTitleKeys[surfaceID] = pending.titleKey + 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 + } } } private func clearTitleDetectionState(surfaceID: String) { pendingTitleChanges.removeValue(forKey: surfaceID) - titleChangeTasks[surfaceID]?.cancel() - titleChangeTasks[surfaceID] = nil deliveredTitleKeys.removeValue(forKey: surfaceID) transcriptResolutionTasks[surfaceID]?.cancel() transcriptResolutionTasks[surfaceID] = nil From 3a807fc5c7dc93c90e3d666c28e9b13ddc3c2cfd Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 19 Jun 2026 16:18:29 -0700 Subject: [PATCH 11/18] fix: keep transcript scan helper within service --- .github/swift-file-length-budget.tsv | 2 +- .../AgentChatTranscriptResolver.swift | 16 ----- .../AgentChatTranscriptService.swift | 65 +++++++++++-------- 3 files changed, 38 insertions(+), 45 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 08a03f85b1de..5f326ad93346 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -151,8 +151,8 @@ 655 Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeLifecycle.swift 654 Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/KeyboardShortcutsSection.swift 653 Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Import/Detection/BrowserInstalledBrowserDetector.swift +653 Sources/Mobile/AgentChat/AgentChatTranscriptService.swift 650 Sources/Panels/MarkdownRemoteImageLoader.swift -644 Sources/Mobile/AgentChat/AgentChatTranscriptService.swift 641 cmuxTests/CommandPaletteNucleoFFITests.swift 636 Sources/TerminalController+ControlPaneContext.swift 630 Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutWhenClause.swift diff --git a/Sources/Mobile/AgentChat/AgentChatTranscriptResolver.swift b/Sources/Mobile/AgentChat/AgentChatTranscriptResolver.swift index c546cb15370b..6b74987c9ab4 100644 --- a/Sources/Mobile/AgentChat/AgentChatTranscriptResolver.swift +++ b/Sources/Mobile/AgentChat/AgentChatTranscriptResolver.swift @@ -217,19 +217,3 @@ struct AgentChatTranscriptResolver: Sendable { return nil } } - -actor AgentChatTranscriptScanQueue { - func newestClaudeTranscript( - resolver: AgentChatTranscriptResolver, - workingDirectory: String, - excludingSessionIDs: Set, - titleHint: String? - ) -> (sessionID: String, path: String)? { - guard !Task.isCancelled else { return nil } - return resolver.newestClaudeTranscript( - workingDirectory: workingDirectory, - excludingSessionIDs: excludingSessionIDs, - titleHint: titleHint - ) - } -} diff --git a/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift b/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift index 82377fd13642..9f3165688f59 100644 --- a/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift +++ b/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift @@ -12,7 +12,6 @@ final class AgentChatTranscriptService { private let registry: AgentChatSessionRegistry private let resolver: AgentChatTranscriptResolver - private let scanQueue: AgentChatTranscriptScanQueue private let coding = ChatWireCoding() private var tailers: [String: AgentChatTranscriptTailer] = [:] /// Sessions whose transcript could not be resolved; skipped until an @@ -50,12 +49,10 @@ final class AgentChatTranscriptService { /// - resolver: Transcript path resolver. init( registry: AgentChatSessionRegistry, - resolver: AgentChatTranscriptResolver = AgentChatTranscriptResolver(), - scanQueue: AgentChatTranscriptScanQueue = AgentChatTranscriptScanQueue() + resolver: AgentChatTranscriptResolver = AgentChatTranscriptResolver() ) { self.registry = registry self.resolver = resolver - self.scanQueue = scanQueue registry.onRecordChanged = { [weak self] record, previous in self?.handleRecordChange(record, previous: previous) } @@ -273,17 +270,13 @@ final class AgentChatTranscriptService { // MARK: - Internals - private struct PendingTitleChange { - let change: GhosttyTitleChange - let titleKey: String - } - - private struct ClaudeTranscriptResolutionKey: Equatable, Sendable { - let workingDirectory: String - let claimedSessionIDs: Set - let titleKey: String? - let forceScan: Bool - } + private typealias PendingTitleChange = (change: GhosttyTitleChange, titleKey: String) + private typealias ClaudeTranscriptResolutionKey = ( + workingDirectory: String, + claimedSessionIDs: Set, + titleKey: String?, + forceScan: Bool + ) private func scheduleTitleDetectedAdoption(_ change: GhosttyTitleChange) { let surfaceID = change.surfaceId.uuidString @@ -300,7 +293,7 @@ final class AgentChatTranscriptService { return } - pendingTitleChanges[surfaceID] = PendingTitleChange(change: change, titleKey: titleKey) + pendingTitleChanges[surfaceID] = (change: change, titleKey: titleKey) titleChangeCoalescer.signal { [weak self] in self?.flushTitleDetectedAdoptions() } @@ -348,7 +341,7 @@ final class AgentChatTranscriptService { if let excludingSessionID { claimed.remove(excludingSessionID) } - let key = ClaudeTranscriptResolutionKey( + let key: ClaudeTranscriptResolutionKey = ( workingDirectory: workingDirectory, claimedSessionIDs: claimed, titleKey: Self.specificClaudeTitleKey(titleHint), @@ -362,24 +355,40 @@ final class AgentChatTranscriptService { transcriptResolutionKeys[surfaceID] = key transcriptResolutionTasks[surfaceID]?.cancel() let resolver = self.resolver - let scanQueue = self.scanQueue + #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, - resolver, - scanQueue, + scanTask, key, workspaceID, workingDirectory, surfaceID, - titleHint, - claimed + titleHint ] in - let resolved = await scanQueue.newestClaudeTranscript( - resolver: resolver, - workingDirectory: workingDirectory, - excludingSessionIDs: claimed, - titleHint: titleHint - ) + let resolved = await withTaskCancellationHandler { + await scanTask.value + } onCancel: { + scanTask.cancel() + } guard !Task.isCancelled else { return } self?.applyClaudeTranscriptResolution( resolved, From 618d0fe42c5f64a04ca18035622bf0bfdfa802bd Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 19 Jun 2026 16:27:08 -0700 Subject: [PATCH 12/18] fix: split title detection helper below budget --- .github/swift-file-length-budget.tsv | 1 - ...ChatTranscriptService+TitleDetection.swift | 190 +++++++++++++++ .../AgentChatTranscriptService.swift | 219 ++---------------- cmux.xcodeproj/project.pbxproj | 4 + 4 files changed, 210 insertions(+), 204 deletions(-) create mode 100644 Sources/Mobile/AgentChat/AgentChatTranscriptService+TitleDetection.swift diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 5f326ad93346..6afd675c0438 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -151,7 +151,6 @@ 655 Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeLifecycle.swift 654 Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/KeyboardShortcutsSection.swift 653 Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Import/Detection/BrowserInstalledBrowserDetector.swift -653 Sources/Mobile/AgentChat/AgentChatTranscriptService.swift 650 Sources/Panels/MarkdownRemoteImageLoader.swift 641 cmuxTests/CommandPaletteNucleoFFITests.swift 636 Sources/TerminalController+ControlPaneContext.swift diff --git a/Sources/Mobile/AgentChat/AgentChatTranscriptService+TitleDetection.swift b/Sources/Mobile/AgentChat/AgentChatTranscriptService+TitleDetection.swift new file mode 100644 index 000000000000..e746ee571cea --- /dev/null +++ b/Sources/Mobile/AgentChat/AgentChatTranscriptService+TitleDetection.swift @@ -0,0 +1,190 @@ +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) { + pendingTitleChanges.removeValue(forKey: surfaceID) + deliveredTitleKeys.removeValue(forKey: surfaceID) + transcriptResolutionTasks[surfaceID]?.cancel() + transcriptResolutionTasks[surfaceID] = nil + transcriptResolutionKeys.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(claimedDetectedTranscriptSessionIDs) + if let excludingSessionID { + claimed.remove(excludingSessionID) + } + let key: ClaudeTranscriptResolutionKey = ( + workingDirectory: workingDirectory, + claimedSessionIDs: claimed, + titleKey: Self.specificClaudeTitleKey(titleHint), + forceScan: forceScan + ) + guard transcriptResolutionKeys[surfaceID] != key else { + return + } + + detectionScanAt[surfaceID] = now + transcriptResolutionKeys[surfaceID] = key + 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 transcriptResolutionKeys[surfaceID] == key else { + return + } + transcriptResolutionTasks[surfaceID] = nil + transcriptResolutionKeys[surfaceID] = nil + + guard let resolved else { return } + guard !claimedDetectedTranscriptSessionIDs.contains(resolved.sessionID) else { + scheduleClaudeTranscriptResolution( + workspaceID: workspaceID, + workingDirectory: workingDirectory, + surfaceID: surfaceID, + excludingSessionID: registry.liveSession(surfaceID: surfaceID)?.sessionID, + titleHint: titleHint, + forceScan: true + ) + return + } + if let claimed = registry.record(sessionID: resolved.sessionID), + claimed.surfaceID != nil, + claimed.surfaceID != surfaceID { + scheduleClaudeTranscriptResolution( + workspaceID: workspaceID, + workingDirectory: workingDirectory, + surfaceID: surfaceID, + excludingSessionID: registry.liveSession(surfaceID: surfaceID)?.sessionID, + titleHint: titleHint, + forceScan: true + ) + return + } + + detectionScanAt.removeValue(forKey: surfaceID) + claimedDetectedTranscriptSessionIDs.insert(resolved.sessionID) + 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 + } + return + } + + registry.adoptDetectedSession( + sessionID: resolved.sessionID, + agentKind: .claude, + workspaceID: workspaceID, + surfaceID: surfaceID, + workingDirectory: workingDirectory, + transcriptPath: resolved.path, + at: Date() + ) + } +} diff --git a/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift b/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift index 9f3165688f59..d9fa764b0ed9 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 @@ -23,16 +23,16 @@ final class AgentChatTranscriptService { /// 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 var pendingTitleChanges: [String: PendingTitleChange] = [:] - private var deliveredTitleKeys: [String: String] = [:] - private var transcriptResolutionTasks: [String: Task] = [:] - private var transcriptResolutionKeys: [String: ClaudeTranscriptResolutionKey] = [:] - private var claimedDetectedTranscriptSessionIDs: Set = [] - private var titleAdoptionHandler: (@MainActor (GhosttyTitleChange) -> Bool)? - private let titleChangeCoalescer = NotificationBurstCoalescer(delay: 0.25) - private static let detectionScanThrottle: TimeInterval = 4 + var pendingTitleChanges: [String: PendingTitleChange] = [:] + var deliveredTitleKeys: [String: String] = [:] + var transcriptResolutionTasks: [String: Task] = [:] + var transcriptResolutionKeys: [String: ClaudeTranscriptResolutionKey] = [:] + var claimedDetectedTranscriptSessionIDs: Set = [] + var titleAdoptionHandler: (@MainActor (GhosttyTitleChange) -> Bool)? + let titleChangeCoalescer = NotificationBurstCoalescer(delay: 0.25) + static let detectionScanThrottle: TimeInterval = 4 private static let provisionalClaudeSessionIDPrefix = "detected-claude-surface-" /// Creates the service with a hook-store-backed registry. @@ -270,201 +270,14 @@ final class AgentChatTranscriptService { // MARK: - Internals - private typealias PendingTitleChange = (change: GhosttyTitleChange, titleKey: String) - private typealias ClaudeTranscriptResolutionKey = ( + typealias PendingTitleChange = (change: GhosttyTitleChange, titleKey: String) + typealias ClaudeTranscriptResolutionKey = ( workingDirectory: String, claimedSessionIDs: Set, titleKey: String?, forceScan: Bool ) - private 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() - } - } - - private 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 - } - } - } - - private func clearTitleDetectionState(surfaceID: String) { - pendingTitleChanges.removeValue(forKey: surfaceID) - deliveredTitleKeys.removeValue(forKey: surfaceID) - transcriptResolutionTasks[surfaceID]?.cancel() - transcriptResolutionTasks[surfaceID] = nil - transcriptResolutionKeys.removeValue(forKey: surfaceID) - detectionScanAt.removeValue(forKey: surfaceID) - } - - private 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(claimedDetectedTranscriptSessionIDs) - if let excludingSessionID { - claimed.remove(excludingSessionID) - } - let key: ClaudeTranscriptResolutionKey = ( - workingDirectory: workingDirectory, - claimedSessionIDs: claimed, - titleKey: Self.specificClaudeTitleKey(titleHint), - forceScan: forceScan - ) - guard transcriptResolutionKeys[surfaceID] != key else { - return - } - - detectionScanAt[surfaceID] = now - transcriptResolutionKeys[surfaceID] = key - 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 - ) - } - } - - private func applyClaudeTranscriptResolution( - _ resolved: (sessionID: String, path: String)?, - key: ClaudeTranscriptResolutionKey, - workspaceID: String, - workingDirectory: String, - surfaceID: String, - titleHint: String? - ) { - guard transcriptResolutionKeys[surfaceID] == key else { - return - } - transcriptResolutionTasks[surfaceID] = nil - transcriptResolutionKeys[surfaceID] = nil - - guard let resolved else { return } - guard !claimedDetectedTranscriptSessionIDs.contains(resolved.sessionID) else { - scheduleClaudeTranscriptResolution( - workspaceID: workspaceID, - workingDirectory: workingDirectory, - surfaceID: surfaceID, - excludingSessionID: registry.liveSession(surfaceID: surfaceID)?.sessionID, - titleHint: titleHint, - forceScan: true - ) - return - } - if let claimed = registry.record(sessionID: resolved.sessionID), - claimed.surfaceID != nil, - claimed.surfaceID != surfaceID { - scheduleClaudeTranscriptResolution( - workspaceID: workspaceID, - workingDirectory: workingDirectory, - surfaceID: surfaceID, - excludingSessionID: registry.liveSession(surfaceID: surfaceID)?.sessionID, - titleHint: titleHint, - forceScan: true - ) - return - } - - detectionScanAt.removeValue(forKey: surfaceID) - claimedDetectedTranscriptSessionIDs.insert(resolved.sessionID) - 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 - } - return - } - - registry.adoptDetectedSession( - sessionID: resolved.sessionID, - agentKind: .claude, - workspaceID: workspaceID, - surfaceID: surfaceID, - workingDirectory: workingDirectory, - transcriptPath: resolved.path, - at: Date() - ) - } - @discardableResult private func ensureTailer(for record: AgentChatSessionRecord) -> AgentChatTranscriptTailer? { if let existing = tailers[record.sessionID] { @@ -608,7 +421,7 @@ final class AgentChatTranscriptService { sessionID.hasPrefix(provisionalClaudeSessionIDPrefix) } - private static func claudeTitleDetectionKey(_ title: String?) -> String? { + static func claudeTitleDetectionKey(_ title: String?) -> String? { guard let title else { return nil } @@ -619,7 +432,7 @@ final class AgentChatTranscriptService { return specificClaudeTitleKey(title) ?? "generic:claude" } - private static func specificClaudeTitleKey(_ title: String?) -> String? { + static func specificClaudeTitleKey(_ title: String?) -> String? { guard var title = title?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty else { return nil @@ -637,7 +450,7 @@ final class AgentChatTranscriptService { return "specific:\(normalized)" } - private static func isSpecificClaudeTitle(_ title: String?) -> Bool { + static func isSpecificClaudeTitle(_ title: String?) -> Bool { specificClaudeTitleKey(title) != nil } diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index e90e02020a1b..12630f0bc307 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 */; }; @@ -1059,6 +1060,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 = ""; }; @@ -2149,6 +2151,7 @@ ACA7C4A70000000000000006 /* AgentChatTranscriptResolver.swift */, ACA7C4A70000000000000008 /* AgentChatSessionRegistry.swift */, ACA7C4A7000000000000000A /* AgentChatTranscriptTailer.swift */, + ACA7C4A70000000000000102 /* AgentChatTranscriptService+TitleDetection.swift */, ACA7C4A7000000000000000C /* AgentChatTranscriptService.swift */, ); name = AgentChat; @@ -3496,6 +3499,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 */, From 85166eb4aa00ef301717ed183e2ad3b6d34909ed Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 19 Jun 2026 16:34:54 -0700 Subject: [PATCH 13/18] fix: unwrap transcript resolution cache key --- .../AgentChatTranscriptService+TitleDetection.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Sources/Mobile/AgentChat/AgentChatTranscriptService+TitleDetection.swift b/Sources/Mobile/AgentChat/AgentChatTranscriptService+TitleDetection.swift index e746ee571cea..d45b03712503 100644 --- a/Sources/Mobile/AgentChat/AgentChatTranscriptService+TitleDetection.swift +++ b/Sources/Mobile/AgentChat/AgentChatTranscriptService+TitleDetection.swift @@ -70,7 +70,8 @@ extension AgentChatTranscriptService { titleKey: Self.specificClaudeTitleKey(titleHint), forceScan: forceScan ) - guard transcriptResolutionKeys[surfaceID] != key else { + if let currentKey = transcriptResolutionKeys[surfaceID], + currentKey == key { return } @@ -132,7 +133,8 @@ extension AgentChatTranscriptService { surfaceID: String, titleHint: String? ) { - guard transcriptResolutionKeys[surfaceID] == key else { + guard let currentKey = transcriptResolutionKeys[surfaceID], + currentKey == key else { return } transcriptResolutionTasks[surfaceID] = nil From d1bc1f53a45ffb5a873f8f4fc69adde9ac1fe7d8 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 19 Jun 2026 16:52:39 -0700 Subject: [PATCH 14/18] fix: harden title transcript adoption --- .../AgentChat/AgentChatSessionRegistry.swift | 57 ++++++++++++++--- .../AgentChatTranscriptResolver.swift | 64 ++++++++++++++++--- 2 files changed, 101 insertions(+), 20 deletions(-) diff --git a/Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift b/Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift index 65163d24babe..f8c2ddc4bec8 100644 --- a/Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift +++ b/Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift @@ -65,12 +65,15 @@ final class AgentChatSessionRegistry { /// - Parameter surfaceID: Terminal surface UUID string. /// - Returns: A non-ended record bound to the surface, or `nil`. func liveSession(surfaceID: String) -> AgentChatSessionRecord? { - sweepDeadProcesses() guard let sessionID = liveSessionIDBySurfaceID[surfaceID], let record = records[sessionID], record.surfaceID == surfaceID, record.state != .ended else { - refreshLiveSessionIndex(surfaceID: surfaceID) + liveSessionIDBySurfaceID.removeValue(forKey: surfaceID) + return nil + } + if let pid = record.pid, processIsDead(pid) { + update(sessionID: sessionID) { $0.state = .ended } return liveSessionIDBySurfaceID[surfaceID].flatMap { records[$0] } } return record @@ -115,8 +118,7 @@ final class AgentChatSessionRegistry { var record = previous mutate(&record) records[sessionID] = record - refreshLiveSessionIndex(surfaceID: previous.surfaceID) - refreshLiveSessionIndex(surfaceID: record.surfaceID) + updateLiveSessionIndex(previous: previous, current: record) onRecordChanged?(record, previous) } @@ -159,7 +161,7 @@ final class AgentChatSessionRegistry { pid: entry.pid ) records[entry.sessionID] = record - refreshLiveSessionIndex(surfaceID: record.surfaceID) + updateLiveSessionIndex(previous: nil, current: record) } } } @@ -189,7 +191,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( @@ -205,7 +207,7 @@ final class AgentChatSessionRegistry { pid: nil ) records[sessionID] = record - refreshLiveSessionIndex(surfaceID: surfaceID) + updateLiveSessionIndex(previous: nil, current: record) onRecordChanged?(record, nil) return record } @@ -271,13 +273,37 @@ final class AgentChatSessionRegistry { let previous = records[sessionID] record.state = Self.nextState(previous: record.state, event: event) records[sessionID] = record - refreshLiveSessionIndex(surfaceID: previous?.surfaceID) - refreshLiveSessionIndex(surfaceID: record.surfaceID) + updateLiveSessionIndex(previous: previous, current: record) onRecordChanged?(record, previous) return record } - private func refreshLiveSessionIndex(surfaceID: String?) { + 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 }) @@ -288,6 +314,17 @@ final class AgentChatSessionRegistry { } } + 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 6b74987c9ab4..813594bdae21 100644 --- a/Sources/Mobile/AgentChat/AgentChatTranscriptResolver.swift +++ b/Sources/Mobile/AgentChat/AgentChatTranscriptResolver.swift @@ -8,6 +8,9 @@ import Foundation /// codex: rollout filename containing the session id). struct AgentChatTranscriptResolver: Sendable { private let homeDirectory: URL + private static let claudeTranscriptTitleReadLimit = 1_048_576 + private static let claudeTranscriptTitleChunkSize = 64 * 1024 + private static let claudeTranscriptTitleMaxLineBytes = 256 * 1024 /// Creates a resolver. /// @@ -100,8 +103,13 @@ struct AgentChatTranscriptResolver: Sendable { } let newest: URL? if let normalizedTitleHint { - newest = transcriptCandidates - .filter { Self.normalizedClaudeTitle($0.title) == normalizedTitleHint || $0.title == nil } + let exactTitleCandidates = transcriptCandidates + .filter { Self.normalizedClaudeTitle($0.title) == normalizedTitleHint } + newest = ( + exactTitleCandidates.isEmpty + ? transcriptCandidates.filter { $0.title == nil } + : exactTitleCandidates + ) .max { $0.date < $1.date }? .url } else { @@ -203,17 +211,53 @@ struct AgentChatTranscriptResolver: Sendable { } 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 } } From 2f71fe696e209852da8e6b36b0091af55fccedc5 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 19 Jun 2026 16:57:37 -0700 Subject: [PATCH 15/18] fix: bound transcript collision retries --- ...ChatTranscriptService+TitleDetection.swift | 46 +++++++++++++++---- .../AgentChatTranscriptService.swift | 2 + 2 files changed, 40 insertions(+), 8 deletions(-) diff --git a/Sources/Mobile/AgentChat/AgentChatTranscriptService+TitleDetection.swift b/Sources/Mobile/AgentChat/AgentChatTranscriptService+TitleDetection.swift index d45b03712503..ce36e3db64ef 100644 --- a/Sources/Mobile/AgentChat/AgentChatTranscriptService+TitleDetection.swift +++ b/Sources/Mobile/AgentChat/AgentChatTranscriptService+TitleDetection.swift @@ -42,6 +42,7 @@ extension AgentChatTranscriptService { transcriptResolutionTasks[surfaceID]?.cancel() transcriptResolutionTasks[surfaceID] = nil transcriptResolutionKeys.removeValue(forKey: surfaceID) + transcriptResolutionForcedRetryCounts.removeValue(forKey: surfaceID) detectionScanAt.removeValue(forKey: surfaceID) } @@ -77,6 +78,9 @@ extension AgentChatTranscriptService { detectionScanAt[surfaceID] = now transcriptResolutionKeys[surfaceID] = key + if !forceScan { + transcriptResolutionForcedRetryCounts.removeValue(forKey: surfaceID) + } transcriptResolutionTasks[surfaceID]?.cancel() let resolver = self.resolver #if compiler(>=6.2) @@ -142,32 +146,29 @@ extension AgentChatTranscriptService { guard let resolved else { return } guard !claimedDetectedTranscriptSessionIDs.contains(resolved.sessionID) else { - scheduleClaudeTranscriptResolution( + scheduleForcedClaudeTranscriptRetry( workspaceID: workspaceID, workingDirectory: workingDirectory, surfaceID: surfaceID, excludingSessionID: registry.liveSession(surfaceID: surfaceID)?.sessionID, - titleHint: titleHint, - forceScan: true + titleHint: titleHint ) return } if let claimed = registry.record(sessionID: resolved.sessionID), claimed.surfaceID != nil, claimed.surfaceID != surfaceID { - scheduleClaudeTranscriptResolution( + scheduleForcedClaudeTranscriptRetry( workspaceID: workspaceID, workingDirectory: workingDirectory, surfaceID: surfaceID, excludingSessionID: registry.liveSession(surfaceID: surfaceID)?.sessionID, - titleHint: titleHint, - forceScan: true + titleHint: titleHint ) return } detectionScanAt.removeValue(forKey: surfaceID) - claimedDetectedTranscriptSessionIDs.insert(resolved.sessionID) if let bound = registry.liveSession(surfaceID: surfaceID) { guard bound.transcriptPath == nil else { return } registry.update(sessionID: bound.sessionID) { record in @@ -176,10 +177,12 @@ extension AgentChatTranscriptService { record.workingDirectory = workingDirectory record.transcriptPath = resolved.path } + claimedDetectedTranscriptSessionIDs.insert(resolved.sessionID) + transcriptResolutionForcedRetryCounts.removeValue(forKey: surfaceID) return } - registry.adoptDetectedSession( + let adopted = registry.adoptDetectedSession( sessionID: resolved.sessionID, agentKind: .claude, workspaceID: workspaceID, @@ -188,5 +191,32 @@ extension AgentChatTranscriptService { transcriptPath: resolved.path, at: Date() ) + if adopted.surfaceID == surfaceID, + adopted.transcriptPath == resolved.path { + claimedDetectedTranscriptSessionIDs.insert(resolved.sessionID) + 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 + ) } } diff --git a/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift b/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift index d9fa764b0ed9..872f41aec04f 100644 --- a/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift +++ b/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift @@ -29,10 +29,12 @@ final class AgentChatTranscriptService { var deliveredTitleKeys: [String: String] = [:] var transcriptResolutionTasks: [String: Task] = [:] var transcriptResolutionKeys: [String: ClaudeTranscriptResolutionKey] = [:] + var transcriptResolutionForcedRetryCounts: [String: Int] = [:] var claimedDetectedTranscriptSessionIDs: 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. From e731981fa41036dcac773004e160954fd58d3aed Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 19 Jun 2026 17:08:39 -0700 Subject: [PATCH 16/18] fix: avoid stale detected transcript claims --- .../AgentChat/AgentChatSessionRegistry.swift | 4 ++-- .../AgentChatTranscriptResolver.swift | 16 +++++++------ ...ChatTranscriptService+TitleDetection.swift | 23 +++++++++++++++---- .../AgentChatTranscriptService.swift | 5 ++-- 4 files changed, 33 insertions(+), 15 deletions(-) diff --git a/Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift b/Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift index f8c2ddc4bec8..a47d50cd7625 100644 --- a/Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift +++ b/Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift @@ -79,12 +79,12 @@ final class AgentChatSessionRegistry { return record } - /// Every session id the registry already tracks. Title-detected adoption + /// Every live 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 /// instead of colliding on the newest file. func claimedSessionIDs() -> Set { - Set(records.keys) + Set(records.values.lazy.filter { $0.state != .ended }.map(\.sessionID)) } /// Re-reads the hook store for one session and adopts its bindings, diff --git a/Sources/Mobile/AgentChat/AgentChatTranscriptResolver.swift b/Sources/Mobile/AgentChat/AgentChatTranscriptResolver.swift index 813594bdae21..19742824c932 100644 --- a/Sources/Mobile/AgentChat/AgentChatTranscriptResolver.swift +++ b/Sources/Mobile/AgentChat/AgentChatTranscriptResolver.swift @@ -105,13 +105,15 @@ struct AgentChatTranscriptResolver: Sendable { if let normalizedTitleHint { let exactTitleCandidates = transcriptCandidates .filter { Self.normalizedClaudeTitle($0.title) == normalizedTitleHint } - newest = ( - exactTitleCandidates.isEmpty - ? transcriptCandidates.filter { $0.title == nil } - : exactTitleCandidates - ) - .max { $0.date < $1.date }? - .url + 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 diff --git a/Sources/Mobile/AgentChat/AgentChatTranscriptService+TitleDetection.swift b/Sources/Mobile/AgentChat/AgentChatTranscriptService+TitleDetection.swift index ce36e3db64ef..4165ce8eec9f 100644 --- a/Sources/Mobile/AgentChat/AgentChatTranscriptService+TitleDetection.swift +++ b/Sources/Mobile/AgentChat/AgentChatTranscriptService+TitleDetection.swift @@ -43,6 +43,7 @@ extension AgentChatTranscriptService { transcriptResolutionTasks[surfaceID] = nil transcriptResolutionKeys.removeValue(forKey: surfaceID) transcriptResolutionForcedRetryCounts.removeValue(forKey: surfaceID) + claimedDetectedTranscriptSessionIDsBySurfaceID.removeValue(forKey: surfaceID) detectionScanAt.removeValue(forKey: surfaceID) } @@ -61,7 +62,8 @@ extension AgentChatTranscriptService { return } - var claimed = registry.claimedSessionIDs().union(claimedDetectedTranscriptSessionIDs) + var claimed = registry.claimedSessionIDs() + .union(activeClaimedDetectedTranscriptSessionIDs(excludingSurfaceID: surfaceID)) if let excludingSessionID { claimed.remove(excludingSessionID) } @@ -145,7 +147,7 @@ extension AgentChatTranscriptService { transcriptResolutionKeys[surfaceID] = nil guard let resolved else { return } - guard !claimedDetectedTranscriptSessionIDs.contains(resolved.sessionID) else { + guard !activeClaimedDetectedTranscriptSessionIDs(excludingSurfaceID: surfaceID).contains(resolved.sessionID) else { scheduleForcedClaudeTranscriptRetry( workspaceID: workspaceID, workingDirectory: workingDirectory, @@ -177,7 +179,7 @@ extension AgentChatTranscriptService { record.workingDirectory = workingDirectory record.transcriptPath = resolved.path } - claimedDetectedTranscriptSessionIDs.insert(resolved.sessionID) + claimDetectedTranscriptSessionID(resolved.sessionID, surfaceID: surfaceID) transcriptResolutionForcedRetryCounts.removeValue(forKey: surfaceID) return } @@ -193,7 +195,7 @@ extension AgentChatTranscriptService { ) if adopted.surfaceID == surfaceID, adopted.transcriptPath == resolved.path { - claimedDetectedTranscriptSessionIDs.insert(resolved.sessionID) + claimDetectedTranscriptSessionID(resolved.sessionID, surfaceID: surfaceID) transcriptResolutionForcedRetryCounts.removeValue(forKey: surfaceID) } } @@ -219,4 +221,17 @@ extension AgentChatTranscriptService { 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 872f41aec04f..c6bb990e4a54 100644 --- a/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift +++ b/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift @@ -30,7 +30,7 @@ final class AgentChatTranscriptService { var transcriptResolutionTasks: [String: Task] = [:] var transcriptResolutionKeys: [String: ClaudeTranscriptResolutionKey] = [:] var transcriptResolutionForcedRetryCounts: [String: Int] = [:] - var claimedDetectedTranscriptSessionIDs: Set = [] + var claimedDetectedTranscriptSessionIDsBySurfaceID: [String: Set] = [:] var titleAdoptionHandler: (@MainActor (GhosttyTitleChange) -> Bool)? let titleChangeCoalescer = NotificationBurstCoalescer(delay: 0.25) static let detectionScanThrottle: TimeInterval = 4 @@ -404,7 +404,8 @@ final class AgentChatTranscriptService { return nil } detectionScanAt[surfaceID] = now - var claimed = registry.claimedSessionIDs().union(claimedDetectedTranscriptSessionIDs) + var claimed = registry.claimedSessionIDs() + .union(activeClaimedDetectedTranscriptSessionIDs(excludingSurfaceID: surfaceID)) if let excludingSessionID { claimed.remove(excludingSessionID) } From d0cd1a4ecfa6ff503fd03cdc40e469fd63bf8ca4 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 19 Jun 2026 17:21:05 -0700 Subject: [PATCH 17/18] fix: revalidate live session cache --- .../AgentChat/AgentChatSessionRegistry.swift | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift b/Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift index a47d50cd7625..55f18f8d6f4d 100644 --- a/Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift +++ b/Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift @@ -65,26 +65,28 @@ final class AgentChatSessionRegistry { /// - Parameter surfaceID: Terminal surface UUID string. /// - Returns: A non-ended record bound to the surface, or `nil`. func liveSession(surfaceID: String) -> AgentChatSessionRecord? { - guard let sessionID = liveSessionIDBySurfaceID[surfaceID], - 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 } - return liveSessionIDBySurfaceID[surfaceID].flatMap { records[$0] } + 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 record + return nil } - /// Every live session id the registry already tracks. Title-detected adoption + /// 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 /// instead of colliding on the newest file. func claimedSessionIDs() -> Set { - Set(records.values.lazy.filter { $0.state != .ended }.map(\.sessionID)) + Set(records.keys) } /// Re-reads the hook store for one session and adopts its bindings, From e9062424ec0bf3e76189a806d5b1fe864d3d8872 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 19 Jun 2026 17:33:25 -0700 Subject: [PATCH 18/18] fix: retain detected transcript claims while live --- .../AgentChatTranscriptService+TitleDetection.swift | 9 +++++++-- .../Mobile/AgentChat/AgentChatTranscriptService.swift | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/Sources/Mobile/AgentChat/AgentChatTranscriptService+TitleDetection.swift b/Sources/Mobile/AgentChat/AgentChatTranscriptService+TitleDetection.swift index 4165ce8eec9f..1b49d50a6a49 100644 --- a/Sources/Mobile/AgentChat/AgentChatTranscriptService+TitleDetection.swift +++ b/Sources/Mobile/AgentChat/AgentChatTranscriptService+TitleDetection.swift @@ -36,14 +36,19 @@ extension AgentChatTranscriptService { } } - func clearTitleDetectionState(surfaceID: String) { + 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) - claimedDetectedTranscriptSessionIDsBySurfaceID.removeValue(forKey: surfaceID) + if releaseTranscriptClaims { + claimedDetectedTranscriptSessionIDsBySurfaceID.removeValue(forKey: surfaceID) + } detectionScanAt.removeValue(forKey: surfaceID) } diff --git a/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift b/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift index c6bb990e4a54..45a242c7ebea 100644 --- a/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift +++ b/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift @@ -351,7 +351,7 @@ final class AgentChatTranscriptService { let transcriptBecameAvailable = previous?.transcriptPath == nil && record.transcriptPath != nil if stateChanged, record.state == .ended { if let surfaceID = record.surfaceID { - clearTitleDetectionState(surfaceID: surfaceID) + clearTitleDetectionState(surfaceID: surfaceID, releaseTranscriptClaims: true) } if let tailer = tailers.removeValue(forKey: record.sessionID) { // The transcript can no longer grow; release the file watcher