From 25ba2e6e00fcba1c8457fdad0173452a41291742 Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Mon, 15 Jun 2026 20:21:40 -0700 Subject: [PATCH 1/7] Refactor chat parser and transcript service ownership --- .../Parsing/OSC133CommandParser.swift | 16 ++++----- .../OSC133CommandParserTests.swift | 34 +++++++++--------- Sources/AppDelegate.swift | 8 ++++- .../AgentChatTranscriptService.swift | 35 ++++++++++--------- Sources/TerminalController+MobileChat.swift | 19 ++++++---- Sources/TerminalController.swift | 9 ++++- 6 files changed, 72 insertions(+), 49 deletions(-) diff --git a/Packages/CmuxAgentChat/Sources/CmuxAgentChat/Parsing/OSC133CommandParser.swift b/Packages/CmuxAgentChat/Sources/CmuxAgentChat/Parsing/OSC133CommandParser.swift index 06c170f567ab..5d4989a3fa2b 100644 --- a/Packages/CmuxAgentChat/Sources/CmuxAgentChat/Parsing/OSC133CommandParser.swift +++ b/Packages/CmuxAgentChat/Sources/CmuxAgentChat/Parsing/OSC133CommandParser.swift @@ -15,7 +15,7 @@ import Foundation /// Pure and incremental: ``consume(_:)`` may be fed arbitrary chunk /// boundaries, including ones that split an escape sequence (the tail is /// carried over). Read ``blocks`` after feeding. -public final class OSC133CommandParser { +public struct OSC133CommandParser { /// The command blocks parsed so far, oldest first. public private(set) var blocks: [TerminalCommandBlock] = [] @@ -48,7 +48,7 @@ public final class OSC133CommandParser { /// Feeds a chunk of raw terminal output through the state machine. /// /// - Parameter text: A slice of the PTY stream, any length. - public func consume(_ text: String) { + public mutating func consume(_ text: String) { let stream = pending + text pending = "" var index = stream.startIndex @@ -78,7 +78,7 @@ public final class OSC133CommandParser { /// Publishes the running block's output: the already-folded completed /// lines plus the open line folded on its own (O(open line), not O(total)). - private func flushOpenOutput() { + private mutating func flushOpenOutput() { guard phase == .output, let openIndex else { return } blocks[openIndex].output = foldedOutput + Self.foldLine(openLine) } @@ -206,7 +206,7 @@ public final class OSC133CommandParser { // MARK: - State transitions - private func apply(_ action: EscapeAction) { + private mutating func apply(_ action: EscapeAction) { switch action { case .promptStart: finalizeOpenOutput() @@ -231,7 +231,7 @@ public final class OSC133CommandParser { } } - private func appendText(_ char: Character) { + private mutating func appendText(_ char: Character) { switch phase { case .command: commandBuffer.append(char) @@ -251,7 +251,7 @@ public final class OSC133CommandParser { } } - private func openBlock() { + private mutating func openBlock() { let block = TerminalCommandBlock( id: nextID, command: commandBuffer.trimmingCharacters(in: .whitespacesAndNewlines), @@ -264,7 +264,7 @@ public final class OSC133CommandParser { openIndex = blocks.count - 1 } - private func closeBlock(exitCode: Int?) { + private mutating func closeBlock(exitCode: Int?) { guard let openIndex else { return } blocks[openIndex].output = foldedOutput + Self.foldLine(openLine) blocks[openIndex].exitCode = exitCode @@ -274,7 +274,7 @@ public final class OSC133CommandParser { openLine = "" } - private func finalizeOpenOutput() { + private mutating func finalizeOpenOutput() { // A new prompt without a D mark (e.g. Ctrl-C, or a shell that skipped // D): close the open block with an unknown exit code. if openIndex != nil { closeBlock(exitCode: nil) } diff --git a/Packages/CmuxAgentChat/Tests/CmuxAgentChatTests/OSC133CommandParserTests.swift b/Packages/CmuxAgentChat/Tests/CmuxAgentChatTests/OSC133CommandParserTests.swift index 826f45764f13..122716ebd3ff 100644 --- a/Packages/CmuxAgentChat/Tests/CmuxAgentChatTests/OSC133CommandParserTests.swift +++ b/Packages/CmuxAgentChat/Tests/CmuxAgentChatTests/OSC133CommandParserTests.swift @@ -12,7 +12,7 @@ struct OSC133CommandParserTests { @Test("a complete command/output/exit cycle yields one finished block") func happyPath() { - let parser = OSC133CommandParser() + var parser = OSC133CommandParser() parser.consume(mark("A") + "user@host$ " + mark("B") + "echo hi" + mark("C") + "hi\n" + mark("D;0")) #expect(parser.blocks.count == 1) let block = parser.blocks[0] @@ -25,7 +25,7 @@ struct OSC133CommandParserTests { @Test("a nonzero exit code marks the block failed") func failure() { - let parser = OSC133CommandParser() + var parser = OSC133CommandParser() parser.consume(mark("A") + mark("B") + "false" + mark("C") + mark("D;1")) #expect(parser.blocks[0].exitCode == 1) #expect(parser.blocks[0].failed) @@ -33,7 +33,7 @@ struct OSC133CommandParserTests { @Test("a block with no D mark stays running until the next prompt closes it") func runningUntilNextPrompt() { - let parser = OSC133CommandParser() + var parser = OSC133CommandParser() parser.consume(mark("A") + mark("B") + "sleep 5" + mark("C") + "working") #expect(parser.blocks.count == 1) #expect(parser.blocks[0].isRunning) @@ -46,7 +46,7 @@ struct OSC133CommandParserTests { @Test("two commands produce two blocks with distinct ids") func twoCommands() { - let parser = OSC133CommandParser() + var parser = OSC133CommandParser() parser.consume(mark("A") + mark("B") + "ls" + mark("C") + "a b\n" + mark("D;0")) parser.consume(mark("A") + mark("B") + "pwd" + mark("C") + "/tmp\n" + mark("D;0")) #expect(parser.blocks.count == 2) @@ -57,7 +57,7 @@ struct OSC133CommandParserTests { @Test("an escape sequence split across chunks is parsed once completed") func splitEscape() { - let parser = OSC133CommandParser() + var parser = OSC133CommandParser() let full = mark("A") + mark("B") + "id" + mark("C") + "uid=0\n" + mark("D;0") let mid = full.index(full.startIndex, offsetBy: 3) parser.consume(String(full[.. Void) { + guard titleObserver == nil else { return } registry.seedFromHookStores() - observeAgentTitleChanges() + observeAgentTitleChanges(adoptDetectedAgentSessions: adoptDetectedAgentSessions) } /// Watches terminal title changes so a coding agent launched without a @@ -63,8 +66,8 @@ 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() { - NotificationCenter.default.addObserver( + private func observeAgentTitleChanges(adoptDetectedAgentSessions: @escaping @MainActor (String) -> Void) { + titleObserver = NotificationCenter.default.addObserver( forName: .ghosttyDidSetTitle, object: nil, queue: .main @@ -74,8 +77,8 @@ final class AgentChatTranscriptService { title.lowercased().contains("claude") else { return } - MainActor.assumeIsolated { - TerminalController.shared.adoptDetectedAgentSessions(workspaceID: tabId.uuidString) + Task { @MainActor in + adoptDetectedAgentSessions(tabId.uuidString) } } } diff --git a/Sources/TerminalController+MobileChat.swift b/Sources/TerminalController+MobileChat.swift index 0e0c9d9477f9..30486a73e450 100644 --- a/Sources/TerminalController+MobileChat.swift +++ b/Sources/TerminalController+MobileChat.swift @@ -43,14 +43,19 @@ extension TerminalController { /// full chat-session registry state, for diagnosing inconsistent /// phone-side states. func v2ChatSessionsDump() -> V2CallResult { - .ok(["sessions": AgentChatTranscriptService.shared.debugSessionDump()]) + guard let service = agentChatTranscriptService else { + return .err(code: "unavailable", message: "Agent chat transcript service is not configured", data: nil) + } + return .ok(["sessions": service.debugSessionDump()]) } /// `mobile.chat.sessions`: list chat-capable coding-agent sessions, /// optionally scoped to one workspace. func v2MobileChatSessions(params: [String: Any]) -> V2CallResult { let workspaceID = v2String(params, "workspace_id") - let service = AgentChatTranscriptService.shared + guard let service = agentChatTranscriptService else { + return .err(code: "unavailable", message: "Agent chat transcript service is not configured", data: nil) + } // Register coding agents cmux detects by terminal title but that never // ran a hook (e.g. launched through a shell wrapper that bypasses // cmux's hook injection), so they get a chat session and toggle like @@ -89,7 +94,7 @@ extension TerminalController { /// workspace touches no filesystem. func adoptDetectedAgentSessions(workspace: Workspace) { let workspaceID = workspace.id.uuidString - let service = AgentChatTranscriptService.shared + 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 @@ -121,7 +126,9 @@ extension TerminalController { } let limit = min(max(v2Int(params, "limit") ?? 100, 1), 200) let beforeSeq = v2Int(params, "before_seq") - let service = AgentChatTranscriptService.shared + guard let service = agentChatTranscriptService else { + return .err(code: "unavailable", message: "Agent chat transcript service is not configured", data: nil) + } var page = await service.history(sessionID: sessionID, beforeSeq: beforeSeq, limit: limit) if page == nil, let staleRecord = service.sessionRecord(sessionID: sessionID) { // The record exists but its transcript didn't resolve — the @@ -132,7 +139,7 @@ extension TerminalController { #if DEBUG cmuxDebugLog("mobile.chat.history transcript unresolved session=\(sessionID.prefix(8)); refreshing bindings") #endif - let refreshed = AgentChatTranscriptService.shared.refreshSessionBindings(sessionID: sessionID) + let refreshed = service.refreshSessionBindings(sessionID: sessionID) if refreshed?.transcriptPath != staleRecord.transcriptPath || refreshed?.workingDirectory != staleRecord.workingDirectory { page = await service.history(sessionID: sessionID, beforeSeq: beforeSeq, limit: limit) @@ -294,7 +301,7 @@ extension TerminalController { /// retried. If it still doesn't resolve we fail with an actionable error /// rather than redirect the prompt to some other terminal. private func mobileChatTerminalParams(sessionID: String) -> [String: Any]? { - let service = AgentChatTranscriptService.shared + guard let service = agentChatTranscriptService else { return nil } guard let record = service.sessionRecord(sessionID: sessionID), let workspaceID = record.workspaceID else { return nil diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 6ea874349bb2..fce5ea215b01 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -108,6 +108,9 @@ class TerminalController { /// listener starts. Socket auth commands read these on the main actor. @MainActor private(set) var authCoordinator: AuthCoordinator? @MainActor private(set) var browserSignInFlow: HostBrowserSignInFlow? + /// Agent-chat transcript routing service, injected by `AppDelegate` at + /// startup before mobile/chat socket commands are reachable. + @MainActor private(set) var agentChatTranscriptService: AgentChatTranscriptService? // Sendable value type; injected at construction so socket auth never reaches a global. private nonisolated let passwordStore: SocketControlPasswordStore /// Process-wide proxy-tunnel broker (one shared tunnel per remote transport across all @@ -330,6 +333,10 @@ class TerminalController { } } + func attachAgentChatTranscriptService(_ service: AgentChatTranscriptService) { + agentChatTranscriptService = service + } + nonisolated func currentSocketPathForRemoteRestore() -> String? { socketServer.currentSocketPathForRemoteRestore() } @@ -5312,7 +5319,7 @@ class TerminalController { CmuxEventBus.shared.publishWorkstreamEvent(event, phase: "received") v2ApplyIMessageModeSideEffects(for: event) - Task { @MainActor in AgentChatTranscriptService.shared.noteHookEvent(event) } + Task { @MainActor in self.agentChatTranscriptService?.noteHookEvent(event) } let result = FeedCoordinator.shared.ingestBlocking( event: event, From 00d6338c5b19b51bdee3a8cb68efc4f38f0b9c9b Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Mon, 15 Jun 2026 20:32:10 -0700 Subject: [PATCH 2/7] Fix chat refactor CI guards --- .../ComposerDictationTextMerge.swift | 4 +++- Sources/AppDelegate.swift | 14 +++----------- Sources/TerminalController.swift | 9 +-------- 3 files changed, 7 insertions(+), 20 deletions(-) diff --git a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/ComposerDictationTextMerge.swift b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/ComposerDictationTextMerge.swift index 965af4049a8b..18accd94b293 100644 --- a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/ComposerDictationTextMerge.swift +++ b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/ComposerDictationTextMerge.swift @@ -67,7 +67,9 @@ enum ComposerDictationState: Equatable { /// composer always reads `base` + the latest transcript and never accumulates /// stale partials. The base is preserved verbatim so text the user typed before /// starting is never clobbered. -enum ComposerDictationTextMerge { +struct ComposerDictationTextMerge { + private init() {} + /// Combine the captured base text with the current transcript. /// /// - A trailing run of whitespace on the base is preserved (the user may diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index ccca56a78b56..57b21d77dbf4 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -730,10 +730,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent /// Strongly-held observers for every active TabManager. Each observer owns /// Combine subscriptions that publish workspace.updated to mobile clients. private var mobileWorkspaceListObservers: [ObjectIdentifier: MobileWorkspaceListObserver] = [:] - /// Mac-side agent-chat transcript service, owned by the app composition root - /// and injected into socket/mobile routing. private let agentChatTranscriptService = AgentChatTranscriptService() - /// The app's settings dependency container, handed over by `cmuxApp` via /// `configure(...)` before any main window is created. AppKit builds the /// main window's `NSHostingView` itself, so it injects this into the @@ -1929,17 +1926,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent MobileHostService.shared.configure(auth: auth.coordinator) DeviceRegistryClient.shared.configure(auth: auth.coordinator) PresenceHeartbeatClient.shared.configure(auth: auth.coordinator) - TerminalController.shared.attachAuth( - coordinator: auth.coordinator, - browserSignIn: auth.browserSignIn - ) - TerminalController.shared.attachAgentChatTranscriptService(agentChatTranscriptService) + TerminalController.shared.attachAuth(coordinator: auth.coordinator, browserSignIn: auth.browserSignIn) + TerminalController.shared.agentChatTranscriptService = agentChatTranscriptService auth.start() ensureMobileWorkspaceListObserver(for: tabManager) MobileTerminalRenderObserver.shared.start() - agentChatTranscriptService.start { workspaceID in - TerminalController.shared.adoptDetectedAgentSessions(workspaceID: workspaceID) - } + agentChatTranscriptService.start { TerminalController.shared.adoptDetectedAgentSessions(workspaceID: $0) } installMobileHostSettingsObserver() scheduleGhosttyCrashBreadcrumbIfNeeded(notificationStore: notificationStore) disableSuddenTerminationIfNeeded() diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index fce5ea215b01..8f4bd117744d 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -108,9 +108,7 @@ class TerminalController { /// listener starts. Socket auth commands read these on the main actor. @MainActor private(set) var authCoordinator: AuthCoordinator? @MainActor private(set) var browserSignInFlow: HostBrowserSignInFlow? - /// Agent-chat transcript routing service, injected by `AppDelegate` at - /// startup before mobile/chat socket commands are reachable. - @MainActor private(set) var agentChatTranscriptService: AgentChatTranscriptService? + @MainActor var agentChatTranscriptService: AgentChatTranscriptService? // Sendable value type; injected at construction so socket auth never reaches a global. private nonisolated let passwordStore: SocketControlPasswordStore /// Process-wide proxy-tunnel broker (one shared tunnel per remote transport across all @@ -332,11 +330,6 @@ class TerminalController { } } } - - func attachAgentChatTranscriptService(_ service: AgentChatTranscriptService) { - agentChatTranscriptService = service - } - nonisolated func currentSocketPathForRemoteRestore() -> String? { socketServer.currentSocketPathForRemoteRestore() } From ec753baf36eb35134e96bbb94836e3bc19f184f8 Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Mon, 15 Jun 2026 21:06:06 -0700 Subject: [PATCH 3/7] Type ghostty title change observation --- Sources/ContentView.swift | 3 +- Sources/GhosttyTerminalView.swift | 7 ++--- Sources/GhosttyTitleChange.swift | 31 +++++++++++++++++++ Sources/GhosttyTitleChangeSubscription.swift | 28 +++++++++++++++++ .../AgentChatTranscriptService.swift | 24 +++----------- Sources/TabManager.swift | 6 ++-- cmux.xcodeproj/project.pbxproj | 8 +++++ 7 files changed, 77 insertions(+), 30 deletions(-) create mode 100644 Sources/GhosttyTitleChange.swift create mode 100644 Sources/GhosttyTitleChangeSubscription.swift diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 918b19e1b507..85d476ff9ad9 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -2647,8 +2647,7 @@ struct ContentView: View { }) view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .ghosttyDidSetTitle)) { notification in - guard let tabId = notification.userInfo?[GhosttyNotificationKey.tabId] as? UUID, - tabId == tabManager.selectedTabId else { return } + guard GhosttyTitleChange(notification: notification)?.tabId == tabManager.selectedTabId else { return } scheduleTitlebarTextRefresh() }) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index a40dd735ebc9..9e1132168a30 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -3279,15 +3279,12 @@ class GhosttyApp { .flatMap { String(cString: $0) } ?? "" if let tabId = surfaceView.tabId, let surfaceId = surfaceView.terminalSurface?.id { + let change = GhosttyTitleChange(tabId: tabId, surfaceId: surfaceId, title: title) DispatchQueue.main.async { NotificationCenter.default.post( name: .ghosttyDidSetTitle, object: surfaceView, - userInfo: [ - GhosttyNotificationKey.tabId: tabId, - GhosttyNotificationKey.surfaceId: surfaceId, - GhosttyNotificationKey.title: title, - ] + userInfo: change.userInfo ) } } diff --git a/Sources/GhosttyTitleChange.swift b/Sources/GhosttyTitleChange.swift new file mode 100644 index 000000000000..b42f63ffcf41 --- /dev/null +++ b/Sources/GhosttyTitleChange.swift @@ -0,0 +1,31 @@ +import Foundation + +/// Typed payload for `.ghosttyDidSetTitle` notifications. +struct GhosttyTitleChange: Equatable, Sendable { + let tabId: UUID + let surfaceId: UUID + let title: String + + init(tabId: UUID, surfaceId: UUID, title: String) { + self.tabId = tabId + self.surfaceId = surfaceId + self.title = title + } + + init?(notification: Notification) { + guard let tabId = notification.userInfo?[GhosttyNotificationKey.tabId] as? UUID, + let surfaceId = notification.userInfo?[GhosttyNotificationKey.surfaceId] as? UUID, + let title = notification.userInfo?[GhosttyNotificationKey.title] as? String else { + return nil + } + self.init(tabId: tabId, surfaceId: surfaceId, title: title) + } + + var userInfo: [String: Any] { + [ + GhosttyNotificationKey.tabId: tabId, + GhosttyNotificationKey.surfaceId: surfaceId, + GhosttyNotificationKey.title: title, + ] + } +} diff --git a/Sources/GhosttyTitleChangeSubscription.swift b/Sources/GhosttyTitleChangeSubscription.swift new file mode 100644 index 000000000000..fac1b9c530ae --- /dev/null +++ b/Sources/GhosttyTitleChangeSubscription.swift @@ -0,0 +1,28 @@ +import Foundation + +/// Owns a `.ghosttyDidSetTitle` observer and delivers typed title changes. +final class GhosttyTitleChangeSubscription { + private let center: NotificationCenter + private let observer: NSObjectProtocol + + init( + center: NotificationCenter = .default, + handler: @escaping @MainActor (GhosttyTitleChange) -> Void + ) { + self.center = center + observer = center.addObserver( + forName: Notification.Name.ghosttyDidSetTitle, + object: nil, + queue: .main + ) { notification in + guard let change = GhosttyTitleChange(notification: notification) else { return } + Task { @MainActor in + handler(change) + } + } + } + + deinit { + center.removeObserver(observer) + } +} diff --git a/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift b/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift index 7ffcf8a3c3c5..be630294b887 100644 --- a/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift +++ b/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift @@ -24,7 +24,7 @@ final class AgentChatTranscriptService { /// title-detected claude has not yet written its transcript; a successful /// adoption removes the entry. private var detectionScanAt: [String: Date] = [:] - private var titleObserver: NSObjectProtocol? + private var ghosttyTitleSubscription: GhosttyTitleChangeSubscription? private static let detectionScanThrottle: TimeInterval = 4 private static let provisionalClaudeSessionIDPrefix = "detected-claude-surface-" @@ -44,19 +44,13 @@ final class AgentChatTranscriptService { } } - deinit { - if let titleObserver { - NotificationCenter.default.removeObserver(titleObserver) - } - } - /// 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) { - guard titleObserver == nil else { return } + guard ghosttyTitleSubscription == nil else { return } registry.seedFromHookStores() observeAgentTitleChanges(adoptDetectedAgentSessions: adoptDetectedAgentSessions) } @@ -67,19 +61,11 @@ final class AgentChatTranscriptService { /// "✳ 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) { - titleObserver = NotificationCenter.default.addObserver( - forName: .ghosttyDidSetTitle, - object: nil, - queue: .main - ) { notification in - guard let tabId = notification.userInfo?[GhosttyNotificationKey.tabId] as? UUID, - let title = notification.userInfo?[GhosttyNotificationKey.title] as? String, - title.lowercased().contains("claude") else { + ghosttyTitleSubscription = GhosttyTitleChangeSubscription { change in + guard change.title.lowercased().contains("claude") else { return } - Task { @MainActor in - adoptDetectedAgentSessions(tabId.uuidString) - } + adoptDetectedAgentSessions(change.tabId.uuidString) } } diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 93a0c3918329..bd50044d32f6 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -560,10 +560,8 @@ class TabManager: ObservableObject { ) { [weak self] notification in MainActor.assumeIsolated { [weak self] in guard let self else { return } - guard let tabId = notification.userInfo?[GhosttyNotificationKey.tabId] as? UUID else { return } - guard let surfaceId = notification.userInfo?[GhosttyNotificationKey.surfaceId] as? UUID else { return } - guard let title = notification.userInfo?[GhosttyNotificationKey.title] as? String else { return } - enqueuePanelTitleUpdate(tabId: tabId, panelId: surfaceId, title: title) + guard let change = GhosttyTitleChange(notification: notification) else { return } + enqueuePanelTitleUpdate(tabId: change.tabId, panelId: change.surfaceId, title: change.title) } }) observers.append(NotificationCenter.default.addObserver( diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index b002263baff8..c947b34624de 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -456,6 +456,8 @@ D0B1000AA1B2C3D4E5F60001 /* GhosttyTerminalViewSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B1000BA1B2C3D4E5F60001 /* GhosttyTerminalViewSupport.swift */; }; D7AB34400000000000000003 /* GhosttyTerminalViewVisibilityPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB34400000000000000004 /* GhosttyTerminalViewVisibilityPolicyTests.swift */; }; D3284101A1B2C3D4E5F60718 /* GhosttyTextInputSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3284102A1B2C3D4E5F60718 /* GhosttyTextInputSupport.swift */; }; + A11EAE000000000000000000 /* GhosttyTitleChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11EAE000000000000000001 /* GhosttyTitleChange.swift */; }; + A11EAF000000000000000000 /* GhosttyTitleChangeSubscription.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11EAF000000000000000001 /* GhosttyTitleChangeSubscription.swift */; }; 3865A0023865A0023865A002 /* GlobalSearchCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3865B0023865B0023865B002 /* GlobalSearchCoordinator.swift */; }; 3865A0073865A0073865A007 /* GlobalSearchDocuments.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3865B0073865B0073865B007 /* GlobalSearchDocuments.swift */; }; 3865A0083865A0083865A008 /* GlobalSearchPanelCaptureManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3865B0083865B0083865B008 /* GlobalSearchPanelCaptureManager.swift */; }; @@ -1396,6 +1398,8 @@ D0B1000BA1B2C3D4E5F60001 /* GhosttyTerminalViewSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalViewSupport.swift; sourceTree = ""; }; D7AB34400000000000000004 /* GhosttyTerminalViewVisibilityPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalViewVisibilityPolicyTests.swift; sourceTree = ""; }; D3284102A1B2C3D4E5F60718 /* GhosttyTextInputSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTextInputSupport.swift; sourceTree = ""; }; + A11EAE000000000000000001 /* GhosttyTitleChange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTitleChange.swift; sourceTree = ""; }; + A11EAF000000000000000001 /* GhosttyTitleChangeSubscription.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTitleChangeSubscription.swift; sourceTree = ""; }; 3865B0023865B0023865B002 /* GlobalSearchCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Search/GlobalSearchCoordinator.swift; sourceTree = ""; }; 3865B0073865B0073865B007 /* GlobalSearchDocuments.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Search/GlobalSearchDocuments.swift; sourceTree = ""; }; 3865B0083865B0083865B008 /* GlobalSearchPanelCaptureManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Search/GlobalSearchPanelCaptureManager.swift; sourceTree = ""; }; @@ -2164,6 +2168,8 @@ 3023B1013023B1013023B101 /* ConfigSettingsView.swift */, A11EAB000000000000000001 /* AppearanceSettings.swift */, A11EAC000000000000000001 /* GhosttyTerminalAppearance.swift */, + A11EAE000000000000000001 /* GhosttyTitleChange.swift */, + A11EAF000000000000000001 /* GhosttyTitleChangeSubscription.swift */, A11EAD000000000000000001 /* WorkspaceAppearanceResolution.swift */, A5001011 /* cmuxApp.swift */, C46790000000000000000004 /* CLIForwardingLaunchRouter.swift */, @@ -3634,6 +3640,8 @@ A5001005 /* GhosttyTerminalView.swift in Sources */, D0B1000AA1B2C3D4E5F60001 /* GhosttyTerminalViewSupport.swift in Sources */, D3284101A1B2C3D4E5F60718 /* GhosttyTextInputSupport.swift in Sources */, + A11EAE000000000000000000 /* GhosttyTitleChange.swift in Sources */, + A11EAF000000000000000000 /* GhosttyTitleChangeSubscription.swift in Sources */, 3865A0023865A0023865A002 /* GlobalSearchCoordinator.swift in Sources */, 3865A0073865A0073865A007 /* GlobalSearchDocuments.swift in Sources */, 3865A0083865A0083865A008 /* GlobalSearchPanelCaptureManager.swift in Sources */, From 7004a320f9a0597599da35d96481b2c733bbc318 Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Mon, 15 Jun 2026 21:41:12 -0700 Subject: [PATCH 4/7] Fix chat refactor project file entry --- cmux.xcodeproj/project.pbxproj | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index c947b34624de..a2c49b0cf441 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -457,7 +457,7 @@ D7AB34400000000000000003 /* GhosttyTerminalViewVisibilityPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB34400000000000000004 /* GhosttyTerminalViewVisibilityPolicyTests.swift */; }; D3284101A1B2C3D4E5F60718 /* GhosttyTextInputSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3284102A1B2C3D4E5F60718 /* GhosttyTextInputSupport.swift */; }; A11EAE000000000000000000 /* GhosttyTitleChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11EAE000000000000000001 /* GhosttyTitleChange.swift */; }; - A11EAF000000000000000000 /* GhosttyTitleChangeSubscription.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11EAF000000000000000001 /* GhosttyTitleChangeSubscription.swift */; }; + D7C6F5010000000000000000 /* GhosttyTitleChangeSubscription.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7C6F5010000000000000001 /* GhosttyTitleChangeSubscription.swift */; }; 3865A0023865A0023865A002 /* GlobalSearchCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3865B0023865B0023865B002 /* GlobalSearchCoordinator.swift */; }; 3865A0073865A0073865A007 /* GlobalSearchDocuments.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3865B0073865B0073865B007 /* GlobalSearchDocuments.swift */; }; 3865A0083865A0083865A008 /* GlobalSearchPanelCaptureManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3865B0083865B0083865B008 /* GlobalSearchPanelCaptureManager.swift */; }; @@ -1399,7 +1399,7 @@ D7AB34400000000000000004 /* GhosttyTerminalViewVisibilityPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalViewVisibilityPolicyTests.swift; sourceTree = ""; }; D3284102A1B2C3D4E5F60718 /* GhosttyTextInputSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTextInputSupport.swift; sourceTree = ""; }; A11EAE000000000000000001 /* GhosttyTitleChange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTitleChange.swift; sourceTree = ""; }; - A11EAF000000000000000001 /* GhosttyTitleChangeSubscription.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTitleChangeSubscription.swift; sourceTree = ""; }; + D7C6F5010000000000000001 /* GhosttyTitleChangeSubscription.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTitleChangeSubscription.swift; sourceTree = ""; }; 3865B0023865B0023865B002 /* GlobalSearchCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Search/GlobalSearchCoordinator.swift; sourceTree = ""; }; 3865B0073865B0073865B007 /* GlobalSearchDocuments.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Search/GlobalSearchDocuments.swift; sourceTree = ""; }; 3865B0083865B0083865B008 /* GlobalSearchPanelCaptureManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Search/GlobalSearchPanelCaptureManager.swift; sourceTree = ""; }; @@ -2169,7 +2169,7 @@ A11EAB000000000000000001 /* AppearanceSettings.swift */, A11EAC000000000000000001 /* GhosttyTerminalAppearance.swift */, A11EAE000000000000000001 /* GhosttyTitleChange.swift */, - A11EAF000000000000000001 /* GhosttyTitleChangeSubscription.swift */, + D7C6F5010000000000000001 /* GhosttyTitleChangeSubscription.swift */, A11EAD000000000000000001 /* WorkspaceAppearanceResolution.swift */, A5001011 /* cmuxApp.swift */, C46790000000000000000004 /* CLIForwardingLaunchRouter.swift */, @@ -3641,7 +3641,7 @@ D0B1000AA1B2C3D4E5F60001 /* GhosttyTerminalViewSupport.swift in Sources */, D3284101A1B2C3D4E5F60718 /* GhosttyTextInputSupport.swift in Sources */, A11EAE000000000000000000 /* GhosttyTitleChange.swift in Sources */, - A11EAF000000000000000000 /* GhosttyTitleChangeSubscription.swift in Sources */, + D7C6F5010000000000000000 /* GhosttyTitleChangeSubscription.swift in Sources */, 3865A0023865A0023865A002 /* GlobalSearchCoordinator.swift in Sources */, 3865A0073865A0073865A007 /* GlobalSearchDocuments.swift in Sources */, 3865A0083865A0083865A008 /* GlobalSearchPanelCaptureManager.swift in Sources */, From 5c5cbad9ef3dde9d480df9d9dac7f2ade9add38c Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Tue, 16 Jun 2026 10:15:44 -0700 Subject: [PATCH 5/7] Fix remaining chat refactor project file id --- cmux.xcodeproj/project.pbxproj | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index a2c49b0cf441..96ac43cc7284 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -456,7 +456,7 @@ D0B1000AA1B2C3D4E5F60001 /* GhosttyTerminalViewSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B1000BA1B2C3D4E5F60001 /* GhosttyTerminalViewSupport.swift */; }; D7AB34400000000000000003 /* GhosttyTerminalViewVisibilityPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB34400000000000000004 /* GhosttyTerminalViewVisibilityPolicyTests.swift */; }; D3284101A1B2C3D4E5F60718 /* GhosttyTextInputSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3284102A1B2C3D4E5F60718 /* GhosttyTextInputSupport.swift */; }; - A11EAE000000000000000000 /* GhosttyTitleChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11EAE000000000000000001 /* GhosttyTitleChange.swift */; }; + D7C6F5000000000000000000 /* GhosttyTitleChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7C6F5000000000000000001 /* GhosttyTitleChange.swift */; }; D7C6F5010000000000000000 /* GhosttyTitleChangeSubscription.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7C6F5010000000000000001 /* GhosttyTitleChangeSubscription.swift */; }; 3865A0023865A0023865A002 /* GlobalSearchCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3865B0023865B0023865B002 /* GlobalSearchCoordinator.swift */; }; 3865A0073865A0073865A007 /* GlobalSearchDocuments.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3865B0073865B0073865B007 /* GlobalSearchDocuments.swift */; }; @@ -1398,7 +1398,7 @@ D0B1000BA1B2C3D4E5F60001 /* GhosttyTerminalViewSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalViewSupport.swift; sourceTree = ""; }; D7AB34400000000000000004 /* GhosttyTerminalViewVisibilityPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalViewVisibilityPolicyTests.swift; sourceTree = ""; }; D3284102A1B2C3D4E5F60718 /* GhosttyTextInputSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTextInputSupport.swift; sourceTree = ""; }; - A11EAE000000000000000001 /* GhosttyTitleChange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTitleChange.swift; sourceTree = ""; }; + D7C6F5000000000000000001 /* GhosttyTitleChange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTitleChange.swift; sourceTree = ""; }; D7C6F5010000000000000001 /* GhosttyTitleChangeSubscription.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTitleChangeSubscription.swift; sourceTree = ""; }; 3865B0023865B0023865B002 /* GlobalSearchCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Search/GlobalSearchCoordinator.swift; sourceTree = ""; }; 3865B0073865B0073865B007 /* GlobalSearchDocuments.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Search/GlobalSearchDocuments.swift; sourceTree = ""; }; @@ -2168,7 +2168,7 @@ 3023B1013023B1013023B101 /* ConfigSettingsView.swift */, A11EAB000000000000000001 /* AppearanceSettings.swift */, A11EAC000000000000000001 /* GhosttyTerminalAppearance.swift */, - A11EAE000000000000000001 /* GhosttyTitleChange.swift */, + D7C6F5000000000000000001 /* GhosttyTitleChange.swift */, D7C6F5010000000000000001 /* GhosttyTitleChangeSubscription.swift */, A11EAD000000000000000001 /* WorkspaceAppearanceResolution.swift */, A5001011 /* cmuxApp.swift */, @@ -3640,7 +3640,7 @@ A5001005 /* GhosttyTerminalView.swift in Sources */, D0B1000AA1B2C3D4E5F60001 /* GhosttyTerminalViewSupport.swift in Sources */, D3284101A1B2C3D4E5F60718 /* GhosttyTextInputSupport.swift in Sources */, - A11EAE000000000000000000 /* GhosttyTitleChange.swift in Sources */, + D7C6F5000000000000000000 /* GhosttyTitleChange.swift in Sources */, D7C6F5010000000000000000 /* GhosttyTitleChangeSubscription.swift in Sources */, 3865A0023865A0023865A002 /* GlobalSearchCoordinator.swift in Sources */, 3865A0073865A0073865A007 /* GlobalSearchDocuments.swift in Sources */, From bc336ca99eb06941cfc5cfd47360bfe92c18edcd Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Tue, 16 Jun 2026 10:34:21 -0700 Subject: [PATCH 6/7] Fix chat service actor-isolated defaults --- .../AgentChat/AgentChatTranscriptService.swift | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift b/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift index be630294b887..fcd19f3c2591 100644 --- a/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift +++ b/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift @@ -28,13 +28,20 @@ final class AgentChatTranscriptService { private static let detectionScanThrottle: TimeInterval = 4 private static let provisionalClaudeSessionIDPrefix = "detected-claude-surface-" - /// Creates the service. + /// Creates the service with a hook-store-backed registry. + /// + /// - Parameter resolver: Transcript path resolver. + convenience init(resolver: AgentChatTranscriptResolver = AgentChatTranscriptResolver()) { + self.init(registry: AgentChatSessionRegistry(), resolver: resolver) + } + + /// Creates the service with explicit dependencies. /// /// - Parameters: - /// - registry: Session registry; defaults to a hook-store-backed one. + /// - registry: Session registry. /// - resolver: Transcript path resolver. init( - registry: AgentChatSessionRegistry = AgentChatSessionRegistry(), + registry: AgentChatSessionRegistry, resolver: AgentChatTranscriptResolver = AgentChatTranscriptResolver() ) { self.registry = registry From 61e699a5b3a4001a09e27843735afdeeee425a12 Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Tue, 16 Jun 2026 10:39:23 -0700 Subject: [PATCH 7/7] Localize mobile chat unavailable error --- Resources/Localizable.xcstrings | 17 +++++++++++++++++ Sources/TerminalController+MobileChat.swift | 15 ++++++++++++--- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index d48f86fd18c5..ffb1707a14bd 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -201552,6 +201552,23 @@ } } }, + "mobile.chat.error.serviceUnavailable": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Agent chat transcript service is not configured" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エージェントチャットのトランスクリプトサービスが設定されていません" + } + } + } + }, "mobile.chat.error.transcriptNotReadable": { "extractionState": "manual", "localizations": { diff --git a/Sources/TerminalController+MobileChat.swift b/Sources/TerminalController+MobileChat.swift index 30486a73e450..d17efc273c06 100644 --- a/Sources/TerminalController+MobileChat.swift +++ b/Sources/TerminalController+MobileChat.swift @@ -18,6 +18,15 @@ extension TerminalController { ) } + /// Error shown when the Mac-side chat service is not wired into this + /// process. Surfaces in mobile RPC error banners and debug responses. + static var chatServiceUnavailableErrorMessage: String { + String( + localized: "mobile.chat.error.serviceUnavailable", + defaultValue: "Agent chat transcript service is not configured" + ) + } + /// Routes one `mobile.chat.*` method to its handler (single dispatch /// case in `mobileHostHandleRPC` keeps the god-file growth flat). func v2MobileChatDispatch(method: String, params: [String: Any]) async -> V2CallResult { @@ -44,7 +53,7 @@ extension TerminalController { /// phone-side states. func v2ChatSessionsDump() -> V2CallResult { guard let service = agentChatTranscriptService else { - return .err(code: "unavailable", message: "Agent chat transcript service is not configured", data: nil) + return .err(code: "unavailable", message: Self.chatServiceUnavailableErrorMessage, data: nil) } return .ok(["sessions": service.debugSessionDump()]) } @@ -54,7 +63,7 @@ extension TerminalController { func v2MobileChatSessions(params: [String: Any]) -> V2CallResult { let workspaceID = v2String(params, "workspace_id") guard let service = agentChatTranscriptService else { - return .err(code: "unavailable", message: "Agent chat transcript service is not configured", data: nil) + return .err(code: "unavailable", message: Self.chatServiceUnavailableErrorMessage, data: nil) } // Register coding agents cmux detects by terminal title but that never // ran a hook (e.g. launched through a shell wrapper that bypasses @@ -127,7 +136,7 @@ extension TerminalController { let limit = min(max(v2Int(params, "limit") ?? 100, 1), 200) let beforeSeq = v2Int(params, "before_seq") guard let service = agentChatTranscriptService else { - return .err(code: "unavailable", message: "Agent chat transcript service is not configured", data: nil) + return .err(code: "unavailable", message: Self.chatServiceUnavailableErrorMessage, data: nil) } var page = await service.history(sessionID: sessionID, beforeSeq: beforeSeq, limit: limit) if page == nil, let staleRecord = service.sessionRecord(sessionID: sessionID) {