From ba5e7ae203fd772b02fc1e7a32af36d3c044dc8c Mon Sep 17 00:00:00 2001 From: cmux Date: Fri, 26 Jun 2026 16:37:29 -0700 Subject: [PATCH 01/41] Add Codex transcript fallback regression --- .../AgentChatTranscriptResolverTests.swift | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/cmuxTests/AgentChatTranscriptResolverTests.swift b/cmuxTests/AgentChatTranscriptResolverTests.swift index f0c5066497c2..05c896b462ac 100644 --- a/cmuxTests/AgentChatTranscriptResolverTests.swift +++ b/cmuxTests/AgentChatTranscriptResolverTests.swift @@ -121,6 +121,27 @@ import Testing #expect(resolver.newestClaudeTranscript(workingDirectory: bareCwd)?.sessionID == "priv-sess") } + @Test("Codex fallback ignores a rollout whose session_meta id belongs to another session") + func codexFallbackRequiresMatchingSessionMetaID() throws { + let fm = FileManager.default + let home = fm.temporaryDirectory + .appendingPathComponent("agentchat-resolver-codex-\(UUID().uuidString)", isDirectory: true) + defer { try? fm.removeItem(at: home) } + + let liveSessionID = "live-wedged-session-actual" + let liveRollout = try Self.writeCodexRollout( + home: home, + sessionID: liveSessionID + ) + let resolver = AgentChatTranscriptResolver(homeDirectory: home) + let wedgedRecord = Self.codexRecord(sessionID: "wedged-session") + + #expect(resolver.transcriptPath(for: wedgedRecord) == nil) + + let liveRecord = Self.codexRecord(sessionID: liveSessionID) + #expect(resolver.transcriptPath(for: liveRecord) == liveRollout.path) + } + @MainActor @Test("compaction telemetry preserves mobile session state") func compactionTelemetryPreservesMobileSessionState() { @@ -194,4 +215,42 @@ import Testing ) #expect(record.state == .working(since: workingSince)) } + + private static func codexRecord(sessionID: String) -> AgentChatSessionRecord { + AgentChatSessionRecord( + sessionID: sessionID, + agentKind: .codex, + workspaceID: nil, + surfaceID: nil, + workingDirectory: nil, + transcriptPath: nil, + state: .idle, + lastActivityAt: Date(timeIntervalSince1970: 0), + title: nil, + pid: nil + ) + } + + private static func writeCodexRollout( + home: URL, + sessionID: String + ) throws -> URL { + let dir = home + .appendingPathComponent(".codex", isDirectory: true) + .appendingPathComponent("sessions", isDirectory: true) + .appendingPathComponent("2026", isDirectory: true) + .appendingPathComponent("06", isDirectory: true) + .appendingPathComponent("26", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + let rolloutURL = dir.appendingPathComponent( + "rollout-2026-06-26T00-00-00-\(sessionID).jsonl", + isDirectory: false + ) + let contents = """ + {"timestamp":"2026-06-26T00:00:00.000Z","type":"session_meta","payload":{"id":"\(sessionID)","cwd":"/tmp/project"}} + {"timestamp":"2026-06-26T00:00:01.000Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"hello"}]}} + """ + try (contents + "\n").write(to: rolloutURL, atomically: true, encoding: .utf8) + return rolloutURL + } } From 731d7a5dbfc923f49f763d03c02ec756386a1e9a Mon Sep 17 00:00:00 2001 From: cmux Date: Fri, 26 Jun 2026 16:39:23 -0700 Subject: [PATCH 02/41] Bind Codex transcript fallback to exact session id --- CLI/cmux.swift | 37 ++++++++++++++++- .../AgentChatTranscriptResolver.swift | 41 +++++++++++++++++-- 2 files changed, 74 insertions(+), 4 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 512c61316eba..7f153135d607 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -25341,6 +25341,7 @@ struct CMUXCLI { func findCodexTranscriptPath(sessionId: String, env: [String: String]) -> String? { let normalizedSessionId = sessionId.trimmingCharacters(in: .whitespacesAndNewlines) guard !normalizedSessionId.isEmpty else { return nil } + let lowercasedSessionId = normalizedSessionId.lowercased() let codexHome = normalizedHookValue(env["CODEX_HOME"]) ?? "~/.codex" let sessionsURL = URL(fileURLWithPath: NSString(string: codexHome).expandingTildeInPath, isDirectory: true) @@ -25359,7 +25360,7 @@ struct CMUXCLI { for url in urls { guard url.pathExtension == "jsonl", - url.lastPathComponent.contains(normalizedSessionId) else { + codexTranscript(url, matchesSessionId: lowercasedSessionId) else { continue } let modificationDate = (try? url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast @@ -25372,6 +25373,40 @@ struct CMUXCLI { return newest?.path } + private func codexTranscript(_ url: URL, matchesSessionId lowercasedSessionId: String) -> Bool { + if let sessionMetaId = codexTranscriptSessionMetaId(at: url) { + return sessionMetaId.lowercased() == lowercasedSessionId + } + let filename = url.lastPathComponent.lowercased() + return filename == "\(lowercasedSessionId).jsonl" + || filename.hasSuffix("-\(lowercasedSessionId).jsonl") + } + + private func codexTranscriptSessionMetaId(at url: URL) -> String? { + guard let handle = try? FileHandle(forReadingFrom: url) else { + return nil + } + defer { try? handle.close() } + guard let data = try? handle.read(upToCount: 64 * 1024), + !data.isEmpty else { + return nil + } + let lineData: Data + if let newline = data.firstIndex(of: 0x0A) { + lineData = Data(data[.. [URL] { var directories: [URL] = [] var seenPaths = Set() diff --git a/Sources/Mobile/AgentChat/AgentChatTranscriptResolver.swift b/Sources/Mobile/AgentChat/AgentChatTranscriptResolver.swift index 19742824c932..16cd26118bdb 100644 --- a/Sources/Mobile/AgentChat/AgentChatTranscriptResolver.swift +++ b/Sources/Mobile/AgentChat/AgentChatTranscriptResolver.swift @@ -5,7 +5,7 @@ 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). +/// codex: rollout whose confirmed session id matches exactly). struct AgentChatTranscriptResolver: Sendable { private let homeDirectory: URL private static let claudeTranscriptTitleReadLimit = 1_048_576 @@ -176,6 +176,8 @@ struct AgentChatTranscriptResolver: Sendable { /// the session id. private func codexFallbackPath(sessionID: String) -> String? { let fileManager = FileManager.default + let normalizedSessionID = sessionID.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !normalizedSessionID.isEmpty else { return nil } let root = homeDirectory .appendingPathComponent(".codex", isDirectory: true) .appendingPathComponent("sessions", isDirectory: true) @@ -184,16 +186,49 @@ struct AgentChatTranscriptResolver: Sendable { includingPropertiesForKeys: nil, options: [.skipsHiddenFiles] ) else { return nil } - let needle = sessionID.lowercased() for case let url as URL in enumerator { guard url.pathExtension == "jsonl" else { continue } - if url.lastPathComponent.lowercased().contains(needle) { + if Self.codexRollout(url, matchesSessionID: normalizedSessionID) { return url.path } } return nil } + private static func codexRollout(_ url: URL, matchesSessionID normalizedSessionID: String) -> Bool { + if let sessionMetaID = codexSessionMetaID(at: url) { + return sessionMetaID.lowercased() == normalizedSessionID + } + let filename = url.lastPathComponent.lowercased() + return filename == "\(normalizedSessionID).jsonl" + || filename.hasSuffix("-\(normalizedSessionID).jsonl") + } + + private static func codexSessionMetaID(at url: URL) -> String? { + guard let handle = try? FileHandle(forReadingFrom: url) else { + return nil + } + defer { try? handle.close() } + guard let data = try? handle.read(upToCount: 64 * 1024), + !data.isEmpty else { + return nil + } + let lineData: Data + if let newline = data.firstIndex(of: 0x0A) { + lineData = Data(data[.. String? { guard var title = title?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty else { From 1bd0bc2797599430003e19c24a3edab7a2740ff4 Mon Sep 17 00:00:00 2001 From: cmux Date: Fri, 26 Jun 2026 16:44:23 -0700 Subject: [PATCH 03/41] Share Codex transcript identity matching --- CLI/cmux.swift | 37 +----------- .../CodexTranscriptIdentityMatcher.swift | 57 +++++++++++++++++++ .../AgentChatTranscriptResolver.swift | 39 +------------ 3 files changed, 61 insertions(+), 72 deletions(-) create mode 100644 Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/CodexTranscriptIdentityMatcher.swift diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 7f153135d607..77e20d292b86 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -25341,7 +25341,6 @@ struct CMUXCLI { func findCodexTranscriptPath(sessionId: String, env: [String: String]) -> String? { let normalizedSessionId = sessionId.trimmingCharacters(in: .whitespacesAndNewlines) guard !normalizedSessionId.isEmpty else { return nil } - let lowercasedSessionId = normalizedSessionId.lowercased() let codexHome = normalizedHookValue(env["CODEX_HOME"]) ?? "~/.codex" let sessionsURL = URL(fileURLWithPath: NSString(string: codexHome).expandingTildeInPath, isDirectory: true) @@ -25360,7 +25359,7 @@ struct CMUXCLI { for url in urls { guard url.pathExtension == "jsonl", - codexTranscript(url, matchesSessionId: lowercasedSessionId) else { + CodexTranscriptIdentityMatcher().transcript(at: url, matchesSessionID: normalizedSessionId) else { continue } let modificationDate = (try? url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast @@ -25373,40 +25372,6 @@ struct CMUXCLI { return newest?.path } - private func codexTranscript(_ url: URL, matchesSessionId lowercasedSessionId: String) -> Bool { - if let sessionMetaId = codexTranscriptSessionMetaId(at: url) { - return sessionMetaId.lowercased() == lowercasedSessionId - } - let filename = url.lastPathComponent.lowercased() - return filename == "\(lowercasedSessionId).jsonl" - || filename.hasSuffix("-\(lowercasedSessionId).jsonl") - } - - private func codexTranscriptSessionMetaId(at url: URL) -> String? { - guard let handle = try? FileHandle(forReadingFrom: url) else { - return nil - } - defer { try? handle.close() } - guard let data = try? handle.read(upToCount: 64 * 1024), - !data.isEmpty else { - return nil - } - let lineData: Data - if let newline = data.firstIndex(of: 0x0A) { - lineData = Data(data[.. [URL] { var directories: [URL] = [] var seenPaths = Set() diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/CodexTranscriptIdentityMatcher.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/CodexTranscriptIdentityMatcher.swift new file mode 100644 index 000000000000..aac072c78209 --- /dev/null +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/CodexTranscriptIdentityMatcher.swift @@ -0,0 +1,57 @@ +import Foundation + +/// Matches Codex rollout JSONL files to a confirmed session id. +/// +/// Codex writes rollout files under `.codex/sessions` with a `session_meta` +/// first line. When that metadata is present it is the authority; the filename +/// suffix is used only for metadata-free legacy files. +public struct CodexTranscriptIdentityMatcher: Sendable { + /// Creates a Codex transcript identity matcher. + public init() {} + + /// Returns whether a rollout file belongs to the provided session id. + /// + /// - Parameters: + /// - url: The candidate rollout JSONL file URL. + /// - sessionID: The confirmed Codex session id for the pane/session. + /// - Returns: `true` only when the rollout is bound to `sessionID`. + public func transcript(at url: URL, matchesSessionID sessionID: String) -> Bool { + let normalizedSessionID = normalized(sessionID) + guard !normalizedSessionID.isEmpty else { return false } + if let sessionMetaID = sessionMetaID(at: url) { + return sessionMetaID.lowercased() == normalizedSessionID + } + let filename = url.lastPathComponent.lowercased() + return filename == "\(normalizedSessionID).jsonl" + || filename.hasSuffix("-\(normalizedSessionID).jsonl") + } + + private func sessionMetaID(at url: URL) -> String? { + guard let handle = try? FileHandle(forReadingFrom: url) else { + return nil + } + defer { try? handle.close() } + guard let data = try? handle.read(upToCount: 64 * 1024), + !data.isEmpty else { + return nil + } + let lineData: Data + if let newline = data.firstIndex(of: 0x0A) { + lineData = Data(data[.. String { + sessionID.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + } +} diff --git a/Sources/Mobile/AgentChat/AgentChatTranscriptResolver.swift b/Sources/Mobile/AgentChat/AgentChatTranscriptResolver.swift index 16cd26118bdb..fdacf0f0cbe3 100644 --- a/Sources/Mobile/AgentChat/AgentChatTranscriptResolver.swift +++ b/Sources/Mobile/AgentChat/AgentChatTranscriptResolver.swift @@ -1,4 +1,5 @@ import CmuxAgentChat +import CmuxFoundation import Foundation /// Resolves the transcript JSONL path for an agent session. @@ -176,7 +177,7 @@ struct AgentChatTranscriptResolver: Sendable { /// the session id. private func codexFallbackPath(sessionID: String) -> String? { let fileManager = FileManager.default - let normalizedSessionID = sessionID.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let normalizedSessionID = sessionID.trimmingCharacters(in: .whitespacesAndNewlines) guard !normalizedSessionID.isEmpty else { return nil } let root = homeDirectory .appendingPathComponent(".codex", isDirectory: true) @@ -188,47 +189,13 @@ struct AgentChatTranscriptResolver: Sendable { ) else { return nil } for case let url as URL in enumerator { guard url.pathExtension == "jsonl" else { continue } - if Self.codexRollout(url, matchesSessionID: normalizedSessionID) { + if CodexTranscriptIdentityMatcher().transcript(at: url, matchesSessionID: normalizedSessionID) { return url.path } } return nil } - private static func codexRollout(_ url: URL, matchesSessionID normalizedSessionID: String) -> Bool { - if let sessionMetaID = codexSessionMetaID(at: url) { - return sessionMetaID.lowercased() == normalizedSessionID - } - let filename = url.lastPathComponent.lowercased() - return filename == "\(normalizedSessionID).jsonl" - || filename.hasSuffix("-\(normalizedSessionID).jsonl") - } - - private static func codexSessionMetaID(at url: URL) -> String? { - guard let handle = try? FileHandle(forReadingFrom: url) else { - return nil - } - defer { try? handle.close() } - guard let data = try? handle.read(upToCount: 64 * 1024), - !data.isEmpty else { - return nil - } - let lineData: Data - if let newline = data.firstIndex(of: 0x0A) { - lineData = Data(data[.. String? { guard var title = title?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty else { From 24bb84a6c97a1ece0e419812ccd5d0b32512696c Mon Sep 17 00:00:00 2001 From: cmux Date: Fri, 26 Jun 2026 16:46:00 -0700 Subject: [PATCH 04/41] Fail closed on Codex rollouts without session metadata --- .../CodexTranscriptIdentityMatcher.swift | 12 +++---- .../AgentChatTranscriptResolverTests.swift | 31 ++++++++++++++++--- 2 files changed, 31 insertions(+), 12 deletions(-) diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/CodexTranscriptIdentityMatcher.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/CodexTranscriptIdentityMatcher.swift index aac072c78209..e63e048646dc 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/CodexTranscriptIdentityMatcher.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/CodexTranscriptIdentityMatcher.swift @@ -3,8 +3,8 @@ import Foundation /// Matches Codex rollout JSONL files to a confirmed session id. /// /// Codex writes rollout files under `.codex/sessions` with a `session_meta` -/// first line. When that metadata is present it is the authority; the filename -/// suffix is used only for metadata-free legacy files. +/// first line. That metadata is the authority; metadata-free candidates fail +/// closed so a stale or unrelated rollout cannot be attributed to a pane. public struct CodexTranscriptIdentityMatcher: Sendable { /// Creates a Codex transcript identity matcher. public init() {} @@ -18,12 +18,10 @@ public struct CodexTranscriptIdentityMatcher: Sendable { public func transcript(at url: URL, matchesSessionID sessionID: String) -> Bool { let normalizedSessionID = normalized(sessionID) guard !normalizedSessionID.isEmpty else { return false } - if let sessionMetaID = sessionMetaID(at: url) { - return sessionMetaID.lowercased() == normalizedSessionID + guard url.lastPathComponent.lowercased().contains(normalizedSessionID) else { + return false } - let filename = url.lastPathComponent.lowercased() - return filename == "\(normalizedSessionID).jsonl" - || filename.hasSuffix("-\(normalizedSessionID).jsonl") + return sessionMetaID(at: url)?.lowercased() == normalizedSessionID } private func sessionMetaID(at url: URL) -> String? { diff --git a/cmuxTests/AgentChatTranscriptResolverTests.swift b/cmuxTests/AgentChatTranscriptResolverTests.swift index 05c896b462ac..bea95f757e37 100644 --- a/cmuxTests/AgentChatTranscriptResolverTests.swift +++ b/cmuxTests/AgentChatTranscriptResolverTests.swift @@ -142,6 +142,24 @@ import Testing #expect(resolver.transcriptPath(for: liveRecord) == liveRollout.path) } + @Test("Codex fallback fails closed when session_meta id is unavailable") + func codexFallbackRequiresSessionMetaID() throws { + let fm = FileManager.default + let home = fm.temporaryDirectory + .appendingPathComponent("agentchat-resolver-codex-missing-meta-\(UUID().uuidString)", isDirectory: true) + defer { try? fm.removeItem(at: home) } + + let sessionID = "live-wedged-session-actual" + _ = try Self.writeCodexRollout( + home: home, + sessionID: sessionID, + includeSessionMeta: false + ) + let resolver = AgentChatTranscriptResolver(homeDirectory: home) + + #expect(resolver.transcriptPath(for: Self.codexRecord(sessionID: sessionID)) == nil) + } + @MainActor @Test("compaction telemetry preserves mobile session state") func compactionTelemetryPreservesMobileSessionState() { @@ -233,7 +251,8 @@ import Testing private static func writeCodexRollout( home: URL, - sessionID: String + sessionID: String, + includeSessionMeta: Bool = true ) throws -> URL { let dir = home .appendingPathComponent(".codex", isDirectory: true) @@ -246,10 +265,12 @@ import Testing "rollout-2026-06-26T00-00-00-\(sessionID).jsonl", isDirectory: false ) - let contents = """ - {"timestamp":"2026-06-26T00:00:00.000Z","type":"session_meta","payload":{"id":"\(sessionID)","cwd":"/tmp/project"}} - {"timestamp":"2026-06-26T00:00:01.000Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"hello"}]}} - """ + var lines: [String] = [] + if includeSessionMeta { + lines.append(#"{"timestamp":"2026-06-26T00:00:00.000Z","type":"session_meta","payload":{"id":"\#(sessionID)","cwd":"/tmp/project"}}"#) + } + lines.append(#"{"timestamp":"2026-06-26T00:00:01.000Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"hello"}]}}"#) + let contents = lines.joined(separator: "\n") try (contents + "\n").write(to: rolloutURL, atomically: true, encoding: .utf8) return rolloutURL } From 951053fa7d6942700c3a69954dd218ddad5b82d6 Mon Sep 17 00:00:00 2001 From: cmux Date: Fri, 26 Jun 2026 19:07:06 -0700 Subject: [PATCH 05/41] Export Foundation for Codex matcher API --- .../Sources/CmuxFoundation/CodexTranscriptIdentityMatcher.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/CodexTranscriptIdentityMatcher.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/CodexTranscriptIdentityMatcher.swift index e63e048646dc..9d0a4628c023 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/CodexTranscriptIdentityMatcher.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/CodexTranscriptIdentityMatcher.swift @@ -1,4 +1,4 @@ -import Foundation +public import Foundation /// Matches Codex rollout JSONL files to a confirmed session id. /// From 5580b7ceeb182995533b32642314fc271f83d186 Mon Sep 17 00:00:00 2001 From: cmux Date: Fri, 26 Jun 2026 19:35:07 -0700 Subject: [PATCH 06/41] Bound Codex transcript lookup --- CLI/cmux.swift | 72 +------ .../CodexTranscriptIdentityMatcher.swift | 3 - .../Parsing/CodexTranscriptLocator.swift | 184 ++++++++++++++++++ .../AgentChatTranscriptResolver.swift | 26 ++- cmux.xcodeproj/project.pbxproj | 8 + .../AgentChatTranscriptResolverTests.swift | 90 ++++++++- 6 files changed, 291 insertions(+), 92 deletions(-) rename Packages/{macOS/CmuxFoundation/Sources/CmuxFoundation => Shared/CmuxAgentChat/Sources/CmuxAgentChat/Parsing}/CodexTranscriptIdentityMatcher.swift (94%) create mode 100644 Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Parsing/CodexTranscriptLocator.swift diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 77e20d292b86..5b6f8db93028 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -1,5 +1,6 @@ import Foundation import CMUXAgentLaunch +import CmuxAgentChat import CmuxFoundation import CmuxSettings import CoreFoundation @@ -25345,72 +25346,11 @@ struct CMUXCLI { let codexHome = normalizedHookValue(env["CODEX_HOME"]) ?? "~/.codex" let sessionsURL = URL(fileURLWithPath: NSString(string: codexHome).expandingTildeInPath, isDirectory: true) .appendingPathComponent("sessions", isDirectory: true) - let fileManager = FileManager.default - var newest: URL? - var newestModificationDate: Date? - for directoryURL in recentCodexSessionDirectories(sessionsURL: sessionsURL, fileManager: fileManager) { - guard let urls = try? fileManager.contentsOfDirectory( - at: directoryURL, - includingPropertiesForKeys: [.contentModificationDateKey], - options: [.skipsHiddenFiles] - ) else { - continue - } - - for url in urls { - guard url.pathExtension == "jsonl", - CodexTranscriptIdentityMatcher().transcript(at: url, matchesSessionID: normalizedSessionId) else { - continue - } - let modificationDate = (try? url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast - if newest == nil || modificationDate > (newestModificationDate ?? .distantPast) { - newest = url - newestModificationDate = modificationDate - } - } - } - return newest?.path - } - - private func recentCodexSessionDirectories(sessionsURL: URL, fileManager: FileManager) -> [URL] { - var directories: [URL] = [] - var seenPaths = Set() - - func appendIfDirectory(_ url: URL) { - guard seenPaths.insert(url.path).inserted else { return } - var isDirectory: ObjCBool = false - guard fileManager.fileExists(atPath: url.path, isDirectory: &isDirectory), - isDirectory.boolValue else { - return - } - directories.append(url) - } - - appendIfDirectory(sessionsURL) - - var utcCalendar = Calendar(identifier: .gregorian) - utcCalendar.timeZone = TimeZone(secondsFromGMT: 0) ?? .current - for calendar in [Calendar.current, utcCalendar] { - for dayOffset in -14...1 { - guard let date = calendar.date(byAdding: .day, value: dayOffset, to: Date()) else { - continue - } - let components = calendar.dateComponents([.year, .month, .day], from: date) - guard let year = components.year, - let month = components.month, - let day = components.day else { - continue - } - appendIfDirectory( - sessionsURL - .appendingPathComponent(String(format: "%04d", year), isDirectory: true) - .appendingPathComponent(String(format: "%02d", month), isDirectory: true) - .appendingPathComponent(String(format: "%02d", day), isDirectory: true) - ) - } - } - - return directories + return CodexTranscriptLocator().transcriptPath( + sessionID: normalizedSessionId, + sessionsURL: sessionsURL, + fileManager: .default + ) } private static let codexMonitorLeaseDirectoryName = "codex-monitor-leases" diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/CodexTranscriptIdentityMatcher.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Parsing/CodexTranscriptIdentityMatcher.swift similarity index 94% rename from Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/CodexTranscriptIdentityMatcher.swift rename to Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Parsing/CodexTranscriptIdentityMatcher.swift index 9d0a4628c023..d173c1f09b9b 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/CodexTranscriptIdentityMatcher.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Parsing/CodexTranscriptIdentityMatcher.swift @@ -18,9 +18,6 @@ public struct CodexTranscriptIdentityMatcher: Sendable { public func transcript(at url: URL, matchesSessionID sessionID: String) -> Bool { let normalizedSessionID = normalized(sessionID) guard !normalizedSessionID.isEmpty else { return false } - guard url.lastPathComponent.lowercased().contains(normalizedSessionID) else { - return false - } return sessionMetaID(at: url)?.lowercased() == normalizedSessionID } diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Parsing/CodexTranscriptLocator.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Parsing/CodexTranscriptLocator.swift new file mode 100644 index 000000000000..6d50b698904a --- /dev/null +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Parsing/CodexTranscriptLocator.swift @@ -0,0 +1,184 @@ +public import Foundation + +/// Locates Codex rollout transcripts using bounded, metadata-backed matching. +/// +/// The locator scans recent `~/.codex/sessions/YYYY/MM/DD` day directories, +/// plus the sessions root for legacy flat files, and verifies candidates with +/// ``CodexTranscriptIdentityMatcher`` before returning a path. +public struct CodexTranscriptLocator: Sendable { + /// Default number of past day directories included in the lookup window. + public static let defaultDayLookback = 14 + + /// Default number of future day directories included for timezone skew. + public static let defaultFutureDayLookahead = 1 + + /// Default cap on JSONL files opened during a single transcript lookup. + public static let defaultMaxCandidateFiles = 2_000 + + private let dayLookback: Int + private let futureDayLookahead: Int + private let maxCandidateFiles: Int + private let matcher: CodexTranscriptIdentityMatcher + + /// Creates a Codex transcript locator. + /// + /// - Parameters: + /// - dayLookback: Number of past day directories to scan. + /// - futureDayLookahead: Number of future day directories to scan. + /// - maxCandidateFiles: Maximum JSONL files opened per lookup. + /// - matcher: Metadata matcher used to verify candidate rollout files. + public init( + dayLookback: Int = Self.defaultDayLookback, + futureDayLookahead: Int = Self.defaultFutureDayLookahead, + maxCandidateFiles: Int = Self.defaultMaxCandidateFiles, + matcher: CodexTranscriptIdentityMatcher = CodexTranscriptIdentityMatcher() + ) { + self.dayLookback = max(0, dayLookback) + self.futureDayLookahead = max(0, futureDayLookahead) + self.maxCandidateFiles = max(1, maxCandidateFiles) + self.matcher = matcher + } + + /// Returns the matching Codex transcript path, when one is found. + /// + /// - Parameters: + /// - sessionID: The confirmed Codex session id. + /// - sessionsURL: The `.codex/sessions` directory. + /// - fileManager: File manager used for directory and file access. + /// - now: Reference date for the bounded day-directory window. + /// - Returns: Absolute path to the newest matching transcript, or `nil`. + public func transcriptPath( + sessionID: String, + sessionsURL: URL, + fileManager: FileManager = .default, + now: Date = Date() + ) -> String? { + transcriptURL( + sessionID: sessionID, + sessionsURL: sessionsURL, + fileManager: fileManager, + now: now + )?.path + } + + /// Returns the matching Codex transcript URL, when one is found. + /// + /// - Parameters: + /// - sessionID: The confirmed Codex session id. + /// - sessionsURL: The `.codex/sessions` directory. + /// - fileManager: File manager used for directory and file access. + /// - now: Reference date for the bounded day-directory window. + /// - Returns: URL to the newest matching transcript, or `nil`. + public func transcriptURL( + sessionID: String, + sessionsURL: URL, + fileManager: FileManager = .default, + now: Date = Date() + ) -> URL? { + let normalizedSessionID = sessionID.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedSessionID.isEmpty else { return nil } + + var scannedFileCount = 0 + for directoryURL in recentSessionDirectories( + sessionsURL: sessionsURL, + fileManager: fileManager, + now: now + ) { + guard scannedFileCount < maxCandidateFiles else { + return nil + } + let candidates = jsonlCandidates( + in: directoryURL, + fileManager: fileManager, + limit: maxCandidateFiles - scannedFileCount + ) + for candidate in candidates { + scannedFileCount += 1 + guard matcher.transcript(at: candidate, matchesSessionID: normalizedSessionID) else { + continue + } + return candidate + } + } + return nil + } + + private func recentSessionDirectories( + sessionsURL: URL, + fileManager: FileManager, + now: Date + ) -> [URL] { + var directories: [URL] = [] + var seenPaths = Set() + + func appendIfDirectory(_ url: URL) { + guard seenPaths.insert(url.path).inserted else { return } + var isDirectory: ObjCBool = false + guard fileManager.fileExists(atPath: url.path, isDirectory: &isDirectory), + isDirectory.boolValue else { + return + } + directories.append(url) + } + + var utcCalendar = Calendar(identifier: .gregorian) + utcCalendar.timeZone = TimeZone(secondsFromGMT: 0) ?? .current + let calendars = [Calendar.current, utcCalendar] + for dayOffset in stride(from: futureDayLookahead, through: -dayLookback, by: -1) { + for calendar in calendars { + guard let date = calendar.date(byAdding: .day, value: dayOffset, to: now) else { + continue + } + let components = calendar.dateComponents([.year, .month, .day], from: date) + guard let year = components.year, + let month = components.month, + let day = components.day else { + continue + } + appendIfDirectory( + sessionsURL + .appendingPathComponent(String(format: "%04d", year), isDirectory: true) + .appendingPathComponent(String(format: "%02d", month), isDirectory: true) + .appendingPathComponent(String(format: "%02d", day), isDirectory: true) + ) + } + } + + appendIfDirectory(sessionsURL) + return directories + } + + private func jsonlCandidates( + in directoryURL: URL, + fileManager: FileManager, + limit: Int + ) -> [URL] { + guard limit > 0 else { return [] } + guard let urls = try? fileManager.contentsOfDirectory( + at: directoryURL, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ) else { + return [] + } + var candidates: [URL] = [] + for url in urls where url.pathExtension == "jsonl" { + insertCandidate(url, into: &candidates, limit: limit) + } + return candidates + } + + private func insertCandidate(_ url: URL, into candidates: inout [URL], limit: Int) { + let candidateName = url.lastPathComponent + let insertionIndex = candidates.firstIndex { + candidateName > $0.lastPathComponent + } ?? candidates.endIndex + guard insertionIndex < limit else { + return + } + candidates.insert(url, at: insertionIndex) + if candidates.count > limit { + candidates.removeLast() + } + } +} diff --git a/Sources/Mobile/AgentChat/AgentChatTranscriptResolver.swift b/Sources/Mobile/AgentChat/AgentChatTranscriptResolver.swift index fdacf0f0cbe3..9564c5c2bab7 100644 --- a/Sources/Mobile/AgentChat/AgentChatTranscriptResolver.swift +++ b/Sources/Mobile/AgentChat/AgentChatTranscriptResolver.swift @@ -1,5 +1,4 @@ import CmuxAgentChat -import CmuxFoundation import Foundation /// Resolves the transcript JSONL path for an agent session. @@ -9,6 +8,7 @@ import Foundation /// codex: rollout whose confirmed session id matches exactly). struct AgentChatTranscriptResolver: Sendable { private let homeDirectory: URL + private let now: @Sendable () -> Date private static let claudeTranscriptTitleReadLimit = 1_048_576 private static let claudeTranscriptTitleChunkSize = 64 * 1024 private static let claudeTranscriptTitleMaxLineBytes = 256 * 1024 @@ -16,8 +16,12 @@ struct AgentChatTranscriptResolver: Sendable { /// Creates a resolver. /// /// - Parameter homeDirectory: Injectable home directory for tests. - init(homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser) { + init( + homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser, + now: @escaping @Sendable () -> Date = { Date() } + ) { self.homeDirectory = homeDirectory + self.now = now } /// Resolves the transcript path for a session. @@ -182,18 +186,12 @@ struct AgentChatTranscriptResolver: Sendable { let root = homeDirectory .appendingPathComponent(".codex", isDirectory: true) .appendingPathComponent("sessions", isDirectory: true) - guard let enumerator = fileManager.enumerator( - at: root, - includingPropertiesForKeys: nil, - options: [.skipsHiddenFiles] - ) else { return nil } - for case let url as URL in enumerator { - guard url.pathExtension == "jsonl" else { continue } - if CodexTranscriptIdentityMatcher().transcript(at: url, matchesSessionID: normalizedSessionID) { - return url.path - } - } - return nil + return CodexTranscriptLocator().transcriptPath( + sessionID: normalizedSessionID, + sessionsURL: root, + fileManager: fileManager, + now: now() + ) } private static func normalizedClaudeTitle(_ title: String?) -> String? { diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index ec61ce65b26f..e5d105f7f7db 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -222,6 +222,7 @@ B9000034A1B2C3D4E5F60719 /* cmux_open.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000030A1B2C3D4E5F60719 /* cmux_open.swift */; }; A5001654 /* CmuxActionTrust.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001655 /* CmuxActionTrust.swift */; }; AC4A6E7C4A700001AC4A0001 /* CmuxAgentChat in Frameworks */ = {isa = PBXBuildFile; productRef = AC4A6E7C4A700001AC4A0002 /* CmuxAgentChat */; }; + C0D555000000000000000001 /* CmuxAgentChat in Frameworks */ = {isa = PBXBuildFile; productRef = C0D555000000000000000002 /* CmuxAgentChat */; }; A5B00003A1B2C3D4E5F60718 /* CMUXAgentLaunch in Frameworks */ = {isa = PBXBuildFile; productRef = A5B00002A1B2C3D4E5F60718 /* CMUXAgentLaunch */; }; A5B00004A1B2C3D4E5F60718 /* CMUXAgentLaunch in Frameworks */ = {isa = PBXBuildFile; productRef = A5B00002A1B2C3D4E5F60718 /* CMUXAgentLaunch */; }; A5B00005A1B2C3D4E5F60718 /* CMUXAgentLaunch in Frameworks */ = {isa = PBXBuildFile; productRef = A5B00002A1B2C3D4E5F60718 /* CMUXAgentLaunch */; }; @@ -2319,6 +2320,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + C0D555000000000000000001 /* CmuxAgentChat in Frameworks */, A5B00004A1B2C3D4E5F60718 /* CMUXAgentLaunch in Frameworks */, CF00F00000000000000000A4 /* CmuxFoundation in Frameworks */, CD0CFE5700000000CD0CFE57 /* CmuxSettings in Frameworks */, @@ -3608,6 +3610,7 @@ C5A1FED000000000000000C3 /* CmuxSwiftRender */, C5A1FED100000000000000D3 /* CmuxSwiftRenderUI */, C5A1FED200000000000000E3 /* CmuxSidebarInterpreterClient */, + C0D555000000000000000002 /* CmuxAgentChat */, CD0CFE5200000000CD0CFE52 /* CmuxSettings */, CF00F00000000000000000A2 /* CmuxFoundation */, ); @@ -5613,6 +5616,11 @@ package = AC4A6E7C4A700001AC4A0003 /* XCLocalSwiftPackageReference "CmuxAgentChat" */; productName = CmuxAgentChat; }; + C0D555000000000000000002 /* CmuxAgentChat */ = { + isa = XCSwiftPackageProductDependency; + package = AC4A6E7C4A700001AC4A0003 /* XCLocalSwiftPackageReference "CmuxAgentChat" */; + productName = CmuxAgentChat; + }; C0DE45000000000000000002 /* CmuxExtensionKit */ = { isa = XCSwiftPackageProductDependency; package = C0DE45000000000000000003 /* XCLocalSwiftPackageReference "CmuxExtensionKit" */; diff --git a/cmuxTests/AgentChatTranscriptResolverTests.swift b/cmuxTests/AgentChatTranscriptResolverTests.swift index bea95f757e37..07cbd48001c0 100644 --- a/cmuxTests/AgentChatTranscriptResolverTests.swift +++ b/cmuxTests/AgentChatTranscriptResolverTests.swift @@ -133,7 +133,7 @@ import Testing home: home, sessionID: liveSessionID ) - let resolver = AgentChatTranscriptResolver(homeDirectory: home) + let resolver = Self.codexResolver(home: home) let wedgedRecord = Self.codexRecord(sessionID: "wedged-session") #expect(resolver.transcriptPath(for: wedgedRecord) == nil) @@ -155,11 +155,58 @@ import Testing sessionID: sessionID, includeSessionMeta: false ) - let resolver = AgentChatTranscriptResolver(homeDirectory: home) + let resolver = Self.codexResolver(home: home) #expect(resolver.transcriptPath(for: Self.codexRecord(sessionID: sessionID)) == nil) } + @Test("Codex fallback trusts session_meta even when the filename lacks the session id") + func codexFallbackUsesSessionMetaAsAuthority() throws { + let fm = FileManager.default + let home = fm.temporaryDirectory + .appendingPathComponent("agentchat-resolver-codex-meta-authority-\(UUID().uuidString)", isDirectory: true) + defer { try? fm.removeItem(at: home) } + + let sessionID = "confirmed-session-from-meta" + let rollout = try Self.writeCodexRollout( + home: home, + sessionID: sessionID, + filenameSessionID: "opaque-rollout-name" + ) + let resolver = Self.codexResolver(home: home) + + #expect(resolver.transcriptPath(for: Self.codexRecord(sessionID: sessionID)) == rollout.path) + } + + @Test("Codex fallback only scans bounded recent day directories") + func codexFallbackScansOnlyRecentDayDirectories() throws { + let fm = FileManager.default + let home = fm.temporaryDirectory + .appendingPathComponent("agentchat-resolver-codex-recent-\(UUID().uuidString)", isDirectory: true) + defer { try? fm.removeItem(at: home) } + + let staleSessionID = "old-session-with-valid-meta" + _ = try Self.writeCodexRollout( + home: home, + sessionID: staleSessionID, + year: 2026, + month: 5, + day: 1 + ) + let recentSessionID = "recent-session-with-valid-meta" + let recentRollout = try Self.writeCodexRollout( + home: home, + sessionID: recentSessionID, + year: 2026, + month: 6, + day: 26 + ) + let resolver = Self.codexResolver(home: home) + + #expect(resolver.transcriptPath(for: Self.codexRecord(sessionID: staleSessionID)) == nil) + #expect(resolver.transcriptPath(for: Self.codexRecord(sessionID: recentSessionID)) == recentRollout.path) + } + @MainActor @Test("compaction telemetry preserves mobile session state") func compactionTelemetryPreservesMobileSessionState() { @@ -249,29 +296,54 @@ import Testing ) } + private static func codexResolver(home: URL) -> AgentChatTranscriptResolver { + AgentChatTranscriptResolver( + homeDirectory: home, + now: { Self.utcDate(year: 2026, month: 6, day: 26) } + ) + } + private static func writeCodexRollout( home: URL, sessionID: String, - includeSessionMeta: Bool = true + includeSessionMeta: Bool = true, + filenameSessionID: String? = nil, + year: Int = 2026, + month: Int = 6, + day: Int = 26 ) throws -> URL { let dir = home .appendingPathComponent(".codex", isDirectory: true) .appendingPathComponent("sessions", isDirectory: true) - .appendingPathComponent("2026", isDirectory: true) - .appendingPathComponent("06", isDirectory: true) - .appendingPathComponent("26", isDirectory: true) + .appendingPathComponent(String(format: "%04d", year), isDirectory: true) + .appendingPathComponent(String(format: "%02d", month), isDirectory: true) + .appendingPathComponent(String(format: "%02d", day), isDirectory: true) try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + let dateStamp = String(format: "%04d-%02d-%02d", year, month, day) let rolloutURL = dir.appendingPathComponent( - "rollout-2026-06-26T00-00-00-\(sessionID).jsonl", + "rollout-\(dateStamp)T00-00-00-\(filenameSessionID ?? sessionID).jsonl", isDirectory: false ) var lines: [String] = [] if includeSessionMeta { - lines.append(#"{"timestamp":"2026-06-26T00:00:00.000Z","type":"session_meta","payload":{"id":"\#(sessionID)","cwd":"/tmp/project"}}"#) + lines.append(#"{"timestamp":"\#(dateStamp)T00:00:00.000Z","type":"session_meta","payload":{"id":"\#(sessionID)","cwd":"/tmp/project"}}"#) } - lines.append(#"{"timestamp":"2026-06-26T00:00:01.000Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"hello"}]}}"#) + lines.append(#"{"timestamp":"\#(dateStamp)T00:00:01.000Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"hello"}]}}"#) let contents = lines.joined(separator: "\n") try (contents + "\n").write(to: rolloutURL, atomically: true, encoding: .utf8) return rolloutURL } + + private static func utcDate(year: Int, month: Int, day: Int) -> Date { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0) ?? .current + return calendar.date(from: DateComponents( + calendar: calendar, + timeZone: calendar.timeZone, + year: year, + month: month, + day: day, + hour: 12 + )) ?? Date(timeIntervalSince1970: 0) + } } From bacff62d86604a41a20297b0ca6beba2d09e46b5 Mon Sep 17 00:00:00 2001 From: cmux Date: Fri, 26 Jun 2026 22:27:36 -0700 Subject: [PATCH 07/41] Guard iOS glass APIs for older SDKs --- .../CmuxAgentChatUI/Composer/ChatComposerView.swift | 7 ++++--- .../Screen/ChatKeyboardTrackingViewController.swift | 6 ++++++ .../Transcript/ChatTranscriptTableView.swift | 2 ++ .../Sources/CmuxMobileShellUI/SignInView.swift | 2 +- .../CmuxMobileShellUI/TerminalComposerView.swift | 5 +++++ .../Sources/CmuxMobileSupport/View+MobileGlass.swift | 12 ++++++------ .../CmuxMobileTerminal/TerminalInputTextView.swift | 8 ++++---- 7 files changed, 28 insertions(+), 14 deletions(-) diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Composer/ChatComposerView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Composer/ChatComposerView.swift index a4484ab89856..8ae440d3c727 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Composer/ChatComposerView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Composer/ChatComposerView.swift @@ -2,12 +2,10 @@ import CmuxAgentChat import CmuxMobileSupport import Foundation import SwiftUI - #if os(iOS) import PhotosUI import UIKit #endif - public struct ChatComposerView: View { private let agentState: ChatAgentState private let agentKind: ChatAgentKind @@ -39,7 +37,6 @@ public struct ChatComposerView: View { private static let maxAttachmentDimension: CGFloat = 2048 private static let jpegQuality: CGFloat = 0.85 private static let hardStopWindow: TimeInterval = 2 - public init( agentState: ChatAgentState, agentKind: ChatAgentKind, @@ -99,6 +96,7 @@ public struct ChatComposerView: View { #if os(iOS) @ViewBuilder private var composerSurface: some View { + #if compiler(>=6.2) if #available(iOS 26.0, *) { GlassEffectContainer { composerStack @@ -106,6 +104,9 @@ public struct ChatComposerView: View { } else { composerStack } + #else + composerStack + #endif } #endif diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/ChatKeyboardTrackingViewController.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/ChatKeyboardTrackingViewController.swift index f2d5b55d5d74..55f4679914cc 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/ChatKeyboardTrackingViewController.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/ChatKeyboardTrackingViewController.swift @@ -382,19 +382,24 @@ final class ChatKeyboardTrackingViewController } private func configureScrollEdgeEffect(for tableView: ChatTranscriptUITableView) { + #if compiler(>=6.2) if #available(iOS 26.0, *) { tableView.topEdgeEffect.style = .soft tableView.bottomEdgeEffect.style = .soft } + #endif } private func configureContentScrollView(for tableView: ChatTranscriptUITableView?) { + #if compiler(>=6.2) if #available(iOS 26.0, *) { setContentScrollView(tableView, for: [.top, .bottom]) } + #endif } private func configureComposerScrollEdgeInteraction(for tableView: ChatTranscriptUITableView?) { + #if compiler(>=6.2) if #available(iOS 26.0, *) { guard let tableView else { if let interaction = composerScrollEdgeInteraction { @@ -420,6 +425,7 @@ final class ChatKeyboardTrackingViewController scrollEdgeInteractionTableView = tableView } } + #endif } private func updateComposerVisibility() { diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptTableView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptTableView.swift index 1cb3f49f9b62..880b6302fbaf 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptTableView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptTableView.swift @@ -40,10 +40,12 @@ struct ChatTranscriptTableView: UIViewRepresentable { tableView.rowHeight = UITableView.automaticDimension tableView.allowsSelection = false tableView.accessibilityIdentifier = "ChatTranscriptTableView" + #if compiler(>=6.2) if #available(iOS 26.0, *) { tableView.topEdgeEffect.style = .soft tableView.bottomEdgeEffect.style = .soft } + #endif tableView.dataSource = context.coordinator tableView.delegate = context.coordinator context.coordinator.attach(tableView) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/SignInView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/SignInView.swift index 5039e6322f6e..6b5ed73735d5 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/SignInView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/SignInView.swift @@ -47,7 +47,7 @@ struct SignInView: View { @ViewBuilder private var signInEntrySwitcher: some View { - #if os(iOS) + #if os(iOS) && compiler(>=6.2) if #available(iOS 26.0, *) { GlassEffectContainer { signInEntryContent diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalComposerView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalComposerView.swift index dbaabd70e4b1..fb9ee2f67352 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalComposerView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalComposerView.swift @@ -251,6 +251,7 @@ struct TerminalComposerView: View { /// a `.bar` material backing behind the material controls. @ViewBuilder private var composerSurface: some View { + #if compiler(>=6.2) if #available(iOS 26.0, *) { GlassEffectContainer { composerBar @@ -259,6 +260,10 @@ struct TerminalComposerView: View { composerBar .background(.bar) } + #else + composerBar + .background(.bar) + #endif } private var composerBar: some View { diff --git a/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/View+MobileGlass.swift b/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/View+MobileGlass.swift index 2f52272aae9a..b0d13567c65f 100644 --- a/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/View+MobileGlass.swift +++ b/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/View+MobileGlass.swift @@ -5,7 +5,7 @@ public extension View { /// Glass (iOS 26+) or bordered button styling for secondary sign-in actions. @ViewBuilder func mobileGlassButton() -> some View { - #if os(iOS) + #if os(iOS) && compiler(>=6.2) if #available(iOS 26.0, *) { self .buttonStyle(.glass) @@ -26,7 +26,7 @@ public extension View { /// Prominent glass (iOS 26+) or bordered-prominent primary button styling. @ViewBuilder func mobileGlassProminentButton() -> some View { - #if os(iOS) + #if os(iOS) && compiler(>=6.2) if #available(iOS 26.0, *) { self .buttonStyle(.glassProminent) @@ -54,7 +54,7 @@ public extension View { /// is a no-op. @ViewBuilder func mobileGlassNavigationTitle() -> some View { - #if os(iOS) + #if os(iOS) && compiler(>=6.2) if #available(iOS 26.0, *) { self .padding(.horizontal, 14) @@ -75,7 +75,7 @@ public extension View { /// Glass (iOS 26+) or thin-material capsule pill background for input fields. @ViewBuilder func mobileGlassPill() -> some View { - #if os(iOS) + #if os(iOS) && compiler(>=6.2) if #available(iOS 26.0, *) { self.glassEffect(.regular.interactive(), in: .capsule) } else { @@ -96,7 +96,7 @@ public extension View { @ViewBuilder func mobileGlassField(cornerRadius: CGFloat = 20) -> some View { let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) - #if os(iOS) + #if os(iOS) && compiler(>=6.2) if #available(iOS 26.0, *) { self.glassEffect(.regular.interactive(), in: shape) } else { @@ -115,7 +115,7 @@ public extension View { /// button (send / dismiss). Pair with a fixed-size icon label. @ViewBuilder func mobileGlassCircle() -> some View { - #if os(iOS) + #if os(iOS) && compiler(>=6.2) if #available(iOS 26.0, *) { self.glassEffect(.regular.interactive(), in: .circle) } else { diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift index c5cf0d55db2f..a69fc9fcfec5 100644 --- a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift @@ -999,10 +999,8 @@ final class TerminalInputTextView: UIView, UIKeyInput, UITextInput { // On iOS 26 the armed and sticky states share the same // prominent-glass blue fill, so the double-tap *lock* is // distinguished by a white capsule border drawn over the glass (see - // ``AccessoryActionButton/isStickyLocked``). On earlier OSes the - // flat style already renders the locked white stroke through the - // background configuration, so the layer border stays off to avoid - // a doubled stroke. + // ``AccessoryActionButton/isStickyLocked``). Earlier OSes already + // render the locked white stroke through the background style. if #available(iOS 26.0, *) { actionButton.isStickyLocked = sticky } else { @@ -1012,6 +1010,7 @@ final class TerminalInputTextView: UIView, UIKeyInput, UITextInput { } private static func accessoryButtonConfiguration(armed: Bool, sticky: Bool) -> UIButton.Configuration { + #if compiler(>=6.2) if #available(iOS 26.0, *) { var config: UIButton.Configuration = (armed || sticky) ? .prominentGlass() : .glass() config.baseForegroundColor = .white @@ -1020,6 +1019,7 @@ final class TerminalInputTextView: UIView, UIKeyInput, UITextInput { } return config } + #endif var config = UIButton.Configuration.plain() var background = UIBackgroundConfiguration.clear() if sticky { From 5592081c784a1b961863bd0bb37680b00ffb8f76 Mon Sep 17 00:00:00 2001 From: cmux Date: Fri, 26 Jun 2026 22:31:10 -0700 Subject: [PATCH 08/41] Keep mobile composer within line budget --- .../Sources/CmuxMobileShellUI/TerminalComposerView.swift | 5 ----- 1 file changed, 5 deletions(-) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalComposerView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalComposerView.swift index fb9ee2f67352..74ef0dc33286 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalComposerView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalComposerView.swift @@ -69,31 +69,26 @@ struct TerminalComposerView: View { /// `@Observable` reference type is held with `@State`; SwiftUI tracks the /// `state` it reads (mic button enabled/listening) automatically. @State private var dictation = ComposerDictationController() - init(store: CMUXMobileShellStore, terminalID: String, requestHeightRemeasure: @escaping () -> Void) { self.store = store self.terminalID = terminalID self.requestHeightRemeasure = requestHeightRemeasure } - /// Single-line height of the round attach button beside the field. It stays /// pinned to the bottom edge of the (taller) field via the outer `HStack`'s /// `.bottom` alignment. private let controlHeight: CGFloat = 40 - /// Diameter of the iMessage-style send button INSIDE the field's rounded /// container. With the container's 6pt vertical padding it exactly fills the /// 40pt single-line field height (6 + 28 + 6), centering the circle on a /// one-line message; the inner `HStack`'s `.bottom` alignment keeps it riding /// the last line as the field grows. private let inlineSendDiameter: CGFloat = 28 - /// Line range for the growing compose field. Opens at a SINGLE line (`1...`) so it /// starts as a compact one-line message box and grows as the user types, up to 14 /// lines before scrolling. Each added line grows this view's height, which the host /// reserves above the toolbar, pushing only the terminal up. private let composerLineLimit = 1...14 - /// Minimum height of the compose field, matching the one-line baseline. private let composerFieldMinHeight: CGFloat = 40 From beb8420fb541101faf243d403dab12b68ef721bc Mon Sep 17 00:00:00 2001 From: cmux Date: Fri, 26 Jun 2026 22:44:02 -0700 Subject: [PATCH 09/41] Isolate chat transcript row hosting on main actor --- .../CmuxAgentChatUI/Transcript/ChatTranscriptTableView.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptTableView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptTableView.swift index 880b6302fbaf..a0b0e88840de 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptTableView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptTableView.swift @@ -152,6 +152,7 @@ struct ChatTranscriptTableView: UIViewRepresentable { items.count } + @MainActor func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: "ChatTranscriptCell") ?? UITableViewCell(style: .default, reuseIdentifier: "ChatTranscriptCell") @@ -363,6 +364,7 @@ private struct ChatTranscriptTableConfiguration { } @ViewBuilder + @MainActor func view(for item: ChatTranscriptTableItem, tableWidth: CGFloat) -> some View { itemView(for: item) .padding(.horizontal, theme.horizontalMargin) @@ -376,6 +378,7 @@ private struct ChatTranscriptTableConfiguration { } @ViewBuilder + @MainActor private func itemView(for item: ChatTranscriptTableItem) -> some View { switch item { case .loadingMore: From 94fb14b8a8776ae26ba791cb3078a6e6642b702c Mon Sep 17 00:00:00 2001 From: cmux Date: Fri, 26 Jun 2026 23:07:51 -0700 Subject: [PATCH 10/41] Use macos-26 fallback for iOS CI --- .github/workflows/test-ios.yml | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/.github/workflows/test-ios.yml b/.github/workflows/test-ios.yml index 86210f47dbb2..bb588120dc5b 100644 --- a/.github/workflows/test-ios.yml +++ b/.github/workflows/test-ios.yml @@ -110,7 +110,7 @@ jobs: mobile-core-package: needs: detect-ios-changes if: ${{ needs.detect-ios-changes.outputs.should_run == 'true' }} - runs-on: ${{ vars.MACOS_RUNNER_IOS || 'blacksmith-6vcpu-macos-26' }} + runs-on: ${{ vars.MACOS_RUNNER_IOS || 'macos-26' }} timeout-minutes: 10 steps: - name: Checkout @@ -150,12 +150,9 @@ jobs: ios-simulator: needs: detect-ios-changes if: ${{ needs.detect-ios-changes.outputs.should_run == 'true' }} - # Blacksmith, not the bare `macos-26` label (which our self-hosted fleet - # carries). iOS simulator XCTest runs inside the Simulator, not as a - # foregrounded Mac app, so the Blacksmith macOS foreground limitation that - # blocks macOS app-host XCTest does not apply here; this lane is verified - # green on blacksmith-6vcpu-macos-26. - runs-on: ${{ vars.MACOS_RUNNER_IOS || 'blacksmith-6vcpu-macos-26' }} + # Default to the currently provisioned macOS 26 runner pool; keep the repo + # variable override so CI can redirect iOS simulator lanes when needed. + runs-on: ${{ vars.MACOS_RUNNER_IOS || 'macos-26' }} timeout-minutes: 35 strategy: fail-fast: false From 8484eeff1dc2bdda7453042ca1df761d751f4220 Mon Sep 17 00:00:00 2001 From: cmux Date: Fri, 26 Jun 2026 23:10:25 -0700 Subject: [PATCH 11/41] Use depot fallback for iOS CI --- .github/workflows/test-ios.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test-ios.yml b/.github/workflows/test-ios.yml index bb588120dc5b..f59709556e68 100644 --- a/.github/workflows/test-ios.yml +++ b/.github/workflows/test-ios.yml @@ -110,7 +110,7 @@ jobs: mobile-core-package: needs: detect-ios-changes if: ${{ needs.detect-ios-changes.outputs.should_run == 'true' }} - runs-on: ${{ vars.MACOS_RUNNER_IOS || 'macos-26' }} + runs-on: ${{ vars.MACOS_RUNNER_IOS || 'depot-macos-latest' }} timeout-minutes: 10 steps: - name: Checkout @@ -150,9 +150,9 @@ jobs: ios-simulator: needs: detect-ios-changes if: ${{ needs.detect-ios-changes.outputs.should_run == 'true' }} - # Default to the currently provisioned macOS 26 runner pool; keep the repo - # variable override so CI can redirect iOS simulator lanes when needed. - runs-on: ${{ vars.MACOS_RUNNER_IOS || 'macos-26' }} + # Default to a cloud macOS runner; keep the repo variable override so CI can + # redirect iOS simulator lanes when needed. + runs-on: ${{ vars.MACOS_RUNNER_IOS || 'depot-macos-latest' }} timeout-minutes: 35 strategy: fail-fast: false From 31170f03c72e3bba5ca98e8851c4b7f77e28d5f7 Mon Sep 17 00:00:00 2001 From: cmux Date: Fri, 26 Jun 2026 23:18:23 -0700 Subject: [PATCH 12/41] Fix iOS localized diff interpolation --- .../Transcript/Rows/ChatFileEditCardView.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/Rows/ChatFileEditCardView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/Rows/ChatFileEditCardView.swift index 7505c043f3ba..e1f47c2ac6c8 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/Rows/ChatFileEditCardView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/Rows/ChatFileEditCardView.swift @@ -166,12 +166,14 @@ public struct ChatFileEditCardView: View { private func diffLineAccessibilityLabel(_ line: String) -> String { if line.hasPrefix("+") { + let addedLine = String(line.dropFirst()) return String(localized: "chat.diff.added.accessibility", - defaultValue: "Added: \(line.dropFirst())", bundle: .module) + defaultValue: "Added: \(addedLine)", bundle: .module) } if line.hasPrefix("-") { + let removedLine = String(line.dropFirst()) return String(localized: "chat.diff.removed.accessibility", - defaultValue: "Removed: \(line.dropFirst())", bundle: .module) + defaultValue: "Removed: \(removedLine)", bundle: .module) } if line.hasPrefix("@@") { return String(localized: "chat.diff.hunk.accessibility", From d07f31753c2462fb024758293af33b51a869e389 Mon Sep 17 00:00:00 2001 From: cmux Date: Fri, 26 Jun 2026 23:26:47 -0700 Subject: [PATCH 13/41] Resolve Codex transcripts off main actor --- .../Parsing/CodexTranscriptLocator.swift | 3 + .../AgentChatTranscriptResolver.swift | 19 ++-- ...ChatTranscriptService+TitleDetection.swift | 92 +++++++++++++++++++ .../AgentChatTranscriptService.swift | 24 ++++- 4 files changed, 131 insertions(+), 7 deletions(-) diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Parsing/CodexTranscriptLocator.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Parsing/CodexTranscriptLocator.swift index 6d50b698904a..edc92ef0c333 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Parsing/CodexTranscriptLocator.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Parsing/CodexTranscriptLocator.swift @@ -84,6 +84,7 @@ public struct CodexTranscriptLocator: Sendable { fileManager: fileManager, now: now ) { + guard !Task.isCancelled else { return nil } guard scannedFileCount < maxCandidateFiles else { return nil } @@ -93,6 +94,7 @@ public struct CodexTranscriptLocator: Sendable { limit: maxCandidateFiles - scannedFileCount ) for candidate in candidates { + guard !Task.isCancelled else { return nil } scannedFileCount += 1 guard matcher.transcript(at: candidate, matchesSessionID: normalizedSessionID) else { continue @@ -163,6 +165,7 @@ public struct CodexTranscriptLocator: Sendable { } var candidates: [URL] = [] for url in urls where url.pathExtension == "jsonl" { + guard !Task.isCancelled else { return [] } insertCandidate(url, into: &candidates, limit: limit) } return candidates diff --git a/Sources/Mobile/AgentChat/AgentChatTranscriptResolver.swift b/Sources/Mobile/AgentChat/AgentChatTranscriptResolver.swift index 9564c5c2bab7..068cbe1324a6 100644 --- a/Sources/Mobile/AgentChat/AgentChatTranscriptResolver.swift +++ b/Sources/Mobile/AgentChat/AgentChatTranscriptResolver.swift @@ -30,12 +30,8 @@ struct AgentChatTranscriptResolver: Sendable { /// - 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) { - return expanded - } + if let recorded = recordedTranscriptPath(for: record) { + return recorded } switch record.agentKind { case .claude: @@ -47,6 +43,17 @@ struct AgentChatTranscriptResolver: Sendable { } } + /// Returns the hook-recorded transcript path when it still exists. + /// + /// This is intentionally cheap enough for main-actor call sites; it does not + /// run any agent-specific fallback scan. + func recordedTranscriptPath(for record: AgentChatSessionRecord) -> String? { + let fileManager = FileManager.default + guard let recorded = record.transcriptPath else { return nil } + let expanded = (recorded as NSString).expandingTildeInPath + return fileManager.fileExists(atPath: expanded) ? expanded : nil + } + /// The newest Claude transcript in a working directory's project dir, /// with its session id (the filename stem). /// diff --git a/Sources/Mobile/AgentChat/AgentChatTranscriptService+TitleDetection.swift b/Sources/Mobile/AgentChat/AgentChatTranscriptService+TitleDetection.swift index 1b49d50a6a49..abe94c9c6084 100644 --- a/Sources/Mobile/AgentChat/AgentChatTranscriptService+TitleDetection.swift +++ b/Sources/Mobile/AgentChat/AgentChatTranscriptService+TitleDetection.swift @@ -239,4 +239,96 @@ extension AgentChatTranscriptService { func claimDetectedTranscriptSessionID(_ sessionID: String, surfaceID: String) { claimedDetectedTranscriptSessionIDsBySurfaceID[surfaceID, default: []].insert(sessionID) } + + func scheduleCodexTranscriptResolution(for record: AgentChatSessionRecord) { + let key: CodexTranscriptResolutionKey = ( + sessionID: record.sessionID, + transcriptPath: record.transcriptPath + ) + if let currentKey = codexTranscriptResolutionKeys[record.sessionID], + currentKey.sessionID == key.sessionID, + currentKey.transcriptPath == key.transcriptPath { + return + } + + clearCodexTranscriptResolution(sessionID: record.sessionID) + codexTranscriptResolutionKeys[record.sessionID] = key + let scanTask = detachedCodexTranscriptResolutionTask(for: record) + codexTranscriptResolutionTasks[record.sessionID] = Task { @MainActor [ + weak self, + scanTask, + key + ] in + let resolved = await withTaskCancellationHandler { + await scanTask.value + } onCancel: { + scanTask.cancel() + } + guard !Task.isCancelled else { return } + self?.applyCodexTranscriptResolution(resolved, key: key) + } + } + + func clearCodexTranscriptResolution(sessionID: String) { + codexTranscriptResolutionTasks[sessionID]?.cancel() + codexTranscriptResolutionTasks[sessionID] = nil + codexTranscriptResolutionKeys[sessionID] = nil + } + + func codexTranscriptPathOffMain(for record: AgentChatSessionRecord) async -> String? { + let scanTask = detachedCodexTranscriptResolutionTask(for: record) + return await withTaskCancellationHandler { + await scanTask.value + } onCancel: { + scanTask.cancel() + } + } + + func detachedCodexTranscriptResolutionTask( + for record: AgentChatSessionRecord + ) -> Task { + let resolver = self.resolver + #if compiler(>=6.2) + let resolveOperation: @concurrent @Sendable () async -> String? = { [resolver, record] in + resolver.transcriptPath(for: record) + } + #else + let resolveOperation: @Sendable () async -> String? = { [resolver, record] in + resolver.transcriptPath(for: record) + } + #endif + return Task.detached(priority: .utility, operation: resolveOperation) + } + + func applyCodexTranscriptResolution( + _ resolved: String?, + key: CodexTranscriptResolutionKey + ) { + guard let currentKey = codexTranscriptResolutionKeys[key.sessionID], + currentKey.sessionID == key.sessionID, + currentKey.transcriptPath == key.transcriptPath else { + return + } + codexTranscriptResolutionTasks[key.sessionID] = nil + codexTranscriptResolutionKeys[key.sessionID] = nil + applyDirectCodexTranscriptResolution(resolved, sessionID: key.sessionID) + } + + func applyDirectCodexTranscriptResolution(_ resolved: String?, sessionID: String) { + guard let record = registry.record(sessionID: sessionID), + record.agentKind == .codex, + record.state != .ended else { + return + } + guard let resolved else { + if resolver.recordedTranscriptPath(for: record) == nil { + failedResolutions.insert(sessionID) + } + return + } + failedResolutions.remove(sessionID) + if record.transcriptPath != resolved { + registry.update(sessionID: sessionID) { $0.transcriptPath = resolved } + } + } } diff --git a/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift b/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift index 45a242c7ebea..964d64b1bd17 100644 --- a/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift +++ b/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift @@ -30,6 +30,8 @@ final class AgentChatTranscriptService { var transcriptResolutionTasks: [String: Task] = [:] var transcriptResolutionKeys: [String: ClaudeTranscriptResolutionKey] = [:] var transcriptResolutionForcedRetryCounts: [String: Int] = [:] + var codexTranscriptResolutionTasks: [String: Task] = [:] + var codexTranscriptResolutionKeys: [String: CodexTranscriptResolutionKey] = [:] var claimedDetectedTranscriptSessionIDsBySurfaceID: [String: Set] = [:] var titleAdoptionHandler: (@MainActor (GhosttyTitleChange) -> Bool)? let titleChangeCoalescer = NotificationBurstCoalescer(delay: 0.25) @@ -220,6 +222,11 @@ final class AgentChatTranscriptService { // A user opening the chat is the right moment to retry a previously // failed transcript resolution. failedResolutions.remove(sessionID) + if record.agentKind == .codex, + resolver.recordedTranscriptPath(for: record) == nil { + let resolved = await codexTranscriptPathOffMain(for: record) + applyDirectCodexTranscriptResolution(resolved, sessionID: sessionID) + } if record.transcriptPath == nil, Self.isProvisionalClaudeSessionID(sessionID), let workingDirectory = record.workingDirectory, @@ -279,6 +286,10 @@ final class AgentChatTranscriptService { titleKey: String?, forceScan: Bool ) + typealias CodexTranscriptResolutionKey = ( + sessionID: String, + transcriptPath: String? + ) @discardableResult private func ensureTailer(for record: AgentChatSessionRecord) -> AgentChatTranscriptTailer? { @@ -286,7 +297,17 @@ final class AgentChatTranscriptService { return existing } guard !failedResolutions.contains(record.sessionID) else { return nil } - guard let path = resolver.transcriptPath(for: record) else { + let resolvedPath: String? + if record.agentKind == .codex { + guard let recordedPath = resolver.recordedTranscriptPath(for: record) else { + scheduleCodexTranscriptResolution(for: record) + return nil + } + resolvedPath = recordedPath + } else { + resolvedPath = resolver.transcriptPath(for: record) + } + guard let path = resolvedPath else { if Self.isProvisionalClaudeSessionID(record.sessionID) { return nil } @@ -360,6 +381,7 @@ final class AgentChatTranscriptService { // discovery while paging an ended session) from churning it. Task { await tailer.stop() } } + clearCodexTranscriptResolution(sessionID: record.sessionID) } guard MobileHostService.hasEventSubscribers(topic: Self.eventTopic) else { return } if transcriptBecameAvailable, record.state != .ended { From d77049a59b15d96fbd4fb3e7bbd448bdd099e1aa Mon Sep 17 00:00:00 2001 From: cmux Date: Fri, 26 Jun 2026 23:27:55 -0700 Subject: [PATCH 14/41] Pin iOS workflow fallback runner --- .github/workflows/test-ios.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test-ios.yml b/.github/workflows/test-ios.yml index f59709556e68..40172d09e31d 100644 --- a/.github/workflows/test-ios.yml +++ b/.github/workflows/test-ios.yml @@ -110,7 +110,7 @@ jobs: mobile-core-package: needs: detect-ios-changes if: ${{ needs.detect-ios-changes.outputs.should_run == 'true' }} - runs-on: ${{ vars.MACOS_RUNNER_IOS || 'depot-macos-latest' }} + runs-on: ${{ vars.MACOS_RUNNER_IOS || 'blacksmith-6vcpu-macos-26' }} timeout-minutes: 10 steps: - name: Checkout @@ -152,7 +152,7 @@ jobs: if: ${{ needs.detect-ios-changes.outputs.should_run == 'true' }} # Default to a cloud macOS runner; keep the repo variable override so CI can # redirect iOS simulator lanes when needed. - runs-on: ${{ vars.MACOS_RUNNER_IOS || 'depot-macos-latest' }} + runs-on: ${{ vars.MACOS_RUNNER_IOS || 'blacksmith-6vcpu-macos-26' }} timeout-minutes: 35 strategy: fail-fast: false From db0f66b4215067eabcfccb06e36f090e270e0b01 Mon Sep 17 00:00:00 2001 From: cmux Date: Fri, 26 Jun 2026 23:34:49 -0700 Subject: [PATCH 15/41] Expose transcript resolution failures to service extension --- Sources/Mobile/AgentChat/AgentChatTranscriptService.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift b/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift index 964d64b1bd17..45430be3b89d 100644 --- a/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift +++ b/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift @@ -17,7 +17,7 @@ final class AgentChatTranscriptService { /// Sessions whose transcript could not be resolved; skipped until an /// explicit history request retries, so per-hook-event resolution /// failures don't rescan the filesystem during tool storms. - private var failedResolutions: Set = [] + var failedResolutions: Set = [] /// Last time `adoptDetectedClaudeSession` ran a filesystem scan for a /// surface that had no session yet, keyed by surface id. Bounds transcript /// resolution scheduling to once per `detectionScanThrottle` while a From 6a8320c2a232b24b4b7cb5ef4cb4a78da25dcdea Mon Sep 17 00:00:00 2001 From: cmux Date: Fri, 26 Jun 2026 23:35:32 -0700 Subject: [PATCH 16/41] Clear stale Codex resolution failures --- .../AgentChat/AgentChatTranscriptService+TitleDetection.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Sources/Mobile/AgentChat/AgentChatTranscriptService+TitleDetection.swift b/Sources/Mobile/AgentChat/AgentChatTranscriptService+TitleDetection.swift index abe94c9c6084..d2deb84fcebe 100644 --- a/Sources/Mobile/AgentChat/AgentChatTranscriptService+TitleDetection.swift +++ b/Sources/Mobile/AgentChat/AgentChatTranscriptService+TitleDetection.swift @@ -323,6 +323,8 @@ extension AgentChatTranscriptService { guard let resolved else { if resolver.recordedTranscriptPath(for: record) == nil { failedResolutions.insert(sessionID) + } else { + failedResolutions.remove(sessionID) } return } From ec6df784a81ea0ae0905e88d4e8905793f09169c Mon Sep 17 00:00:00 2001 From: cmux Date: Sat, 27 Jun 2026 00:59:06 -0700 Subject: [PATCH 17/41] Handle ended Codex transcript resolution --- .github/swift-file-length-budget.tsv | 2 +- .../AgentChatTranscriptService.swift | 3 +- .../AgentChatTranscriptResolverTests.swift | 35 +++++++++++++++++++ 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index ae5863e74763..28ea800cbbab 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -221,8 +221,8 @@ 518 Packages/macOS/CmuxSwiftRender/Tests/CmuxSwiftRenderTests/Corpus/stress-git-review-queue-command-deck.swift 516 Sources/TerminalImageTransfer.swift 514 Packages/macOS/CmuxSwiftRender/Sources/CmuxSwiftRender/ExpressionEvaluator.swift -514 Sources/Mobile/AgentChat/AgentChatTranscriptService.swift 514 cmuxUITests/UpdatePillUITests.swift +513 Sources/Mobile/AgentChat/AgentChatTranscriptService.swift 509 Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizerAdditionalPolicies.swift 507 Sources/TerminalControllerTopSupport.swift 506 Sources/App/MainWindowVisibilityController.swift diff --git a/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift b/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift index 0f6aec6f659d..7500a72731dc 100644 --- a/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift +++ b/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift @@ -484,8 +484,7 @@ final class AgentChatTranscriptService { private func applyDirectCodexTranscriptResolution(_ resolved: String?, sessionID: String) { guard let record = registry.record(sessionID: sessionID), - record.agentKind == .codex, - record.state != .ended else { + record.agentKind == .codex else { return } guard let resolved else { diff --git a/cmuxTests/AgentChatTranscriptResolverTests.swift b/cmuxTests/AgentChatTranscriptResolverTests.swift index 0da562b75682..ae6b97d1bb76 100644 --- a/cmuxTests/AgentChatTranscriptResolverTests.swift +++ b/cmuxTests/AgentChatTranscriptResolverTests.swift @@ -1,4 +1,5 @@ import Foundation +import CMUXAgentLaunch import Testing #if canImport(cmux_DEV) @@ -94,6 +95,40 @@ import Testing #expect(resolver.transcriptPath(for: Self.codexRecord(sessionID: recentSessionID)) == recentRollout.path) } + @MainActor + @Test("Codex history resolves fallback transcript for ended sessions") + func codexHistoryResolvesFallbackForEndedSession() async throws { + let fm = FileManager.default + let home = fm.temporaryDirectory + .appendingPathComponent("agentchat-resolver-codex-ended-\(UUID().uuidString)", isDirectory: true) + defer { try? fm.removeItem(at: home) } + + let sessionID = "ended-session-with-valid-meta" + let rollout = try Self.writeCodexRollout(home: home, sessionID: sessionID) + let registry = AgentChatSessionRegistry() + _ = registry.noteHookEvent( + WorkstreamEvent( + sessionId: sessionID, + hookEventName: .sessionStart, + source: "codex", + receivedAt: Date(timeIntervalSince1970: 1) + ) + ) + registry.update(sessionID: sessionID) { record in + record.state = .ended + record.transcriptPath = nil + } + let service = AgentChatTranscriptService( + registry: registry, + resolver: Self.codexResolver(home: home) + ) + + let page = await service.history(sessionID: sessionID, beforeSeq: nil, limit: 20) + + #expect(page != nil) + #expect(service.sessionRecord(sessionID: sessionID)?.transcriptPath == rollout.path) + } + private static func codexRecord(sessionID: String) -> AgentChatSessionRecord { AgentChatSessionRecord( sessionID: sessionID, From 10e48734630fb141f7d21dc8430405bb9258fb66 Mon Sep 17 00:00:00 2001 From: cmux Date: Sat, 27 Jun 2026 01:49:22 -0700 Subject: [PATCH 18/41] Reattach chat scroll edge interaction --- .../CmuxAgentChatUI/Screen/ChatScrollEdgeCoordinator.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/ChatScrollEdgeCoordinator.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/ChatScrollEdgeCoordinator.swift index 200767020a64..1159fd1a9b37 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/ChatScrollEdgeCoordinator.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/ChatScrollEdgeCoordinator.swift @@ -67,10 +67,14 @@ final class ChatScrollEdgeCoordinator { } else { interaction = UIScrollEdgeElementContainerInteraction() interaction.edge = .bottom - composerView.addInteraction(interaction) bottomInteraction = interaction } + if interaction.view !== composerView { + interaction.view?.removeInteraction(interaction) + composerView.addInteraction(interaction) + } + if bottomInteractionTableView !== tableView { interaction.scrollView = tableView bottomInteractionTableView = tableView From 537e3148443afe19c5567a3084b9b637846732be Mon Sep 17 00:00:00 2001 From: cmux Date: Sat, 27 Jun 2026 01:54:53 -0700 Subject: [PATCH 19/41] Sort Codex transcript candidates once --- .../Parsing/CodexTranscriptLocator.swift | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Parsing/CodexTranscriptLocator.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Parsing/CodexTranscriptLocator.swift index edc92ef0c333..7fcc4e06693e 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Parsing/CodexTranscriptLocator.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Parsing/CodexTranscriptLocator.swift @@ -166,22 +166,14 @@ public struct CodexTranscriptLocator: Sendable { var candidates: [URL] = [] for url in urls where url.pathExtension == "jsonl" { guard !Task.isCancelled else { return [] } - insertCandidate(url, into: &candidates, limit: limit) + candidates.append(url) } - return candidates - } - - private func insertCandidate(_ url: URL, into candidates: inout [URL], limit: Int) { - let candidateName = url.lastPathComponent - let insertionIndex = candidates.firstIndex { - candidateName > $0.lastPathComponent - } ?? candidates.endIndex - guard insertionIndex < limit else { - return + candidates.sort { + $0.lastPathComponent > $1.lastPathComponent } - candidates.insert(url, at: insertionIndex) if candidates.count > limit { - candidates.removeLast() + candidates.removeSubrange(limit...) } + return candidates } } From 86b1a4d934435858b98da637a5f3a5e1289a222b Mon Sep 17 00:00:00 2001 From: cmux Date: Sat, 27 Jun 2026 02:51:33 -0700 Subject: [PATCH 20/41] Use hosted macOS 26 for iOS CI --- .github/workflows/test-ios.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test-ios.yml b/.github/workflows/test-ios.yml index 40172d09e31d..c75a94b376e7 100644 --- a/.github/workflows/test-ios.yml +++ b/.github/workflows/test-ios.yml @@ -110,7 +110,7 @@ jobs: mobile-core-package: needs: detect-ios-changes if: ${{ needs.detect-ios-changes.outputs.should_run == 'true' }} - runs-on: ${{ vars.MACOS_RUNNER_IOS || 'blacksmith-6vcpu-macos-26' }} + runs-on: macos-26 timeout-minutes: 10 steps: - name: Checkout @@ -150,9 +150,9 @@ jobs: ios-simulator: needs: detect-ios-changes if: ${{ needs.detect-ios-changes.outputs.should_run == 'true' }} - # Default to a cloud macOS runner; keep the repo variable override so CI can - # redirect iOS simulator lanes when needed. - runs-on: ${{ vars.MACOS_RUNNER_IOS || 'blacksmith-6vcpu-macos-26' }} + # Use a pinned GitHub-hosted macOS image so PR iOS lanes do not stall behind + # an unavailable self-hosted fleet label. + runs-on: macos-26 timeout-minutes: 35 strategy: fail-fast: false From 6a0cd9897fae3453e97813833f32b4151079487c Mon Sep 17 00:00:00 2001 From: cmux Date: Sat, 27 Jun 2026 02:55:14 -0700 Subject: [PATCH 21/41] Route iOS CI through runner variable --- .github/workflows/test-ios.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test-ios.yml b/.github/workflows/test-ios.yml index c75a94b376e7..ddf8d827782e 100644 --- a/.github/workflows/test-ios.yml +++ b/.github/workflows/test-ios.yml @@ -110,7 +110,7 @@ jobs: mobile-core-package: needs: detect-ios-changes if: ${{ needs.detect-ios-changes.outputs.should_run == 'true' }} - runs-on: macos-26 + runs-on: ${{ vars.MACOS_RUNNER_IOS || 'blacksmith-6vcpu-macos-26' }} timeout-minutes: 10 steps: - name: Checkout @@ -150,9 +150,9 @@ jobs: ios-simulator: needs: detect-ios-changes if: ${{ needs.detect-ios-changes.outputs.should_run == 'true' }} - # Use a pinned GitHub-hosted macOS image so PR iOS lanes do not stall behind - # an unavailable self-hosted fleet label. - runs-on: macos-26 + # Keep iOS CI runner selection behind the repo variable so overflow can + # switch between paid macOS runner providers without a workflow edit. + runs-on: ${{ vars.MACOS_RUNNER_IOS || 'blacksmith-6vcpu-macos-26' }} timeout-minutes: 35 strategy: fail-fast: false From 7a5f11cd2629312e472b7f43953a9c63eeeeebdf Mon Sep 17 00:00:00 2001 From: cmux Date: Sat, 27 Jun 2026 03:03:44 -0700 Subject: [PATCH 22/41] Fix iOS keyboard dismiss cleanup isolation --- .../CmuxAgentChatUI/Screen/KeyboardDismissTap.swift | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/KeyboardDismissTap.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/KeyboardDismissTap.swift index 6958a062dad2..41ba3c624238 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/KeyboardDismissTap.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/KeyboardDismissTap.swift @@ -33,6 +33,10 @@ struct KeyboardDismissTap: UIViewRepresentable { uiView.excludedRegion = excludedRegion } + static func dismantleUIView(_ uiView: TapInstallerView, coordinator: ()) { + uiView.uninstallRecognizer() + } + /// A non-interactive marker view that adds the recognizer to its window /// in `didMoveToWindow` — the only reliable "I'm in a window now" hook /// (relying on `updateUIView` timing missed the attach when no input @@ -69,16 +73,16 @@ struct KeyboardDismissTap: UIViewRepresentable { // and dismisses the keyboard on every tap across other screens // that share the window. if window !== installedWindow { - installedWindow?.removeGestureRecognizer(recognizer) - installedWindow = nil + uninstallRecognizer() } - guard let window else { return } + guard installedWindow == nil, let window else { return } window.addGestureRecognizer(recognizer) installedWindow = window } - deinit { + func uninstallRecognizer() { installedWindow?.removeGestureRecognizer(recognizer) + installedWindow = nil } @objc private func handleTap() { From 61ee95d3a9b5b7abec9d2564dab9d8853d320e64 Mon Sep 17 00:00:00 2001 From: cmux Date: Sat, 27 Jun 2026 03:13:00 -0700 Subject: [PATCH 23/41] Isolate mobile composer view helpers --- .../CmuxAgentChatUI/Composer/ChatComposerView.swift | 9 +++++++++ .../Sources/CmuxMobileShellUI/TerminalComposerView.swift | 5 +++++ 2 files changed, 14 insertions(+) diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Composer/ChatComposerView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Composer/ChatComposerView.swift index 8ae440d3c727..26eafe12f5db 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Composer/ChatComposerView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Composer/ChatComposerView.swift @@ -95,6 +95,7 @@ public struct ChatComposerView: View { #if os(iOS) @ViewBuilder + @MainActor private var composerSurface: some View { #if compiler(>=6.2) if #available(iOS 26.0, *) { @@ -110,6 +111,7 @@ public struct ChatComposerView: View { } #endif + @MainActor private var composerStack: some View { VStack(spacing: 8) { if isEnded { @@ -167,6 +169,7 @@ public struct ChatComposerView: View { // MARK: - Field row + @MainActor private var fieldRow: some View { HStack(alignment: .bottom, spacing: 8) { #if os(iOS) @@ -226,6 +229,7 @@ public struct ChatComposerView: View { agentState == .ended } + @MainActor private var endedRow: some View { HStack(spacing: 12) { Text( @@ -256,6 +260,7 @@ public struct ChatComposerView: View { // MARK: - Send / stop button @ViewBuilder + @MainActor private var sendButton: some View { if hasContent { Button(action: performSend) { @@ -376,6 +381,7 @@ public struct ChatComposerView: View { isDraftFocused = true } + @MainActor private var attachButton: some View { PhotosPicker(selection: $pickedItems, maxSelectionCount: 4, matching: .images) { MobileComposerIconLabel( @@ -399,6 +405,7 @@ public struct ChatComposerView: View { } } + @MainActor private var micButton: some View { let listening = dictation.state.isListening return MobileComposerIconButton( @@ -424,6 +431,7 @@ public struct ChatComposerView: View { } } + @MainActor private var attachmentStrip: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { @@ -450,6 +458,7 @@ public struct ChatComposerView: View { } } + @MainActor private func removeButton(id: String, index: Int) -> some View { Button { removeAttachment(id: id) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalComposerView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalComposerView.swift index 74ef0dc33286..cace8c25d840 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalComposerView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalComposerView.swift @@ -245,6 +245,7 @@ struct TerminalComposerView: View { /// terminal (no opaque bar — that would be glass-on-glass). Earlier OSes get /// a `.bar` material backing behind the material controls. @ViewBuilder + @MainActor private var composerSurface: some View { #if compiler(>=6.2) if #available(iOS 26.0, *) { @@ -261,6 +262,7 @@ struct TerminalComposerView: View { #endif } + @MainActor private var composerBar: some View { VStack(alignment: .leading, spacing: 6) { // iMessage-style chip row of staged image attachments, ABOVE the @@ -365,6 +367,7 @@ struct TerminalComposerView: View { /// leading side. Tapping toggles dictation; while listening it shows a filled, /// tinted mic. Disabled when the recognizer is unavailable or permission was /// denied so the user is never left tapping a dead control. + @MainActor private var micButton: some View { let listening = dictation.state.isListening return MobileComposerIconButton( @@ -395,6 +398,7 @@ struct TerminalComposerView: View { /// Horizontal, removable thumbnail chips for the staged attachments. Each /// chip shows the picked image with an x to remove it. + @MainActor private var attachmentChipRow: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { @@ -795,6 +799,7 @@ private struct AttachmentChip: View { } @ViewBuilder + @MainActor private var thumbnailView: some View { if let thumbnail { Image(uiImage: thumbnail) From 38e77b85588de5791f640824a9e2d87a4fa3c9b1 Mon Sep 17 00:00:00 2001 From: cmux Date: Sat, 27 Jun 2026 03:17:46 -0700 Subject: [PATCH 24/41] Keep composer annotations within length budget --- .../Composer/ChatComposerView.swift | 27 +++++++------------ .../TerminalComposerView.swift | 15 ++++------- 2 files changed, 14 insertions(+), 28 deletions(-) diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Composer/ChatComposerView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Composer/ChatComposerView.swift index 26eafe12f5db..c72e09a26051 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Composer/ChatComposerView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Composer/ChatComposerView.swift @@ -94,8 +94,7 @@ public struct ChatComposerView: View { } #if os(iOS) - @ViewBuilder - @MainActor + @MainActor @ViewBuilder private var composerSurface: some View { #if compiler(>=6.2) if #available(iOS 26.0, *) { @@ -111,8 +110,7 @@ public struct ChatComposerView: View { } #endif - @MainActor - private var composerStack: some View { + @MainActor private var composerStack: some View { VStack(spacing: 8) { if isEnded { endedRow @@ -169,8 +167,7 @@ public struct ChatComposerView: View { // MARK: - Field row - @MainActor - private var fieldRow: some View { + @MainActor private var fieldRow: some View { HStack(alignment: .bottom, spacing: 8) { #if os(iOS) attachButton @@ -229,8 +226,7 @@ public struct ChatComposerView: View { agentState == .ended } - @MainActor - private var endedRow: some View { + @MainActor private var endedRow: some View { HStack(spacing: 12) { Text( String( @@ -259,8 +255,7 @@ public struct ChatComposerView: View { // MARK: - Send / stop button - @ViewBuilder - @MainActor + @MainActor @ViewBuilder private var sendButton: some View { if hasContent { Button(action: performSend) { @@ -381,8 +376,7 @@ public struct ChatComposerView: View { isDraftFocused = true } - @MainActor - private var attachButton: some View { + @MainActor private var attachButton: some View { PhotosPicker(selection: $pickedItems, maxSelectionCount: 4, matching: .images) { MobileComposerIconLabel( systemImage: "paperclip", @@ -405,8 +399,7 @@ public struct ChatComposerView: View { } } - @MainActor - private var micButton: some View { + @MainActor private var micButton: some View { let listening = dictation.state.isListening return MobileComposerIconButton( systemImage: "mic", @@ -431,8 +424,7 @@ public struct ChatComposerView: View { } } - @MainActor - private var attachmentStrip: some View { + @MainActor private var attachmentStrip: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { ForEach(Array(attachments.enumerated()), id: \.element.id) { index, attachment in @@ -458,8 +450,7 @@ public struct ChatComposerView: View { } } - @MainActor - private func removeButton(id: String, index: Int) -> some View { + @MainActor private func removeButton(id: String, index: Int) -> some View { Button { removeAttachment(id: id) } label: { diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalComposerView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalComposerView.swift index cace8c25d840..2e380b6602a8 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalComposerView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalComposerView.swift @@ -244,8 +244,7 @@ struct TerminalComposerView: View { /// On iOS 26 the glass controls float in a `GlassEffectContainer` over the /// terminal (no opaque bar — that would be glass-on-glass). Earlier OSes get /// a `.bar` material backing behind the material controls. - @ViewBuilder - @MainActor + @MainActor @ViewBuilder private var composerSurface: some View { #if compiler(>=6.2) if #available(iOS 26.0, *) { @@ -262,8 +261,7 @@ struct TerminalComposerView: View { #endif } - @MainActor - private var composerBar: some View { + @MainActor private var composerBar: some View { VStack(alignment: .leading, spacing: 6) { // iMessage-style chip row of staged image attachments, ABOVE the // field. Shown only when something is staged so the empty composer @@ -367,8 +365,7 @@ struct TerminalComposerView: View { /// leading side. Tapping toggles dictation; while listening it shows a filled, /// tinted mic. Disabled when the recognizer is unavailable or permission was /// denied so the user is never left tapping a dead control. - @MainActor - private var micButton: some View { + @MainActor private var micButton: some View { let listening = dictation.state.isListening return MobileComposerIconButton( systemImage: "mic", @@ -398,8 +395,7 @@ struct TerminalComposerView: View { /// Horizontal, removable thumbnail chips for the staged attachments. Each /// chip shows the picked image with an x to remove it. - @MainActor - private var attachmentChipRow: some View { + @MainActor private var attachmentChipRow: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { ForEach(pendingAttachments) { attachment in @@ -798,8 +794,7 @@ private struct AttachmentChip: View { } } - @ViewBuilder - @MainActor + @MainActor @ViewBuilder private var thumbnailView: some View { if let thumbnail { Image(uiImage: thumbnail) From 509b07c128b75ff345f1f7e0e2139c0361dce9ff Mon Sep 17 00:00:00 2001 From: cmux Date: Sat, 27 Jun 2026 03:23:04 -0700 Subject: [PATCH 25/41] Build composer picker label on main actor --- .../CmuxAgentChatUI/Composer/ChatComposerView.swift | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Composer/ChatComposerView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Composer/ChatComposerView.swift index c72e09a26051..cae6e64cd582 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Composer/ChatComposerView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Composer/ChatComposerView.swift @@ -377,13 +377,12 @@ public struct ChatComposerView: View { } @MainActor private var attachButton: some View { - PhotosPicker(selection: $pickedItems, maxSelectionCount: 4, matching: .images) { - MobileComposerIconLabel( - systemImage: "paperclip", - foregroundStyle: AnyShapeStyle(Color.secondary.opacity(0.8)), - size: controlHeight - ) - } + let label = MobileComposerIconLabel( + systemImage: "paperclip", + foregroundStyle: AnyShapeStyle(Color.secondary.opacity(0.8)), + size: controlHeight + ) + return PhotosPicker(selection: $pickedItems, maxSelectionCount: 4, matching: .images) { label } .buttonStyle(.plain) .accessibilityIdentifier("ChatComposerAttach") .accessibilityLabel( From ab4dce596139998b5f5f8b645c6380a99bf20dcc Mon Sep 17 00:00:00 2001 From: cmux Date: Sat, 27 Jun 2026 03:28:27 -0700 Subject: [PATCH 26/41] Isolate terminal key command creation --- .../CmuxMobileTerminal/TerminalHardwareKeyResolver.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalHardwareKeyResolver.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalHardwareKeyResolver.swift index 52f7b27a215f..b8b8e13a1ee4 100644 --- a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalHardwareKeyResolver.swift +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalHardwareKeyResolver.swift @@ -33,7 +33,7 @@ struct TerminalHardwareKeyResolver { return navigation + controlInputs + shiftedControlInputs }() - static func makeKeyCommands(target: Any, action: Selector) -> [UIKeyCommand] { + @MainActor static func makeKeyCommands(target: Any, action: Selector) -> [UIKeyCommand] { keyCommands.map { command in UIKeyCommand( input: command.input, From 185e022003c8d67a79c802fc6d4c6d31c219c6f2 Mon Sep 17 00:00:00 2001 From: cmux Date: Sat, 27 Jun 2026 03:37:27 -0700 Subject: [PATCH 27/41] Fix mobile terminal surface isolation --- .../GhosttySurfaceHandle.swift | 16 +++++++++ .../GhosttySurfaceView.swift | 33 +++++++++---------- 2 files changed, 32 insertions(+), 17 deletions(-) create mode 100644 Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceHandle.swift diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceHandle.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceHandle.swift new file mode 100644 index 000000000000..24c25862a2df --- /dev/null +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceHandle.swift @@ -0,0 +1,16 @@ +#if canImport(UIKit) +import GhosttyKit + +/// Carries a libghostty surface pointer across `GhosttySurfaceView.outputQueue`. +/// +/// The pointer is only dereferenced on the serial output queue, and +/// `GhosttySurfaceView.disposeSurface()` orders the eventual free after all +/// already-enqueued queue work. +struct GhosttySurfaceHandle: @unchecked Sendable { + let pointer: ghostty_surface_t + + init(_ pointer: ghostty_surface_t) { + self.pointer = pointer + } +} +#endif diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift index 57055e84696a..ee44947a635f 100644 --- a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift @@ -2060,9 +2060,9 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { // An absolute `set_font_size:` keeps libghostty in lockstep // with `liveFontSize`, which we keep inside [minimumSize, maximumSize]. let action = "set_font_size:\(target)" - Self.outputQueue.async { + Self.outputQueue.async { [surfaceHandle = GhosttySurfaceHandle(surface)] in action.withCString { pointer in - _ = ghostty_surface_binding_action(surface, pointer, UInt(action.utf8.count)) + _ = ghostty_surface_binding_action(surfaceHandle.pointer, pointer, UInt(action.utf8.count)) } } // Render the new font (the grid reflows inside the current surface) but @@ -2184,8 +2184,7 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { } deinit { - stopKeyboardHeightAnimation() - disposeSurface() + MainActor.assumeIsolated { stopKeyboardHeightAnimation(); disposeSurface() } } public override class var layerClass: AnyClass { @@ -2303,11 +2302,11 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { // scene-update watchdog (0x8BADF00D) kills the app. It must run off // the main thread. Feed it on a serial background queue (order // preserved) and hop back to main only for the Swift-side UI state. - Self.outputQueue.async { [weak self] in + Self.outputQueue.async { [weak self, surfaceHandle = GhosttySurfaceHandle(surface)] in forwarded.withUnsafeBytes { buffer in guard let baseAddress = buffer.baseAddress else { return } let pointer = baseAddress.assumingMemoryBound(to: CChar.self) - ghostty_surface_process_output(surface, pointer, UInt(buffer.count)) + ghostty_surface_process_output(surfaceHandle.pointer, pointer, UInt(buffer.count)) } #if DEBUG // `ghostty_surface_read_text` takes the same internal surface lock as @@ -2375,9 +2374,9 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { // `process_output` also preserves ordering. The return was already // discarded. let action = "scroll_to_bottom" - Self.outputQueue.async { + Self.outputQueue.async { [surfaceHandle = GhosttySurfaceHandle(surface)] in action.withCString { pointer in - _ = ghostty_surface_binding_action(surface, pointer, UInt(action.utf8.count)) + _ = ghostty_surface_binding_action(surfaceHandle.pointer, pointer, UInt(action.utf8.count)) } } } @@ -2585,8 +2584,8 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { // backlog drains before the free. (Retain the bridge across the hop; it // owns the userdata libghostty still references until the free.) let retainedBridge = Unmanaged.passRetained(bridge) - Self.outputQueue.async { - ghostty_surface_free(surface) + Self.outputQueue.async { [surfaceHandle = GhosttySurfaceHandle(surface)] in + ghostty_surface_free(surfaceHandle.pointer) retainedBridge.release() } } @@ -2806,12 +2805,12 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { } renderInFlight = true let enqueuedAt = CACurrentMediaTime() - Self.outputQueue.async { [weak self] in + Self.outputQueue.async { [weak self, surfaceHandle = GhosttySurfaceHandle(surface)] in // Queue LAG = how long this render waited behind other ops. If this // climbs into hundreds of ms the queue is backlogged (the freeze). let lagMs = (CACurrentMediaTime() - enqueuedAt) * 1000 if lagMs > 150 { MobileDebugLog.anchormux("oq.render.LAG \(Int(lagMs))ms") } - ghostty_surface_render_now(surface) + ghostty_surface_render_now(surfaceHandle.pointer) DispatchQueue.main.async { guard let self else { return } self.renderInFlight = false @@ -3087,12 +3086,12 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { let pushContentScale = abs(lastAppliedContentScale - scale) > 0.001 if pushContentScale { lastAppliedContentScale = scale } - Self.outputQueue.async { [weak self] in + Self.outputQueue.async { [weak self, surfaceHandle = GhosttySurfaceHandle(surface)] in if pushContentScale { - ghostty_surface_set_content_scale(surface, scale, scale) + ghostty_surface_set_content_scale(surfaceHandle.pointer, scale, scale) } - ghostty_surface_set_size(surface, containerPxW, containerPxH) - let measured = ghostty_surface_size(surface) + ghostty_surface_set_size(surfaceHandle.pointer, containerPxW, containerPxH) + let measured = ghostty_surface_size(surfaceHandle.pointer) var cell = CGSize.zero if measured.columns > 0, measured.rows > 0, measured.width_px > 0, measured.height_px > 0 { @@ -3680,7 +3679,7 @@ private class DisplayLinkProxy { self.target = target } - @objc func handleDisplayLink() { + @MainActor @objc func handleDisplayLink() { target?.handleDisplayLinkFire() } } From 4aea89da52042641ecfc7d8fe439e2b56020c880 Mon Sep 17 00:00:00 2001 From: cmux Date: Sat, 27 Jun 2026 03:45:30 -0700 Subject: [PATCH 28/41] Avoid isolated deinit in mobile shell --- .../Sources/CmuxMobileShell/MobileShellComposite.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index 5652ba2a1e62..41a83a6e247b 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -886,7 +886,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { self.pairingAttemptID = UUID() } - isolated deinit { + deinit { MainActor.assumeIsolated { presenceTask?.cancel() networkPathObservationTask?.cancel() terminalEventListenerTask?.cancel() @@ -902,7 +902,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { if let remoteClient { Task { await remoteClient.disconnect() } } - } + } } public static func preview(runtime: (any MobileSyncRuntime)? = nil) -> CMUXMobileShellStore { CMUXMobileShellStore( From 66112d601107381d3090889c1c21c7cdaff41a38 Mon Sep 17 00:00:00 2001 From: cmux Date: Sat, 27 Jun 2026 03:53:28 -0700 Subject: [PATCH 29/41] Avoid sending notification settings across actors --- .../CmuxMobileShellUI/MobilePushCoordinator.swift | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift index 27fb8019e5b0..19011a88f6b7 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift @@ -140,8 +140,7 @@ public final class MobilePushCoordinator { /// and persist the flag. Returns whether authorization was granted. @discardableResult public func enable() async -> Bool { - let priorStatus = await UNUserNotificationCenter.current() - .notificationSettings().authorizationStatus + let priorStatus = await Self.currentNotificationAuthorizationStatus() // Only an undetermined status produces a real OS prompt; gate the // "shown" event on it so a re-toggle of an already-decided status does // not log a phantom prompt. @@ -172,6 +171,15 @@ public final class MobilePushCoordinator { UIApplication.shared.unregisterForRemoteNotifications() } + private static func currentNotificationAuthorizationStatus() async -> UNAuthorizationStatus { + let rawValue = await withCheckedContinuation { continuation in + UNUserNotificationCenter.current().getNotificationSettings { settings in + continuation.resume(returning: settings.authorizationStatus.rawValue) + } + } + return UNAuthorizationStatus(rawValue: rawValue) ?? .notDetermined + } + /// Hand a freshly-registered APNs token to the network layer. public func handleDeviceToken(_ token: Data) async { await registration.register(deviceToken: token) From 6407663cfd269a1157992d291dea987a6821d7ed Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Mon, 29 Jun 2026 17:32:25 -0700 Subject: [PATCH 30/41] Restore isolated deinit for mobile shell teardown MobileShellComposite is a non-UIView @MainActor store whose last strong reference can be released off the main actor (a background continuation, network callback, or detached task may hold it). The prior `deinit { MainActor.assumeIsolated { ... } }` asserts main-actor isolation and would trap (crash) on any off-main final release. Restore `isolated deinit`, which hops to the main actor for teardown, on the shipping Swift 6.2 toolchain. Keep the assume-isolated form only as a compile shim for the pre-6.2 compat lane, which lacks `isolated deinit`. Cleanup is shared via cancelInFlightWorkForTeardown(). Found by autoreview (Codex). Refresh the file-length budget for the +18-line growth in this already-tracked file. Co-Authored-By: Claude Opus 4.8 --- .github/swift-file-length-budget.tsv | 2 +- .../MobileShellComposite.swift | 22 +++++++++++++++++-- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 646850083803..a418fc833744 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -14,7 +14,7 @@ 8016 CLI/cmux_open.swift 7366 cmuxTests/WorkspaceUnitTests.swift 7218 cmuxTests/WorkspaceRemoteConnectionTests.swift -6895 Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +6913 Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift 6359 cmuxTests/SessionPersistenceTests.swift 6217 cmuxTests/GhosttyConfigTests.swift 6180 Sources/TabManager.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index 41a83a6e247b..e5365fb61400 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -886,7 +886,25 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { self.pairingAttemptID = UUID() } - deinit { MainActor.assumeIsolated { + // This non-UIView @MainActor store can have its last reference released off + // the main actor (a background continuation/callback/detached task may hold + // it), so teardown must HOP to the main actor via `isolated deinit`, not + // assert it — `MainActor.assumeIsolated` would trap on an off-main release. + // cmux ships on Swift 6.2 (the isolated-deinit path); the #else only keeps + // the older compat toolchain, which lacks `isolated deinit`, compiling. + #if compiler(>=6.2) + isolated deinit { + cancelInFlightWorkForTeardown() + } + #else + deinit { + MainActor.assumeIsolated { + cancelInFlightWorkForTeardown() + } + } + #endif + + private func cancelInFlightWorkForTeardown() { presenceTask?.cancel() networkPathObservationTask?.cancel() terminalEventListenerTask?.cancel() @@ -902,7 +920,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { if let remoteClient { Task { await remoteClient.disconnect() } } - } } + } public static func preview(runtime: (any MobileSyncRuntime)? = nil) -> CMUXMobileShellStore { CMUXMobileShellStore( From 09bfa9b7ad9037133f176f4515c5cb04cd9a3fdd Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Mon, 29 Jun 2026 17:58:40 -0700 Subject: [PATCH 31/41] Make Codex resolver tests robust to /var symlink canonicalization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app-host CI runner (macOS 15) canonicalizes `/var` -> `/private/var` when enumerating directories, so the resolver returns `/private/var/folders/.../rollout.jsonl` while the tests compared against the `/var/folders/...` path they wrote — an exact-path mismatch that fails deterministically on that runner image (same file, different string). Resolve symlinks on the temp home up front via a shared `makeTemporaryHome` helper so the rollout paths the tests write match the paths the resolver returns on any macOS version. Production is unaffected: Codex rollouts live under `~/.codex` (no `/var` symlink), so this is a test-only artifact of using `temporaryDirectory`. Co-Authored-By: Claude Opus 4.8 --- .../AgentChatTranscriptResolverTests.swift | 26 ++++++++++++------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/cmuxTests/AgentChatTranscriptResolverTests.swift b/cmuxTests/AgentChatTranscriptResolverTests.swift index ae6b97d1bb76..14a44b0f53c3 100644 --- a/cmuxTests/AgentChatTranscriptResolverTests.swift +++ b/cmuxTests/AgentChatTranscriptResolverTests.swift @@ -12,8 +12,7 @@ import Testing @Test("Codex fallback ignores a rollout whose session_meta id belongs to another session") func codexFallbackRequiresMatchingSessionMetaID() throws { let fm = FileManager.default - let home = fm.temporaryDirectory - .appendingPathComponent("agentchat-resolver-codex-\(UUID().uuidString)", isDirectory: true) + let home = Self.makeTemporaryHome("agentchat-resolver-codex") defer { try? fm.removeItem(at: home) } let liveSessionID = "live-wedged-session-actual" @@ -33,8 +32,7 @@ import Testing @Test("Codex fallback fails closed when session_meta id is unavailable") func codexFallbackRequiresSessionMetaID() throws { let fm = FileManager.default - let home = fm.temporaryDirectory - .appendingPathComponent("agentchat-resolver-codex-missing-meta-\(UUID().uuidString)", isDirectory: true) + let home = Self.makeTemporaryHome("agentchat-resolver-codex-missing-meta") defer { try? fm.removeItem(at: home) } let sessionID = "live-wedged-session-actual" @@ -51,8 +49,7 @@ import Testing @Test("Codex fallback trusts session_meta even when the filename lacks the session id") func codexFallbackUsesSessionMetaAsAuthority() throws { let fm = FileManager.default - let home = fm.temporaryDirectory - .appendingPathComponent("agentchat-resolver-codex-meta-authority-\(UUID().uuidString)", isDirectory: true) + let home = Self.makeTemporaryHome("agentchat-resolver-codex-meta-authority") defer { try? fm.removeItem(at: home) } let sessionID = "confirmed-session-from-meta" @@ -69,8 +66,7 @@ import Testing @Test("Codex fallback only scans bounded recent day directories") func codexFallbackScansOnlyRecentDayDirectories() throws { let fm = FileManager.default - let home = fm.temporaryDirectory - .appendingPathComponent("agentchat-resolver-codex-recent-\(UUID().uuidString)", isDirectory: true) + let home = Self.makeTemporaryHome("agentchat-resolver-codex-recent") defer { try? fm.removeItem(at: home) } let staleSessionID = "old-session-with-valid-meta" @@ -99,8 +95,7 @@ import Testing @Test("Codex history resolves fallback transcript for ended sessions") func codexHistoryResolvesFallbackForEndedSession() async throws { let fm = FileManager.default - let home = fm.temporaryDirectory - .appendingPathComponent("agentchat-resolver-codex-ended-\(UUID().uuidString)", isDirectory: true) + let home = Self.makeTemporaryHome("agentchat-resolver-codex-ended") defer { try? fm.removeItem(at: home) } let sessionID = "ended-session-with-valid-meta" @@ -152,6 +147,17 @@ import Testing ) } + /// Unique temp home for a Codex resolver test, with symlinks resolved up + /// front. The resolver returns paths from `contentsOfDirectory`, which on + /// some macOS versions canonicalizes `/var` -> `/private/var`; resolving the + /// home here keeps the paths the resolver returns equal to the rollout paths + /// the test writes, so the exact-path `#expect` is not a macOS-version flake. + private static func makeTemporaryHome(_ label: String) -> URL { + FileManager.default.temporaryDirectory + .appendingPathComponent("\(label)-\(UUID().uuidString)", isDirectory: true) + .resolvingSymlinksInPath() + } + private static func writeCodexRollout( home: URL, sessionID: String, From 00ce0a495a6f41143a5cd5fd18035e13f46968b4 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Mon, 29 Jun 2026 19:25:19 -0700 Subject: [PATCH 32/41] Fix temp-home symlink resolution in Codex resolver tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prior fix called `resolvingSymlinksInPath()` AFTER appending the not-yet-created leaf component. `resolvingSymlinksInPath()` only canonicalizes the portion of a path that exists, so `/var` was left unresolved — the test home stayed `/var/folders/...` while the resolver (via `contentsOfDirectory`) returned `/private/var/folders/...`, so the exact-path `#expect` failed on runners whose `temporaryDirectory` reports the `/var` form. (It passed only on runners that already report `/private/var`, which is why it was intermittent.) Resolve symlinks on `temporaryDirectory` (which exists) BEFORE appending the leaf, so the home is reliably canonical and every derived path matches. Co-Authored-By: Claude Opus 4.8 --- cmuxTests/AgentChatTranscriptResolverTests.swift | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/cmuxTests/AgentChatTranscriptResolverTests.swift b/cmuxTests/AgentChatTranscriptResolverTests.swift index 14a44b0f53c3..833f81d89883 100644 --- a/cmuxTests/AgentChatTranscriptResolverTests.swift +++ b/cmuxTests/AgentChatTranscriptResolverTests.swift @@ -149,13 +149,20 @@ import Testing /// Unique temp home for a Codex resolver test, with symlinks resolved up /// front. The resolver returns paths from `contentsOfDirectory`, which on - /// some macOS versions canonicalizes `/var` -> `/private/var`; resolving the - /// home here keeps the paths the resolver returns equal to the rollout paths - /// the test writes, so the exact-path `#expect` is not a macOS-version flake. + /// some macOS versions/runners canonicalizes `/var` -> `/private/var`; + /// resolving the home keeps the paths the resolver returns equal to the + /// rollout paths the test writes, so the exact-path `#expect` is not a + /// macOS-version flake. + /// + /// `resolvingSymlinksInPath()` only canonicalizes the portion of a path that + /// already exists, so it MUST run on `temporaryDirectory` (which exists) + /// BEFORE appending the not-yet-created leaf. Calling it afterward (on a path + /// whose leaf does not exist yet) leaves `/var` unresolved, which is the bug + /// that made the resolver's `/private/var` output mismatch the test's `/var`. private static func makeTemporaryHome(_ label: String) -> URL { FileManager.default.temporaryDirectory - .appendingPathComponent("\(label)-\(UUID().uuidString)", isDirectory: true) .resolvingSymlinksInPath() + .appendingPathComponent("\(label)-\(UUID().uuidString)", isDirectory: true) } private static func writeCodexRollout( From 3421dbe0d1b2cd0ac280a5374262817c0ba4b3b4 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Mon, 29 Jun 2026 19:43:56 -0700 Subject: [PATCH 33/41] Compare Codex resolver test paths by realpath, not raw string `URL.resolvingSymlinksInPath()` does NOT resolve `/var` -> `/private/var` on the macOS-15 CI runners (verified empirically), so neither resolving the temp home nor the prior fixes made the test's `/var` rollout path equal the resolver's `/private/var` output (the resolver gets its paths from `FileManager.contentsOfDirectory`, which DOES canonicalize). The exact-string `#expect` therefore failed whenever `temporaryDirectory` reported the `/var` form. Compare the resolver's returned path and the expected rollout path by their `realpath(3)`-resolved real paths instead. `realpath` reliably resolves the `/var` symlink the same way `contentsOfDirectory` does, so the comparison is correct regardless of which surface form the runner reports. Verified end-to-end with a standalone Swift reproduction. Co-Authored-By: Claude Opus 4.8 --- .../AgentChatTranscriptResolverTests.swift | 44 ++++++++++++------- 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/cmuxTests/AgentChatTranscriptResolverTests.swift b/cmuxTests/AgentChatTranscriptResolverTests.swift index 833f81d89883..959ba46d5409 100644 --- a/cmuxTests/AgentChatTranscriptResolverTests.swift +++ b/cmuxTests/AgentChatTranscriptResolverTests.swift @@ -26,7 +26,7 @@ import Testing #expect(resolver.transcriptPath(for: wedgedRecord) == nil) let liveRecord = Self.codexRecord(sessionID: liveSessionID) - #expect(resolver.transcriptPath(for: liveRecord) == liveRollout.path) + #expect(Self.resolvedPathsMatch(resolver.transcriptPath(for: liveRecord), liveRollout)) } @Test("Codex fallback fails closed when session_meta id is unavailable") @@ -60,7 +60,7 @@ import Testing ) let resolver = Self.codexResolver(home: home) - #expect(resolver.transcriptPath(for: Self.codexRecord(sessionID: sessionID)) == rollout.path) + #expect(Self.resolvedPathsMatch(resolver.transcriptPath(for: Self.codexRecord(sessionID: sessionID)), rollout)) } @Test("Codex fallback only scans bounded recent day directories") @@ -88,7 +88,7 @@ import Testing let resolver = Self.codexResolver(home: home) #expect(resolver.transcriptPath(for: Self.codexRecord(sessionID: staleSessionID)) == nil) - #expect(resolver.transcriptPath(for: Self.codexRecord(sessionID: recentSessionID)) == recentRollout.path) + #expect(Self.resolvedPathsMatch(resolver.transcriptPath(for: Self.codexRecord(sessionID: recentSessionID)), recentRollout)) } @MainActor @@ -121,7 +121,7 @@ import Testing let page = await service.history(sessionID: sessionID, beforeSeq: nil, limit: 20) #expect(page != nil) - #expect(service.sessionRecord(sessionID: sessionID)?.transcriptPath == rollout.path) + #expect(Self.resolvedPathsMatch(service.sessionRecord(sessionID: sessionID)?.transcriptPath, rollout)) } private static func codexRecord(sessionID: String) -> AgentChatSessionRecord { @@ -147,24 +147,34 @@ import Testing ) } - /// Unique temp home for a Codex resolver test, with symlinks resolved up - /// front. The resolver returns paths from `contentsOfDirectory`, which on - /// some macOS versions/runners canonicalizes `/var` -> `/private/var`; - /// resolving the home keeps the paths the resolver returns equal to the - /// rollout paths the test writes, so the exact-path `#expect` is not a - /// macOS-version flake. - /// - /// `resolvingSymlinksInPath()` only canonicalizes the portion of a path that - /// already exists, so it MUST run on `temporaryDirectory` (which exists) - /// BEFORE appending the not-yet-created leaf. Calling it afterward (on a path - /// whose leaf does not exist yet) leaves `/var` unresolved, which is the bug - /// that made the resolver's `/private/var` output mismatch the test's `/var`. + /// Unique temp home for a Codex resolver test. Depending on the runner, + /// `temporaryDirectory` reports the path under `/var` or `/private/var`; + /// tests compare resolved real paths (`resolvedPathsMatch`) so the surface + /// representation does not matter. private static func makeTemporaryHome(_ label: String) -> URL { FileManager.default.temporaryDirectory - .resolvingSymlinksInPath() .appendingPathComponent("\(label)-\(UUID().uuidString)", isDirectory: true) } + /// Canonicalizes an existing path with `realpath(3)`. `URL`'s + /// `resolvingSymlinksInPath()` does NOT resolve `/var` -> `/private/var` on + /// some macOS versions, but `realpath` reliably resolves it the same way + /// `FileManager.contentsOfDirectory` (which the resolver uses) does — so the + /// resolver's `/private/var` output and a test's `/var` rollout path compare + /// equal regardless of which form `temporaryDirectory` reports. + private static func canonicalExistingPath(_ path: String) -> String { + guard let resolved = realpath(path, nil) else { return path } + defer { free(resolved) } + return String(cString: resolved) + } + + /// True when a resolver-returned path and the expected rollout URL refer to + /// the same on-disk file, compared by fully symlink-resolved real paths. + private static func resolvedPathsMatch(_ actual: String?, _ expected: URL) -> Bool { + guard let actual else { return false } + return canonicalExistingPath(actual) == canonicalExistingPath(expected.path) + } + private static func writeCodexRollout( home: URL, sessionID: String, From 7d93d263a8c02ed9985b36057bad23eb2073aeca Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Mon, 29 Jun 2026 20:47:05 -0700 Subject: [PATCH 34/41] Route remaining raw Ghostty surface uses through the Sendable handle Two call sites inside `Self.outputQueue.async { [surfaceHandle = ...] }` closures still passed the raw `surface` value instead of `surfaceHandle.pointer`: the pinned-grid `fitSurfaceToGrid` call and the off-main accessibility-text read. That captured the raw libghostty pointer into the @Sendable closure, bypassing the handle's explicit lifetime and threading proof (and risking rejection under stricter concurrency checks). Use `surfaceHandle.pointer` consistently, matching the other libghostty calls in the same closures. Found by autoreview (Codex). Co-Authored-By: Claude Opus 4.8 --- .../Sources/CmuxMobileTerminal/GhosttySurfaceView.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift index ee44947a635f..64959dd7124a 100644 --- a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift @@ -2322,7 +2322,7 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { let a11yNow = CACurrentMediaTime() if a11yNow - Self.lastAccessibilityTextTime > 0.5 { Self.lastAccessibilityTextTime = a11yNow - accessibilityText = Self.accessibilitySurfaceText(surface) + accessibilityText = Self.accessibilitySurfaceText(surfaceHandle.pointer) } #endif DispatchQueue.main.async { @@ -3108,7 +3108,7 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { let pinnedW = CGFloat(eff.cols) * cell.width / scale let pinnedH = CGFloat(eff.rows) * cell.height / scale if !fillsNaturalGrid, !withinOneCell, pinnedW + 0.5 < containerW || pinnedH + 0.5 < containerH { - let fitted = Self.fitSurfaceToGrid(surface, cols: eff.cols, rows: eff.rows, cellPixelSize: cell) + let fitted = Self.fitSurfaceToGrid(surfaceHandle.pointer, cols: eff.cols, rows: eff.rows, cellPixelSize: cell) let aw = fitted.actual.width_px > 0 ? fitted.actual.width_px : fitted.requestedW let ah = fitted.actual.height_px > 0 ? fitted.actual.height_px : fitted.requestedH pinnedSize = CGSize( From 5a3d565e1cb775edf75d3819835aa3c448b45e0a Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Mon, 29 Jun 2026 21:01:44 -0700 Subject: [PATCH 35/41] Stop tailer file watchers in the Codex ended-session test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `AgentChatTranscriptService.history(...)` starts an `AgentChatTranscriptTailer`, which spins up a kqueue `FileWatcher` plus a long-lived watch task. In production the service is a singleton that lives for the app's lifetime, so the watcher is never orphaned. But `codexHistoryResolvesFallbackForEndedSession` creates a transient service and never stopped it, leaking the file watcher and its task past the test — a leaked kqueue source/fd that can trip the app-host XCTest post-test memory checker and destabilize the shard. Add `AgentChatTranscriptService.shutdown()` (stops all tailers, cancels in-flight Codex resolution tasks) and call it from the test after `history(...)`, so the watcher and tasks are released deterministically before teardown. Co-Authored-By: Claude Opus 4.8 --- .github/swift-file-length-budget.tsv | 2 +- .../AgentChat/AgentChatTranscriptService.swift | 13 +++++++++++++ cmuxTests/AgentChatTranscriptResolverTests.swift | 4 ++++ 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 42eb14da3598..79a15722a3e1 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -187,12 +187,12 @@ 583 cmuxTests/GhosttyTerminalStartupEnvironmentTests.swift 580 Packages/macOS/CmuxExtensionKit/Tests/CmuxExtensionKitTests/CmuxExtensionKitTests.swift 580 cmuxTests/CLIHookNoResponseTests.swift +579 Sources/Mobile/AgentChat/AgentChatTranscriptService.swift 577 cmuxTests/AppearanceSettingsTests.swift 574 Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/WorkspaceCoordinatorTests.swift 572 Sources/Feed/FeedTextEditorDebugWindowController.swift 568 Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalRenderGrid.swift 567 Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/ConfigDiscovery/GhosttyConfigDiscovery.swift -566 Sources/Mobile/AgentChat/AgentChatTranscriptService.swift 563 Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Pane/ControlCommandCoordinator+Pane.swift 562 cmuxTests/AgentExecutableResolverTests.swift 561 cmuxTests/GhosttyConfigPathResolverTests.swift diff --git a/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift b/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift index a0da9045a050..2cb4ad78393f 100644 --- a/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift +++ b/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift @@ -554,6 +554,19 @@ final class AgentChatTranscriptService { } } + /// Stops every active tailer and cancels in-flight Codex resolution work so + /// their file watchers and background tasks do not outlive the service. The + /// live singleton normally runs for the app's lifetime; callers that own a + /// transient instance (e.g. tests) call this at teardown so kqueue file + /// watchers and detached tasks are released deterministically. + func shutdown() async { + for task in codexTranscriptResolutionTasks.values { task.cancel() } + codexTranscriptResolutionTasks.removeAll() + codexTranscriptResolutionKeys.removeAll() + for tailer in tailers.values { await tailer.stop() } + tailers.removeAll() + } + /// Encodes a wire value into the `[String: Any]` payload shape the /// event fan-out expects. func wirePayload(_ value: T) -> [String: Any]? { diff --git a/cmuxTests/AgentChatTranscriptResolverTests.swift b/cmuxTests/AgentChatTranscriptResolverTests.swift index 959ba46d5409..2f7f2917ca23 100644 --- a/cmuxTests/AgentChatTranscriptResolverTests.swift +++ b/cmuxTests/AgentChatTranscriptResolverTests.swift @@ -119,6 +119,10 @@ import Testing ) let page = await service.history(sessionID: sessionID, beforeSeq: nil, limit: 20) + // Tear down the tailer/file-watcher started by `history(...)` so the + // kqueue watcher and its task don't outlive this test (a leaked watcher + // trips the app-host XCTest post-test memory checker). + await service.shutdown() #expect(page != nil) #expect(Self.resolvedPathsMatch(service.sessionRecord(sessionID: sessionID)?.transcriptPath, rollout)) From 3b7b621e873283918e4d0661e617217066449c8a Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Mon, 29 Jun 2026 21:18:49 -0700 Subject: [PATCH 36/41] Don't let a stale Codex fallback scan overwrite a recorded path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `applyCodexTranscriptResolution` only re-checked the pending-resolution key, which is not updated when the registry later receives a real `transcriptPath` from a hook. So a detached fallback scan scheduled while `transcriptPath == nil` could, on completion, overwrite an authoritative path recorded in the meantime — moving the chat tailer to an older/different rollout for the same session. Before applying the scan result, bail out if the record now has a valid recorded transcript path; the hook-recorded path is authoritative and the fallback is only meant to fill the gap when none exists. Found by autoreview (Codex). Co-Authored-By: Claude Opus 4.8 --- .github/swift-file-length-budget.tsv | 2 +- .../Mobile/AgentChat/AgentChatTranscriptService.swift | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 79a15722a3e1..666f4c9285ae 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -180,6 +180,7 @@ 594 cmuxTests/PortalTabDragRoutingTests.swift 591 Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/DefaultsValueModelLifecycleTests.swift 589 Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Coordinators/WorkspaceGroupCoordinator.swift +588 Sources/Mobile/AgentChat/AgentChatTranscriptService.swift 588 cmuxTests/CommandPaletteShortcutCustomizationTests.swift 586 Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Session/RemoteSessionCoordinator+PortScan.swift 586 Sources/JSONCParser.swift @@ -187,7 +188,6 @@ 583 cmuxTests/GhosttyTerminalStartupEnvironmentTests.swift 580 Packages/macOS/CmuxExtensionKit/Tests/CmuxExtensionKitTests/CmuxExtensionKitTests.swift 580 cmuxTests/CLIHookNoResponseTests.swift -579 Sources/Mobile/AgentChat/AgentChatTranscriptService.swift 577 cmuxTests/AppearanceSettingsTests.swift 574 Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/WorkspaceCoordinatorTests.swift 572 Sources/Feed/FeedTextEditorDebugWindowController.swift diff --git a/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift b/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift index 2cb4ad78393f..11237782cc16 100644 --- a/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift +++ b/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift @@ -532,6 +532,15 @@ final class AgentChatTranscriptService { } codexTranscriptResolutionTasks[key.sessionID] = nil codexTranscriptResolutionKeys[key.sessionID] = nil + // If a hook recorded the authoritative transcript path while this + // fallback scan was in flight, that recorded path wins. The key match + // above only proves no *newer scan* was scheduled; it does not see a + // registry transcript-path update, so guard against overwriting a now- + // valid recorded path with this (possibly stale) scan result. + if let record = registry.record(sessionID: key.sessionID), + resolver.recordedTranscriptPath(for: record) != nil { + return + } applyDirectCodexTranscriptResolution(resolved, sessionID: key.sessionID) } From d2276a9776870260c216635f27111325516f436e Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Thu, 2 Jul 2026 02:55:02 -0700 Subject: [PATCH 37/41] Add failing regression test for Codex recorded-path overwrite A hook can record the authoritative Codex transcript path while an off-main fallback scan is in flight. When that stale scan result lands, applyDirectCodexTranscriptResolution overwrites the recorded path because it only compares `record.transcriptPath != resolved`. Expose the apply method as internal and add a deterministic test that drives it with a stale scan result while the registry already holds an existing recorded path. This commit intentionally omits the fix so CI shows the test red; the guard follows in the next commit. Co-Authored-By: Claude Opus 4.8 --- .github/swift-file-length-budget.tsv | 2 +- .../AgentChatTranscriptService.swift | 10 +++- .../AgentChatTranscriptResolverTests.swift | 51 +++++++++++++++++++ 3 files changed, 61 insertions(+), 2 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index aa86bc0dbb5c..2889954e14fd 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -183,13 +183,13 @@ 602 Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Coordinators/WorkspaceReorderCoordinator.swift 601 Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizerPrimaryPolicies.swift 598 Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizer.swift +596 Sources/Mobile/AgentChat/AgentChatTranscriptService.swift 596 cmuxTests/CmuxEventBusTests.swift 594 cmuxTests/PortalTabDragRoutingTests.swift 592 Sources/TextBoxSubmitActions.swift 591 Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/DefaultsValueModelLifecycleTests.swift 590 Sources/Panels/BrowserNavigationDelegate.swift 589 Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Coordinators/WorkspaceGroupCoordinator.swift -588 Sources/Mobile/AgentChat/AgentChatTranscriptService.swift 588 cmuxTests/CommandPaletteShortcutCustomizationTests.swift 586 Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Session/RemoteSessionCoordinator+PortScan.swift 586 Sources/JSONCParser.swift diff --git a/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift b/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift index 11237782cc16..d9ca518400d2 100644 --- a/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift +++ b/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift @@ -544,7 +544,15 @@ final class AgentChatTranscriptService { applyDirectCodexTranscriptResolution(resolved, sessionID: key.sessionID) } - private func applyDirectCodexTranscriptResolution(_ resolved: String?, sessionID: String) { + /// Applies a resolved Codex transcript path to the registry. + /// + /// `internal` (not `private`) so a regression test can drive the + /// recorded-path guard directly: the race it protects against — a hook + /// recording the authoritative path while an off-main fallback scan is in + /// flight — cannot be reproduced deterministically through `history(...)` + /// alone. Both real callers (`history(...)` and the scheduled resolution) + /// reach here only after awaiting that scan. + func applyDirectCodexTranscriptResolution(_ resolved: String?, sessionID: String) { guard let record = registry.record(sessionID: sessionID), record.agentKind == .codex else { return diff --git a/cmuxTests/AgentChatTranscriptResolverTests.swift b/cmuxTests/AgentChatTranscriptResolverTests.swift index 2f7f2917ca23..288ab769758d 100644 --- a/cmuxTests/AgentChatTranscriptResolverTests.swift +++ b/cmuxTests/AgentChatTranscriptResolverTests.swift @@ -128,6 +128,57 @@ import Testing #expect(Self.resolvedPathsMatch(service.sessionRecord(sessionID: sessionID)?.transcriptPath, rollout)) } + @MainActor + @Test("Direct Codex resolution keeps a hook-recorded path over a stale in-flight scan") + func codexDirectResolutionPreservesRecordedPathOverStaleScan() async throws { + let fm = FileManager.default + let home = Self.makeTemporaryHome("agentchat-resolver-codex-recorded-wins") + defer { try? fm.removeItem(at: home) } + + let sessionID = "resumed-session-with-two-rollouts" + // The authoritative rollout a hook recorded for this session. + let authoritative = try Self.writeCodexRollout(home: home, sessionID: sessionID, day: 26) + // An older rollout for the same session id that an in-flight fallback + // scan could still return after the hook already recorded `authoritative`. + let stale = try Self.writeCodexRollout( + home: home, + sessionID: sessionID, + filenameSessionID: "older-rollout-name", + day: 25 + ) + + let registry = AgentChatSessionRegistry() + _ = registry.noteHookEvent( + WorkstreamEvent( + sessionId: sessionID, + hookEventName: .sessionStart, + source: "codex", + receivedAt: Date(timeIntervalSince1970: 1) + ) + ) + // A hook recorded the authoritative transcript path for the session. + registry.update(sessionID: sessionID) { record in + record.state = .ended + record.transcriptPath = authoritative.path + } + + let service = AgentChatTranscriptService( + registry: registry, + resolver: Self.codexResolver(home: home) + ) + + // A fallback scan that started before the hook fired now lands with a + // stale rollout for the same session id. It must not clobber the + // authoritative recorded path (the overwrite lived in + // `applyDirectCodexTranscriptResolution`, reachable from both + // `history(...)` and the scheduled resolution). + service.applyDirectCodexTranscriptResolution(stale.path, sessionID: sessionID) + await service.shutdown() + + #expect(Self.resolvedPathsMatch(service.sessionRecord(sessionID: sessionID)?.transcriptPath, authoritative)) + #expect(!Self.resolvedPathsMatch(service.sessionRecord(sessionID: sessionID)?.transcriptPath, stale)) + } + private static func codexRecord(sessionID: String) -> AgentChatSessionRecord { AgentChatSessionRecord( sessionID: sessionID, From 182b812b6816b50e09bb1b5c3f1ddd3ece82c538 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Thu, 2 Jul 2026 03:30:30 -0700 Subject: [PATCH 38/41] Wire Codex transcript resolver regression as a non-tolerant CI gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The regression test from the previous commit is Swift Testing (@Test/#expect). The app-host "Run unit tests" step tolerates failures by parsing only the XCTest "Executed ... (0 unexpected)" summary, which does not count Swift Testing issues — so that test passed CI on the previous commit even though it must fail without the fix. The intended two-commit red proof was silently swallowed (the "silently ignored test" pitfall). Follow the repo's established non-tolerant pattern (BrowserSystemProxy / Option-Alt sided-modifier): add the suite to FOCUSED_GATE_SELECTORS so it is not folded into the tolerant shard, guard that exclusion in test_ci_cmux_unit_test_shard.py, and run the suite through run-app-host-xcodebuild.sh in a focused -only-testing step that propagates xcodebuild's exit code. With this wiring and no fix yet, CI goes genuinely red on the app-host focused shard, proving the test catches the bug. The guard fix follows in the next commit (CI back to green). Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci.yml | 23 +++++++++++++++++++++++ scripts/ci/cmux_unit_test_shard.py | 1 + tests/test_ci_cmux_unit_test_shard.py | 1 + 3 files changed, 25 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6494759e3e0f..a46c71d3b8fc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -524,6 +524,29 @@ jobs: -only-testing:cmuxTests/GhosttyOptionAsAltModsTests \ test + - name: Run Codex transcript fallback session-binding regression + if: ${{ matrix.shard == fromJSON(env.CMUX_APP_HOST_FOCUSED_REGRESSION_SHARD) }} + run: | + # Focused gate for the Codex transcript fallback race + # (https://github.com/manaflow-ai/cmux/issues/5550). The full "Run unit + # tests" step below tolerates failures by parsing only the XCTest + # "Executed ... (0 unexpected)" summary, which does NOT count Swift + # Testing (@Test/#expect) issues. This suite is Swift Testing, so a + # regression here would otherwise be silently swallowed. Keep this + # targeted only-testing invocation as a non-tolerant guard. + set -euo pipefail + SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages" + scripts/ci/run-in-console-session.sh \ + scripts/ci/run-app-host-xcodebuild.sh \ + -project cmux.xcodeproj -scheme cmux-unit -configuration Debug \ + -derivedDataPath "$CMUX_DERIVED_DATA_PATH" \ + -clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \ + -disableAutomaticPackageResolution \ + -destination "platform=macOS" \ + CMUX_SKIP_ZIG_BUILD=1 \ + -only-testing:cmuxTests/AgentChatTranscriptResolverTests \ + test + - name: Run unit tests run: | set -euo pipefail diff --git a/scripts/ci/cmux_unit_test_shard.py b/scripts/ci/cmux_unit_test_shard.py index cbf73c071a6b..f398df1f0412 100755 --- a/scripts/ci/cmux_unit_test_shard.py +++ b/scripts/ci/cmux_unit_test_shard.py @@ -24,6 +24,7 @@ ) LARGE_SUITE_METHOD_THRESHOLD = 40 FOCUSED_GATE_SELECTORS = { + "cmuxTests/AgentChatTranscriptResolverTests", "cmuxTests/BrowserSystemProxyMirrorTests", "cmuxTests/GhosttyOptionAsAltModsTests", } diff --git a/tests/test_ci_cmux_unit_test_shard.py b/tests/test_ci_cmux_unit_test_shard.py index 6b694066e8d0..ddbe410358ff 100644 --- a/tests/test_ci_cmux_unit_test_shard.py +++ b/tests/test_ci_cmux_unit_test_shard.py @@ -107,6 +107,7 @@ def main() -> int: return 1 shard_selectors = output.read_text(encoding="utf-8").splitlines() for focused_selector in ( + "-only-testing:cmuxTests/AgentChatTranscriptResolverTests", "-only-testing:cmuxTests/BrowserSystemProxyMirrorTests", "-only-testing:cmuxTests/GhosttyOptionAsAltModsTests", ): From 880c538fef59bce78dc0d242952335bde7a70b97 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Thu, 2 Jul 2026 04:02:34 -0700 Subject: [PATCH 39/41] Guard the shared Codex resolution path against clobbering a recorded path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the recorded-transcript-path guard out of the scheduled wrapper (applyCodexTranscriptResolution) and into the shared applyDirectCodexTranscriptResolution, which both real callers funnel through. The direct path used by history(...) previously had no guard: it checks recordedTranscriptPath == nil, then awaits an off-main fallback scan, and during that suspension a hook can record the authoritative path — so the post-await apply could overwrite the freshly-recorded authoritative transcript with a stale scan result. Placing the guard on the shared path fixes that race for history(...) too and removes the duplicated guard (addresses Greptile P1). Behavior is unchanged except the exact bug case: when a recorded path already exists on disk, the scan result no longer overwrites it and resolution is treated as succeeded (failedResolutions cleared). All recorded-path-absent cases are identical to before. With the fix, the non-tolerant focused gate added in the previous commit (app-host shard 4) goes back to green — the regression test now passes. Co-Authored-By: Claude Opus 4.8 --- .github/swift-file-length-budget.tsv | 2 +- .../AgentChatTranscriptService.swift | 25 ++++++++----------- 2 files changed, 12 insertions(+), 15 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 2889954e14fd..a11e05440c9a 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -183,9 +183,9 @@ 602 Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Coordinators/WorkspaceReorderCoordinator.swift 601 Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizerPrimaryPolicies.swift 598 Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizer.swift -596 Sources/Mobile/AgentChat/AgentChatTranscriptService.swift 596 cmuxTests/CmuxEventBusTests.swift 594 cmuxTests/PortalTabDragRoutingTests.swift +593 Sources/Mobile/AgentChat/AgentChatTranscriptService.swift 592 Sources/TextBoxSubmitActions.swift 591 Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/DefaultsValueModelLifecycleTests.swift 590 Sources/Panels/BrowserNavigationDelegate.swift diff --git a/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift b/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift index d9ca518400d2..8496908f18a4 100644 --- a/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift +++ b/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift @@ -532,15 +532,6 @@ final class AgentChatTranscriptService { } codexTranscriptResolutionTasks[key.sessionID] = nil codexTranscriptResolutionKeys[key.sessionID] = nil - // If a hook recorded the authoritative transcript path while this - // fallback scan was in flight, that recorded path wins. The key match - // above only proves no *newer scan* was scheduled; it does not see a - // registry transcript-path update, so guard against overwriting a now- - // valid recorded path with this (possibly stale) scan result. - if let record = registry.record(sessionID: key.sessionID), - resolver.recordedTranscriptPath(for: record) != nil { - return - } applyDirectCodexTranscriptResolution(resolved, sessionID: key.sessionID) } @@ -557,12 +548,18 @@ final class AgentChatTranscriptService { record.agentKind == .codex else { return } + // A hook may record the authoritative transcript path while an off-main + // fallback scan is still in flight. Once that recorded path exists on + // disk it wins: never overwrite it with this (possibly stale) scan + // result, and treat resolution as succeeded. Both `history(...)` and the + // scheduled wrapper funnel through here, so the guard lives on this + // shared path rather than being duplicated per caller. + if resolver.recordedTranscriptPath(for: record) != nil { + failedResolutions.remove(sessionID) + return + } guard let resolved else { - if resolver.recordedTranscriptPath(for: record) == nil { - failedResolutions.insert(sessionID) - } else { - failedResolutions.remove(sessionID) - } + failedResolutions.insert(sessionID) return } failedResolutions.remove(sessionID) From 0f1c066f11a48475c5211e57f9ee577707799cd2 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Fri, 3 Jul 2026 16:50:28 -0700 Subject: [PATCH 40/41] Address PR closeout policy findings --- CLI/cmux.swift | 13 +- .../CodexTranscriptIdentityMatcher.swift | 52 ----- .../Parsing/CodexTranscriptLocator.swift | 179 ----------------- .../Screen/KeyboardDismissTap.swift | 8 +- .../MobilePushCoordinator.swift | 4 +- .../TerminalHardwareKeyResolver.swift | 12 +- .../TerminalInputTextView.swift | 31 ++- .../AgentChatTranscriptResolver.swift | 49 +---- .../AgentChatTranscriptService.swift | 150 +------------- cmux.xcodeproj/project.pbxproj | 8 - .../AgentChatTranscriptResolverTests.swift | 189 ++---------------- 11 files changed, 62 insertions(+), 633 deletions(-) delete mode 100644 Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Parsing/CodexTranscriptIdentityMatcher.swift delete mode 100644 Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Parsing/CodexTranscriptLocator.swift diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 6d42f54891a4..f6de15a490c6 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -1,6 +1,5 @@ import Foundation import CMUXAgentLaunch -import CmuxAgentChat import CmuxFoundation import CmuxSettings import CoreFoundation @@ -25466,17 +25465,7 @@ struct CMUXCLI { } func findCodexTranscriptPath(sessionId: String, env: [String: String]) -> String? { - let normalizedSessionId = sessionId.trimmingCharacters(in: .whitespacesAndNewlines) - guard !normalizedSessionId.isEmpty else { return nil } - - let codexHome = normalizedHookValue(env["CODEX_HOME"]) ?? "~/.codex" - let sessionsURL = URL(fileURLWithPath: NSString(string: codexHome).expandingTildeInPath, isDirectory: true) - .appendingPathComponent("sessions", isDirectory: true) - return CodexTranscriptLocator().transcriptPath( - sessionID: normalizedSessionId, - sessionsURL: sessionsURL, - fileManager: .default - ) + nil } private static let codexMonitorLeaseDirectoryName = "codex-monitor-leases" diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Parsing/CodexTranscriptIdentityMatcher.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Parsing/CodexTranscriptIdentityMatcher.swift deleted file mode 100644 index d173c1f09b9b..000000000000 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Parsing/CodexTranscriptIdentityMatcher.swift +++ /dev/null @@ -1,52 +0,0 @@ -public import Foundation - -/// Matches Codex rollout JSONL files to a confirmed session id. -/// -/// Codex writes rollout files under `.codex/sessions` with a `session_meta` -/// first line. That metadata is the authority; metadata-free candidates fail -/// closed so a stale or unrelated rollout cannot be attributed to a pane. -public struct CodexTranscriptIdentityMatcher: Sendable { - /// Creates a Codex transcript identity matcher. - public init() {} - - /// Returns whether a rollout file belongs to the provided session id. - /// - /// - Parameters: - /// - url: The candidate rollout JSONL file URL. - /// - sessionID: The confirmed Codex session id for the pane/session. - /// - Returns: `true` only when the rollout is bound to `sessionID`. - public func transcript(at url: URL, matchesSessionID sessionID: String) -> Bool { - let normalizedSessionID = normalized(sessionID) - guard !normalizedSessionID.isEmpty else { return false } - return sessionMetaID(at: url)?.lowercased() == normalizedSessionID - } - - private func sessionMetaID(at url: URL) -> String? { - guard let handle = try? FileHandle(forReadingFrom: url) else { - return nil - } - defer { try? handle.close() } - guard let data = try? handle.read(upToCount: 64 * 1024), - !data.isEmpty else { - return nil - } - let lineData: Data - if let newline = data.firstIndex(of: 0x0A) { - lineData = Data(data[.. String { - sessionID.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - } -} diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Parsing/CodexTranscriptLocator.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Parsing/CodexTranscriptLocator.swift deleted file mode 100644 index 7fcc4e06693e..000000000000 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Parsing/CodexTranscriptLocator.swift +++ /dev/null @@ -1,179 +0,0 @@ -public import Foundation - -/// Locates Codex rollout transcripts using bounded, metadata-backed matching. -/// -/// The locator scans recent `~/.codex/sessions/YYYY/MM/DD` day directories, -/// plus the sessions root for legacy flat files, and verifies candidates with -/// ``CodexTranscriptIdentityMatcher`` before returning a path. -public struct CodexTranscriptLocator: Sendable { - /// Default number of past day directories included in the lookup window. - public static let defaultDayLookback = 14 - - /// Default number of future day directories included for timezone skew. - public static let defaultFutureDayLookahead = 1 - - /// Default cap on JSONL files opened during a single transcript lookup. - public static let defaultMaxCandidateFiles = 2_000 - - private let dayLookback: Int - private let futureDayLookahead: Int - private let maxCandidateFiles: Int - private let matcher: CodexTranscriptIdentityMatcher - - /// Creates a Codex transcript locator. - /// - /// - Parameters: - /// - dayLookback: Number of past day directories to scan. - /// - futureDayLookahead: Number of future day directories to scan. - /// - maxCandidateFiles: Maximum JSONL files opened per lookup. - /// - matcher: Metadata matcher used to verify candidate rollout files. - public init( - dayLookback: Int = Self.defaultDayLookback, - futureDayLookahead: Int = Self.defaultFutureDayLookahead, - maxCandidateFiles: Int = Self.defaultMaxCandidateFiles, - matcher: CodexTranscriptIdentityMatcher = CodexTranscriptIdentityMatcher() - ) { - self.dayLookback = max(0, dayLookback) - self.futureDayLookahead = max(0, futureDayLookahead) - self.maxCandidateFiles = max(1, maxCandidateFiles) - self.matcher = matcher - } - - /// Returns the matching Codex transcript path, when one is found. - /// - /// - Parameters: - /// - sessionID: The confirmed Codex session id. - /// - sessionsURL: The `.codex/sessions` directory. - /// - fileManager: File manager used for directory and file access. - /// - now: Reference date for the bounded day-directory window. - /// - Returns: Absolute path to the newest matching transcript, or `nil`. - public func transcriptPath( - sessionID: String, - sessionsURL: URL, - fileManager: FileManager = .default, - now: Date = Date() - ) -> String? { - transcriptURL( - sessionID: sessionID, - sessionsURL: sessionsURL, - fileManager: fileManager, - now: now - )?.path - } - - /// Returns the matching Codex transcript URL, when one is found. - /// - /// - Parameters: - /// - sessionID: The confirmed Codex session id. - /// - sessionsURL: The `.codex/sessions` directory. - /// - fileManager: File manager used for directory and file access. - /// - now: Reference date for the bounded day-directory window. - /// - Returns: URL to the newest matching transcript, or `nil`. - public func transcriptURL( - sessionID: String, - sessionsURL: URL, - fileManager: FileManager = .default, - now: Date = Date() - ) -> URL? { - let normalizedSessionID = sessionID.trimmingCharacters(in: .whitespacesAndNewlines) - guard !normalizedSessionID.isEmpty else { return nil } - - var scannedFileCount = 0 - for directoryURL in recentSessionDirectories( - sessionsURL: sessionsURL, - fileManager: fileManager, - now: now - ) { - guard !Task.isCancelled else { return nil } - guard scannedFileCount < maxCandidateFiles else { - return nil - } - let candidates = jsonlCandidates( - in: directoryURL, - fileManager: fileManager, - limit: maxCandidateFiles - scannedFileCount - ) - for candidate in candidates { - guard !Task.isCancelled else { return nil } - scannedFileCount += 1 - guard matcher.transcript(at: candidate, matchesSessionID: normalizedSessionID) else { - continue - } - return candidate - } - } - return nil - } - - private func recentSessionDirectories( - sessionsURL: URL, - fileManager: FileManager, - now: Date - ) -> [URL] { - var directories: [URL] = [] - var seenPaths = Set() - - func appendIfDirectory(_ url: URL) { - guard seenPaths.insert(url.path).inserted else { return } - var isDirectory: ObjCBool = false - guard fileManager.fileExists(atPath: url.path, isDirectory: &isDirectory), - isDirectory.boolValue else { - return - } - directories.append(url) - } - - var utcCalendar = Calendar(identifier: .gregorian) - utcCalendar.timeZone = TimeZone(secondsFromGMT: 0) ?? .current - let calendars = [Calendar.current, utcCalendar] - for dayOffset in stride(from: futureDayLookahead, through: -dayLookback, by: -1) { - for calendar in calendars { - guard let date = calendar.date(byAdding: .day, value: dayOffset, to: now) else { - continue - } - let components = calendar.dateComponents([.year, .month, .day], from: date) - guard let year = components.year, - let month = components.month, - let day = components.day else { - continue - } - appendIfDirectory( - sessionsURL - .appendingPathComponent(String(format: "%04d", year), isDirectory: true) - .appendingPathComponent(String(format: "%02d", month), isDirectory: true) - .appendingPathComponent(String(format: "%02d", day), isDirectory: true) - ) - } - } - - appendIfDirectory(sessionsURL) - return directories - } - - private func jsonlCandidates( - in directoryURL: URL, - fileManager: FileManager, - limit: Int - ) -> [URL] { - guard limit > 0 else { return [] } - guard let urls = try? fileManager.contentsOfDirectory( - at: directoryURL, - includingPropertiesForKeys: nil, - options: [.skipsHiddenFiles] - ) else { - return [] - } - var candidates: [URL] = [] - for url in urls where url.pathExtension == "jsonl" { - guard !Task.isCancelled else { return [] } - candidates.append(url) - } - candidates.sort { - $0.lastPathComponent > $1.lastPathComponent - } - if candidates.count > limit { - candidates.removeSubrange(limit...) - } - return candidates - } -} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/KeyboardDismissTap.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/KeyboardDismissTap.swift index 41ba3c624238..9552e71a89f6 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/KeyboardDismissTap.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/KeyboardDismissTap.swift @@ -33,10 +33,6 @@ struct KeyboardDismissTap: UIViewRepresentable { uiView.excludedRegion = excludedRegion } - static func dismantleUIView(_ uiView: TapInstallerView, coordinator: ()) { - uiView.uninstallRecognizer() - } - /// A non-interactive marker view that adds the recognizer to its window /// in `didMoveToWindow` — the only reliable "I'm in a window now" hook /// (relying on `updateUIView` timing missed the attach when no input @@ -80,6 +76,10 @@ struct KeyboardDismissTap: UIViewRepresentable { installedWindow = window } + deinit { + uninstallRecognizer() + } + func uninstallRecognizer() { installedWindow?.removeGestureRecognizer(recognizer) installedWindow = nil diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift index 19011a88f6b7..5a7993a25893 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift @@ -140,7 +140,7 @@ public final class MobilePushCoordinator { /// and persist the flag. Returns whether authorization was granted. @discardableResult public func enable() async -> Bool { - let priorStatus = await Self.currentNotificationAuthorizationStatus() + let priorStatus = await currentNotificationAuthorizationStatus() // Only an undetermined status produces a real OS prompt; gate the // "shown" event on it so a re-toggle of an already-decided status does // not log a phantom prompt. @@ -171,7 +171,7 @@ public final class MobilePushCoordinator { UIApplication.shared.unregisterForRemoteNotifications() } - private static func currentNotificationAuthorizationStatus() async -> UNAuthorizationStatus { + private func currentNotificationAuthorizationStatus() async -> UNAuthorizationStatus { let rawValue = await withCheckedContinuation { continuation in UNUserNotificationCenter.current().getNotificationSettings { settings in continuation.resume(returning: settings.authorizationStatus.rawValue) diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalHardwareKeyResolver.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalHardwareKeyResolver.swift index b8b8e13a1ee4..bca42155dec2 100644 --- a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalHardwareKeyResolver.swift +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalHardwareKeyResolver.swift @@ -5,8 +5,6 @@ import Foundation import UIKit struct TerminalHardwareKeyResolver { - private init() {} - private static let supportedModifierFlags: UIKeyModifierFlags = [.shift, .control, .alternate] private static let keyCommands: [TerminalHardwareKeyCommand] = { let navigation = [ @@ -33,8 +31,8 @@ struct TerminalHardwareKeyResolver { return navigation + controlInputs + shiftedControlInputs }() - @MainActor static func makeKeyCommands(target: Any, action: Selector) -> [UIKeyCommand] { - keyCommands.map { command in + @MainActor func makeKeyCommands(target: Any, action: Selector) -> [UIKeyCommand] { + Self.keyCommands.map { command in UIKeyCommand( input: command.input, modifierFlags: command.modifierFlags, @@ -45,7 +43,7 @@ struct TerminalHardwareKeyResolver { /// Maps a `UIKeyCommand.input*` string to a platform-neutral special key. /// Returns `nil` for ordinary character inputs. - private static func specialKey(for input: String) -> TerminalSpecialKey? { + private func specialKey(for input: String) -> TerminalSpecialKey? { switch input { case UIKeyCommand.inputUpArrow: return .upArrow case UIKeyCommand.inputDownArrow: return .downArrow @@ -63,7 +61,7 @@ struct TerminalHardwareKeyResolver { } /// Translates `UIKeyModifierFlags` into the kit's platform-neutral set. - private static func kitModifiers(_ flags: UIKeyModifierFlags) -> TerminalKeyModifier { + private func kitModifiers(_ flags: UIKeyModifierFlags) -> TerminalKeyModifier { var result: TerminalKeyModifier = [] if flags.contains(.shift) { result.insert(.shift) } if flags.contains(.control) { result.insert(.control) } @@ -71,7 +69,7 @@ struct TerminalHardwareKeyResolver { return result } - static func data(input: String, modifierFlags: UIKeyModifierFlags) -> Data? { + func data(input: String, modifierFlags: UIKeyModifierFlags) -> Data? { let modifiers = kitModifiers(modifierFlags) if let key = specialKey(for: input) { return TerminalKeyEncoder.encode(specialKey: key, modifiers: modifiers) diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift index a69fc9fcfec5..2d8ec6a5679c 100644 --- a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift @@ -75,6 +75,7 @@ final class TerminalInputTextView: UIView, UIKeyInput, UITextInput { private var alternateAccessorySticky: Bool { modifierState.isStickyOn(.alternate) } private var commandAccessorySticky: Bool { modifierState.isStickyOn(.command) } private var shiftAccessorySticky: Bool { modifierState.isStickyOn(.shift) } + private let hardwareKeyResolver = TerminalHardwareKeyResolver() /// The in-progress IME composition string, or `nil` when not composing. /// @@ -127,7 +128,7 @@ final class TerminalInputTextView: UIView, UIKeyInput, UITextInput { override var keyCommands: [UIKeyCommand]? { guard markedText == nil else { return nil } - var commands = TerminalHardwareKeyResolver.makeKeyCommands( + var commands = hardwareKeyResolver.makeKeyCommands( target: self, action: #selector(handleHardwareKeyCommand(_:)) ) @@ -696,7 +697,7 @@ final class TerminalInputTextView: UIView, UIKeyInput, UITextInput { if !alternateAccessorySticky { setAlternateAccessoryArmed(false) } - if let output = TerminalHardwareKeyResolver.data( + if let output = hardwareKeyResolver.data( input: UIKeyCommand.inputDelete, modifierFlags: [.alternate] ) { @@ -848,7 +849,7 @@ final class TerminalInputTextView: UIView, UIKeyInput, UITextInput { @discardableResult private func handleHardwareKeyInput(input: String, modifierFlags: UIKeyModifierFlags) -> Bool { - guard let data = TerminalHardwareKeyResolver.data(input: input, modifierFlags: modifierFlags) else { + guard let data = hardwareKeyResolver.data(input: input, modifierFlags: modifierFlags) else { return false } onEscapeSequence?(data) @@ -961,7 +962,7 @@ final class TerminalInputTextView: UIView, UIKeyInput, UITextInput { armed: Bool, sticky: Bool ) { - var config = Self.accessoryButtonConfiguration(armed: armed, sticky: sticky) + var config = accessoryButtonConfiguration(armed: armed, sticky: sticky) let symbolName: String? let title: String switch item { @@ -996,11 +997,9 @@ final class TerminalInputTextView: UIView, UIKeyInput, UITextInput { config.contentInsets = Self.accessoryButtonContentInsets button.configuration = config if let actionButton = button as? AccessoryActionButton { - // On iOS 26 the armed and sticky states share the same - // prominent-glass blue fill, so the double-tap *lock* is - // distinguished by a white capsule border drawn over the glass (see - // ``AccessoryActionButton/isStickyLocked``). Earlier OSes already - // render the locked white stroke through the background style. + // On iOS 26 the double-tap lock uses the prominent-glass fill plus + // the white capsule border from ``AccessoryActionButton/isStickyLocked``. + // Earlier OSes already render the locked stroke through the background style. if #available(iOS 26.0, *) { actionButton.isStickyLocked = sticky } else { @@ -1009,7 +1008,7 @@ final class TerminalInputTextView: UIView, UIKeyInput, UITextInput { } } - private static func accessoryButtonConfiguration(armed: Bool, sticky: Bool) -> UIButton.Configuration { + private func accessoryButtonConfiguration(armed: Bool, sticky: Bool) -> UIButton.Configuration { #if compiler(>=6.2) if #available(iOS 26.0, *) { var config: UIButton.Configuration = (armed || sticky) ? .prominentGlass() : .glass() @@ -1253,7 +1252,7 @@ final class TerminalInputTextView: UIView, UIKeyInput, UITextInput { private func controlSequence(for text: String) -> Data? { guard text.count == 1 else { return nil } - return TerminalHardwareKeyResolver.data(input: text, modifierFlags: [.control]) + return hardwareKeyResolver.data(input: text, modifierFlags: [.control]) } private func alternateSequence(for text: String) -> Data? { @@ -1266,12 +1265,12 @@ final class TerminalInputTextView: UIView, UIKeyInput, UITextInput { private func alternateAccessoryOutput(for action: TerminalInputAccessoryAction) -> Data? { switch action { case .leftArrow: - return TerminalHardwareKeyResolver.data( + return hardwareKeyResolver.data( input: UIKeyCommand.inputLeftArrow, modifierFlags: [.alternate] ) case .rightArrow: - return TerminalHardwareKeyResolver.data( + return hardwareKeyResolver.data( input: UIKeyCommand.inputRightArrow, modifierFlags: [.alternate] ) @@ -1296,12 +1295,12 @@ final class TerminalInputTextView: UIView, UIKeyInput, UITextInput { return Data([0x05]) // Ctrl+E - end of line case .upArrow: // Cmd+Up on Mac often scrolls; just send the raw arrow - return TerminalHardwareKeyResolver.data( + return hardwareKeyResolver.data( input: UIKeyCommand.inputUpArrow, modifierFlags: [] ) case .downArrow: - return TerminalHardwareKeyResolver.data( + return hardwareKeyResolver.data( input: UIKeyCommand.inputDownArrow, modifierFlags: [] ) @@ -1322,7 +1321,7 @@ final class TerminalInputTextView: UIView, UIKeyInput, UITextInput { private func shiftAccessoryOutput(for action: TerminalInputAccessoryAction) -> Data? { switch action { case .tab: - return TerminalHardwareKeyResolver.data(input: "\t", modifierFlags: [.shift]) + return hardwareKeyResolver.data(input: "\t", modifierFlags: [.shift]) default: return action.output } diff --git a/Sources/Mobile/AgentChat/AgentChatTranscriptResolver.swift b/Sources/Mobile/AgentChat/AgentChatTranscriptResolver.swift index 1c61d8b576de..8d6a12408146 100644 --- a/Sources/Mobile/AgentChat/AgentChatTranscriptResolver.swift +++ b/Sources/Mobile/AgentChat/AgentChatTranscriptResolver.swift @@ -3,47 +3,35 @@ import Foundation /// Resolves the transcript JSONL path for an agent session. /// -/// Preference order: the hook store's recorded `transcriptPath`, then the -/// agent-specific conventional location (claude: encoded-cwd project dir; -/// codex: rollout whose confirmed session id matches exactly). +/// Preference order: the hook store's recorded `transcriptPath`, then Claude's +/// conventional encoded-cwd project location. Codex transcript binding stays on +/// the hook-recorded path because scanning rollout directories can bind a pane +/// to the wrong conversation. struct AgentChatTranscriptResolver: Sendable { private let homeDirectory: URL /// Config-dir root for Claude (`$CLAUDE_CONFIG_DIR` or `~/.claude`). private let claudeConfigRoot: URL - /// Config-dir root for Codex (`$CODEX_HOME` or `~/.codex`). - private let codexConfigRoot: URL - private let now: @Sendable () -> Date /// Creates a resolver. /// - /// The derived-path fallbacks honor the agents' own config-dir env - /// overrides so a user who relocates their config (e.g. `CLAUDE_CONFIG_DIR` - /// or `CODEX_HOME`, including via a launcher/subrouter) still has transcripts - /// resolved. The PRIMARY source remains the hook-recorded absolute - /// `transcriptPath`, which already encodes any custom dir; this only fixes - /// the fallback used when no path was recorded (e.g. a codex session resumed - /// out-of-band, resolved by scanning the sessions dir). + /// The Claude derived-path fallback honors `CLAUDE_CONFIG_DIR` so a user who + /// relocates their config still has Claude transcripts resolved. Codex uses + /// only the hook-recorded absolute `transcriptPath`, which already encodes + /// any custom `CODEX_HOME`. /// /// - Parameters: /// - homeDirectory: Injectable home directory for tests. /// - environment: Injectable environment for tests; defaults to the /// process environment. Empty/whitespace override values are ignored. - /// - now: Injectable clock for bounded Codex rollout scans. init( homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser, - environment: [String: String] = ProcessInfo.processInfo.environment, - now: @escaping @Sendable () -> Date = { Date() } + environment: [String: String] = ProcessInfo.processInfo.environment ) { self.homeDirectory = homeDirectory - self.now = now self.claudeConfigRoot = Self.configRoot( override: environment["CLAUDE_CONFIG_DIR"], default: homeDirectory.appendingPathComponent(".claude", isDirectory: true) ) - self.codexConfigRoot = Self.configRoot( - override: environment["CODEX_HOME"], - default: homeDirectory.appendingPathComponent(".codex", isDirectory: true) - ) } /// Resolves a config-dir root from an env override, expanding a leading `~`, @@ -69,7 +57,7 @@ struct AgentChatTranscriptResolver: Sendable { case .claude: return claudeFallbackPath(record: record) case .codex: - return codexFallbackPath(sessionID: record.sessionID) + return nil case .other: return nil } @@ -97,21 +85,4 @@ struct AgentChatTranscriptResolver: Sendable { .path return fileManager.fileExists(atPath: path) ? path : nil } - - /// Codex rollout files are named `rollout--.jsonl` - /// 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 normalizedSessionID = sessionID.trimmingCharacters(in: .whitespacesAndNewlines) - guard !normalizedSessionID.isEmpty else { return nil } - let root = codexConfigRoot - .appendingPathComponent("sessions", isDirectory: true) - return CodexTranscriptLocator().transcriptPath( - sessionID: normalizedSessionID, - sessionsURL: root, - fileManager: fileManager, - now: now() - ) - } } diff --git a/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift b/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift index 8496908f18a4..a32c19608d23 100644 --- a/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift +++ b/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift @@ -21,8 +21,6 @@ final class AgentChatTranscriptService { /// explicit history request retries, so per-hook-event resolution /// failures don't rescan the filesystem during tool storms. private var failedResolutions: Set = [] - private var codexTranscriptResolutionTasks: [String: Task] = [:] - private var codexTranscriptResolutionKeys: [String: CodexTranscriptResolutionKey] = [:] /// Creates the service with a hook-store-backed registry. /// @@ -265,16 +263,10 @@ final class AgentChatTranscriptService { // A user opening the chat is the right moment to retry a previously // failed transcript resolution. failedResolutions.remove(sessionID) - if record.agentKind == .codex, - resolver.recordedTranscriptPath(for: record) == nil { - let resolved = await codexTranscriptPathOffMain(for: record) - applyDirectCodexTranscriptResolution(resolved, sessionID: sessionID) - } - guard let currentRecord = registry.record(sessionID: sessionID) else { return nil } - guard let tailer = ensureTailer(for: currentRecord) else { return nil } + guard let tailer = ensureTailer(for: record) else { return nil } await tailer.start() let page = await tailer.history(beforeSeq: beforeSeq, limit: limit) - if currentRecord.title == nil, let title = await tailer.title { + if record.title == nil, let title = await tailer.title { registry.update(sessionID: sessionID) { $0.title = title } } return page @@ -304,28 +296,13 @@ final class AgentChatTranscriptService { // MARK: - Internals - private typealias CodexTranscriptResolutionKey = ( - sessionID: String, - transcriptPath: String? - ) - @discardableResult private func ensureTailer(for record: AgentChatSessionRecord) -> AgentChatTranscriptTailer? { if let existing = tailers[record.sessionID] { return existing } guard !failedResolutions.contains(record.sessionID) else { return nil } - let resolvedPath: String? - if record.agentKind == .codex { - guard let recordedPath = resolver.recordedTranscriptPath(for: record) else { - scheduleCodexTranscriptResolution(for: record) - return nil - } - resolvedPath = recordedPath - } else { - resolvedPath = resolver.transcriptPath(for: record) - } - guard let path = resolvedPath else { + guard let path = resolver.transcriptPath(for: record) else { failedResolutions.insert(record.sessionID) #if DEBUG cmuxDebugLog( @@ -430,7 +407,6 @@ final class AgentChatTranscriptService { // discovery while paging an ended session) from churning it. Task { await tailer.stop() } } - clearCodexTranscriptResolution(sessionID: record.sessionID) } guard MobileHostService.hasEventSubscribers(topic: Self.eventTopic) else { return } if transcriptBecameAvailable, record.state != .ended { @@ -461,126 +437,6 @@ final class AgentChatTranscriptService { MobileHostService.emitEvent(topic: Self.eventTopic, payload: payload) } - private func scheduleCodexTranscriptResolution(for record: AgentChatSessionRecord) { - let key: CodexTranscriptResolutionKey = ( - sessionID: record.sessionID, - transcriptPath: record.transcriptPath - ) - if let currentKey = codexTranscriptResolutionKeys[record.sessionID], - currentKey.sessionID == key.sessionID, - currentKey.transcriptPath == key.transcriptPath { - return - } - - clearCodexTranscriptResolution(sessionID: record.sessionID) - codexTranscriptResolutionKeys[record.sessionID] = key - let scanTask = detachedCodexTranscriptResolutionTask(for: record) - codexTranscriptResolutionTasks[record.sessionID] = Task { @MainActor [ - weak self, - scanTask, - key - ] in - let resolved = await withTaskCancellationHandler { - await scanTask.value - } onCancel: { - scanTask.cancel() - } - guard !Task.isCancelled else { return } - self?.applyCodexTranscriptResolution(resolved, key: key) - } - } - - private func clearCodexTranscriptResolution(sessionID: String) { - codexTranscriptResolutionTasks[sessionID]?.cancel() - codexTranscriptResolutionTasks[sessionID] = nil - codexTranscriptResolutionKeys[sessionID] = nil - } - - private func codexTranscriptPathOffMain(for record: AgentChatSessionRecord) async -> String? { - let scanTask = detachedCodexTranscriptResolutionTask(for: record) - return await withTaskCancellationHandler { - await scanTask.value - } onCancel: { - scanTask.cancel() - } - } - - private func detachedCodexTranscriptResolutionTask( - for record: AgentChatSessionRecord - ) -> Task { - let resolver = self.resolver - #if compiler(>=6.2) - let resolveOperation: @concurrent @Sendable () async -> String? = { [resolver, record] in - resolver.transcriptPath(for: record) - } - #else - let resolveOperation: @Sendable () async -> String? = { [resolver, record] in - resolver.transcriptPath(for: record) - } - #endif - return Task.detached(priority: .utility, operation: resolveOperation) - } - - private func applyCodexTranscriptResolution( - _ resolved: String?, - key: CodexTranscriptResolutionKey - ) { - guard let currentKey = codexTranscriptResolutionKeys[key.sessionID], - currentKey.sessionID == key.sessionID, - currentKey.transcriptPath == key.transcriptPath else { - return - } - codexTranscriptResolutionTasks[key.sessionID] = nil - codexTranscriptResolutionKeys[key.sessionID] = nil - applyDirectCodexTranscriptResolution(resolved, sessionID: key.sessionID) - } - - /// Applies a resolved Codex transcript path to the registry. - /// - /// `internal` (not `private`) so a regression test can drive the - /// recorded-path guard directly: the race it protects against — a hook - /// recording the authoritative path while an off-main fallback scan is in - /// flight — cannot be reproduced deterministically through `history(...)` - /// alone. Both real callers (`history(...)` and the scheduled resolution) - /// reach here only after awaiting that scan. - func applyDirectCodexTranscriptResolution(_ resolved: String?, sessionID: String) { - guard let record = registry.record(sessionID: sessionID), - record.agentKind == .codex else { - return - } - // A hook may record the authoritative transcript path while an off-main - // fallback scan is still in flight. Once that recorded path exists on - // disk it wins: never overwrite it with this (possibly stale) scan - // result, and treat resolution as succeeded. Both `history(...)` and the - // scheduled wrapper funnel through here, so the guard lives on this - // shared path rather than being duplicated per caller. - if resolver.recordedTranscriptPath(for: record) != nil { - failedResolutions.remove(sessionID) - return - } - guard let resolved else { - failedResolutions.insert(sessionID) - return - } - failedResolutions.remove(sessionID) - if record.transcriptPath != resolved { - registry.update(sessionID: sessionID) { $0.transcriptPath = resolved } - } - } - - /// Stops every active tailer and cancels in-flight Codex resolution work so - /// their file watchers and background tasks do not outlive the service. The - /// live singleton normally runs for the app's lifetime; callers that own a - /// transient instance (e.g. tests) call this at teardown so kqueue file - /// watchers and detached tasks are released deterministically. - func shutdown() async { - for task in codexTranscriptResolutionTasks.values { task.cancel() } - codexTranscriptResolutionTasks.removeAll() - codexTranscriptResolutionKeys.removeAll() - for tailer in tailers.values { await tailer.stop() } - tailers.removeAll() - } - /// Encodes a wire value into the `[String: Any]` payload shape the /// event fan-out expects. func wirePayload(_ value: T) -> [String: Any]? { diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 221642006e7a..7c959c94ed0f 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -265,7 +265,6 @@ B9000034A1B2C3D4E5F60719 /* cmux_open.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000030A1B2C3D4E5F60719 /* cmux_open.swift */; }; A5001654 /* CmuxActionTrust.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001655 /* CmuxActionTrust.swift */; }; AC4A6E7C4A700001AC4A0001 /* CmuxAgentChat in Frameworks */ = {isa = PBXBuildFile; productRef = AC4A6E7C4A700001AC4A0002 /* CmuxAgentChat */; }; - C0D555000000000000000001 /* CmuxAgentChat in Frameworks */ = {isa = PBXBuildFile; productRef = C0D555000000000000000002 /* CmuxAgentChat */; }; A5B00003A1B2C3D4E5F60718 /* CMUXAgentLaunch in Frameworks */ = {isa = PBXBuildFile; productRef = A5B00002A1B2C3D4E5F60718 /* CMUXAgentLaunch */; }; A5B00004A1B2C3D4E5F60718 /* CMUXAgentLaunch in Frameworks */ = {isa = PBXBuildFile; productRef = A5B00002A1B2C3D4E5F60718 /* CMUXAgentLaunch */; }; A5B00005A1B2C3D4E5F60718 /* CMUXAgentLaunch in Frameworks */ = {isa = PBXBuildFile; productRef = A5B00002A1B2C3D4E5F60718 /* CMUXAgentLaunch */; }; @@ -2633,7 +2632,6 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - C0D555000000000000000001 /* CmuxAgentChat in Frameworks */, A5B00004A1B2C3D4E5F60718 /* CMUXAgentLaunch in Frameworks */, CF00F00000000000000000A4 /* CmuxFoundation in Frameworks */, CD0CFE5700000000CD0CFE57 /* CmuxSettings in Frameworks */, @@ -4080,7 +4078,6 @@ C5A1FED000000000000000C3 /* CmuxSwiftRender */, C5A1FED100000000000000D3 /* CmuxSwiftRenderUI */, C5A1FED200000000000000E3 /* CmuxSidebarInterpreterClient */, - C0D555000000000000000002 /* CmuxAgentChat */, CD0CFE5200000000CD0CFE52 /* CmuxSettings */, CF00F00000000000000000A2 /* CmuxFoundation */, ); @@ -6241,11 +6238,6 @@ package = AC4A6E7C4A700001AC4A0003 /* XCLocalSwiftPackageReference "CmuxAgentChat" */; productName = CmuxAgentChat; }; - C0D555000000000000000002 /* CmuxAgentChat */ = { - isa = XCSwiftPackageProductDependency; - package = AC4A6E7C4A700001AC4A0003 /* XCLocalSwiftPackageReference "CmuxAgentChat" */; - productName = CmuxAgentChat; - }; C0DE45000000000000000002 /* CmuxExtensionKit */ = { isa = XCSwiftPackageProductDependency; package = C0DE45000000000000000003 /* XCLocalSwiftPackageReference "CmuxExtensionKit" */; diff --git a/cmuxTests/AgentChatTranscriptResolverTests.swift b/cmuxTests/AgentChatTranscriptResolverTests.swift index 288ab769758d..3793699a43e3 100644 --- a/cmuxTests/AgentChatTranscriptResolverTests.swift +++ b/cmuxTests/AgentChatTranscriptResolverTests.swift @@ -9,184 +9,53 @@ import Testing #endif @Suite struct AgentChatTranscriptResolverTests { - @Test("Codex fallback ignores a rollout whose session_meta id belongs to another session") - func codexFallbackRequiresMatchingSessionMetaID() throws { + @Test("Codex sessions without hook-recorded transcript paths fail closed") + func codexWithoutRecordedTranscriptPathFailsClosed() throws { let fm = FileManager.default - let home = Self.makeTemporaryHome("agentchat-resolver-codex") + let home = Self.makeTemporaryHome("agentchat-resolver-codex-no-recorded-path") defer { try? fm.removeItem(at: home) } - let liveSessionID = "live-wedged-session-actual" - let liveRollout = try Self.writeCodexRollout( - home: home, - sessionID: liveSessionID - ) - let resolver = Self.codexResolver(home: home) - let wedgedRecord = Self.codexRecord(sessionID: "wedged-session") - - #expect(resolver.transcriptPath(for: wedgedRecord) == nil) - - let liveRecord = Self.codexRecord(sessionID: liveSessionID) - #expect(Self.resolvedPathsMatch(resolver.transcriptPath(for: liveRecord), liveRollout)) - } - - @Test("Codex fallback fails closed when session_meta id is unavailable") - func codexFallbackRequiresSessionMetaID() throws { - let fm = FileManager.default - let home = Self.makeTemporaryHome("agentchat-resolver-codex-missing-meta") - defer { try? fm.removeItem(at: home) } - - let sessionID = "live-wedged-session-actual" + let sessionID = "codex-session-with-rollout-on-disk" _ = try Self.writeCodexRollout( home: home, - sessionID: sessionID, - includeSessionMeta: false + sessionID: sessionID ) let resolver = Self.codexResolver(home: home) #expect(resolver.transcriptPath(for: Self.codexRecord(sessionID: sessionID)) == nil) } - @Test("Codex fallback trusts session_meta even when the filename lacks the session id") - func codexFallbackUsesSessionMetaAsAuthority() throws { + @Test("Codex uses the hook-recorded transcript path when it exists") + func codexUsesHookRecordedTranscriptPath() throws { let fm = FileManager.default - let home = Self.makeTemporaryHome("agentchat-resolver-codex-meta-authority") + let home = Self.makeTemporaryHome("agentchat-resolver-codex-recorded-path") defer { try? fm.removeItem(at: home) } - let sessionID = "confirmed-session-from-meta" + let sessionID = "codex-session-with-recorded-path" let rollout = try Self.writeCodexRollout( home: home, - sessionID: sessionID, - filenameSessionID: "opaque-rollout-name" + sessionID: "different-session-in-file" ) let resolver = Self.codexResolver(home: home) - - #expect(Self.resolvedPathsMatch(resolver.transcriptPath(for: Self.codexRecord(sessionID: sessionID)), rollout)) - } - - @Test("Codex fallback only scans bounded recent day directories") - func codexFallbackScansOnlyRecentDayDirectories() throws { - let fm = FileManager.default - let home = Self.makeTemporaryHome("agentchat-resolver-codex-recent") - defer { try? fm.removeItem(at: home) } - - let staleSessionID = "old-session-with-valid-meta" - _ = try Self.writeCodexRollout( - home: home, - sessionID: staleSessionID, - year: 2026, - month: 5, - day: 1 - ) - let recentSessionID = "recent-session-with-valid-meta" - let recentRollout = try Self.writeCodexRollout( - home: home, - sessionID: recentSessionID, - year: 2026, - month: 6, - day: 26 - ) - let resolver = Self.codexResolver(home: home) - - #expect(resolver.transcriptPath(for: Self.codexRecord(sessionID: staleSessionID)) == nil) - #expect(Self.resolvedPathsMatch(resolver.transcriptPath(for: Self.codexRecord(sessionID: recentSessionID)), recentRollout)) - } - - @MainActor - @Test("Codex history resolves fallback transcript for ended sessions") - func codexHistoryResolvesFallbackForEndedSession() async throws { - let fm = FileManager.default - let home = Self.makeTemporaryHome("agentchat-resolver-codex-ended") - defer { try? fm.removeItem(at: home) } - - let sessionID = "ended-session-with-valid-meta" - let rollout = try Self.writeCodexRollout(home: home, sessionID: sessionID) - let registry = AgentChatSessionRegistry() - _ = registry.noteHookEvent( - WorkstreamEvent( - sessionId: sessionID, - hookEventName: .sessionStart, - source: "codex", - receivedAt: Date(timeIntervalSince1970: 1) - ) - ) - registry.update(sessionID: sessionID) { record in - record.state = .ended - record.transcriptPath = nil - } - let service = AgentChatTranscriptService( - registry: registry, - resolver: Self.codexResolver(home: home) - ) - - let page = await service.history(sessionID: sessionID, beforeSeq: nil, limit: 20) - // Tear down the tailer/file-watcher started by `history(...)` so the - // kqueue watcher and its task don't outlive this test (a leaked watcher - // trips the app-host XCTest post-test memory checker). - await service.shutdown() - - #expect(page != nil) - #expect(Self.resolvedPathsMatch(service.sessionRecord(sessionID: sessionID)?.transcriptPath, rollout)) - } - - @MainActor - @Test("Direct Codex resolution keeps a hook-recorded path over a stale in-flight scan") - func codexDirectResolutionPreservesRecordedPathOverStaleScan() async throws { - let fm = FileManager.default - let home = Self.makeTemporaryHome("agentchat-resolver-codex-recorded-wins") - defer { try? fm.removeItem(at: home) } - - let sessionID = "resumed-session-with-two-rollouts" - // The authoritative rollout a hook recorded for this session. - let authoritative = try Self.writeCodexRollout(home: home, sessionID: sessionID, day: 26) - // An older rollout for the same session id that an in-flight fallback - // scan could still return after the hook already recorded `authoritative`. - let stale = try Self.writeCodexRollout( - home: home, + let record = Self.codexRecord( sessionID: sessionID, - filenameSessionID: "older-rollout-name", - day: 25 - ) - - let registry = AgentChatSessionRegistry() - _ = registry.noteHookEvent( - WorkstreamEvent( - sessionId: sessionID, - hookEventName: .sessionStart, - source: "codex", - receivedAt: Date(timeIntervalSince1970: 1) - ) - ) - // A hook recorded the authoritative transcript path for the session. - registry.update(sessionID: sessionID) { record in - record.state = .ended - record.transcriptPath = authoritative.path - } - - let service = AgentChatTranscriptService( - registry: registry, - resolver: Self.codexResolver(home: home) + transcriptPath: rollout.path ) - // A fallback scan that started before the hook fired now lands with a - // stale rollout for the same session id. It must not clobber the - // authoritative recorded path (the overwrite lived in - // `applyDirectCodexTranscriptResolution`, reachable from both - // `history(...)` and the scheduled resolution). - service.applyDirectCodexTranscriptResolution(stale.path, sessionID: sessionID) - await service.shutdown() - - #expect(Self.resolvedPathsMatch(service.sessionRecord(sessionID: sessionID)?.transcriptPath, authoritative)) - #expect(!Self.resolvedPathsMatch(service.sessionRecord(sessionID: sessionID)?.transcriptPath, stale)) + #expect(Self.resolvedPathsMatch(resolver.transcriptPath(for: record), rollout)) } - private static func codexRecord(sessionID: String) -> AgentChatSessionRecord { + private static func codexRecord( + sessionID: String, + transcriptPath: String? = nil + ) -> AgentChatSessionRecord { AgentChatSessionRecord( sessionID: sessionID, agentKind: .codex, workspaceID: nil, surfaceID: nil, workingDirectory: nil, - transcriptPath: nil, + transcriptPath: transcriptPath, state: .idle, lastActivityAt: Date(timeIntervalSince1970: 0), title: nil, @@ -197,8 +66,7 @@ import Testing private static func codexResolver(home: URL) -> AgentChatTranscriptResolver { AgentChatTranscriptResolver( homeDirectory: home, - environment: [:], - now: { Self.utcDate(year: 2026, month: 6, day: 26) } + environment: [:] ) } @@ -213,10 +81,9 @@ import Testing /// Canonicalizes an existing path with `realpath(3)`. `URL`'s /// `resolvingSymlinksInPath()` does NOT resolve `/var` -> `/private/var` on - /// some macOS versions, but `realpath` reliably resolves it the same way - /// `FileManager.contentsOfDirectory` (which the resolver uses) does — so the - /// resolver's `/private/var` output and a test's `/var` rollout path compare - /// equal regardless of which form `temporaryDirectory` reports. + /// some macOS versions, but `realpath` reliably resolves both spellings, so + /// a resolver-returned `/private/var` output and a test's `/var` rollout path + /// compare equal regardless of which form `temporaryDirectory` reports. private static func canonicalExistingPath(_ path: String) -> String { guard let resolved = realpath(path, nil) else { return path } defer { free(resolved) } @@ -261,16 +128,4 @@ import Testing return rolloutURL } - private static func utcDate(year: Int, month: Int, day: Int) -> Date { - var calendar = Calendar(identifier: .gregorian) - calendar.timeZone = TimeZone(secondsFromGMT: 0) ?? .current - return calendar.date(from: DateComponents( - calendar: calendar, - timeZone: calendar.timeZone, - year: year, - month: month, - day: day, - hour: 12 - )) ?? Date(timeIntervalSince1970: 0) - } } From 31ce781f7cb3b214115aa6fd632f2192bd850763 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Sat, 4 Jul 2026 16:20:38 -0700 Subject: [PATCH 41/41] Fix duplicate test source wiring --- cmux.xcodeproj/project.pbxproj | 1 - 1 file changed, 1 deletion(-) diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 16315f374122..f9dd0cb0b658 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -5372,7 +5372,6 @@ C7A51D100000000000000002 /* AgentChatSessionRegistryObservationReviewRegressionTests.swift in Sources */, C7A51D000000000000000002 /* AgentChatSessionRegistryObservationTests.swift in Sources */, A9E020000000000000000010 /* AgentChatTranscriptResolverTests.swift in Sources */, - A9E020000000000000000010 /* AgentChatTranscriptResolverTests.swift in Sources */, A9E020000000000000000005 /* AgentExecutableResolverTests.swift in Sources */, D72260010000000000000001 /* AgentHibernationPortalVisibilityTests.swift in Sources */, D36A00020000000000000001 /* AgentHibernationTests.swift in Sources */,