From 97c4673b68ce54ad1d6f79e80e1bf2215f00c908 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 23 Sep 2026 18:37:20 -0700 Subject: [PATCH 01/49] test: preserve Cloud replay state across hidden restore geometry --- cmux.xcodeproj/project.pbxproj | 8 ++ cmuxTests/CloudRestoreReplayFixture.swift | 98 +++++++++++++++++++++ cmuxTests/CloudRestoreReplayGridTests.swift | 63 +++++++++++++ 3 files changed, 169 insertions(+) create mode 100644 cmuxTests/CloudRestoreReplayFixture.swift create mode 100644 cmuxTests/CloudRestoreReplayGridTests.swift diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index de7396ca4c5d..218ddcbc7466 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -870,6 +870,8 @@ 595EC249E2E015ACE304F0EE /* CloudRemoteOperationStep.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64BF8B9B8328108560B40B7F /* CloudRemoteOperationStep.swift */; }; B12438020000000000000001 /* CloudRenameCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = B12438020000000000000002 /* CloudRenameCoordinator.swift */; }; C12575000000000000000050 /* CloudRenameOptimismTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12575000000000000000051 /* CloudRenameOptimismTests.swift */; }; + EFD5BEC316FCF70B7E4FDFBE /* CloudRestoreReplayFixture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 283DE1F547455A79CD6EC26D /* CloudRestoreReplayFixture.swift */; }; + 9A1E85136C5F9BDC2E66DC08 /* CloudRestoreReplayGridTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD277217AB02219F074EDADA /* CloudRestoreReplayGridTests.swift */; }; 46383D21CF46427F36EBEB7B /* CloudSidebarAcceptanceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8150EA26C949866059F3B6EA /* CloudSidebarAcceptanceTests.swift */; }; 5E3484F75CE048CC95018CF1 /* CloudSidebarAttentionLayoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DB4F197C29A4C03B61418A1 /* CloudSidebarAttentionLayoutTests.swift */; }; D075FB64A68C4CA0A4042528 /* CloudSidebarConsistencyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B27A4938294E4A109BC6D2B3 /* CloudSidebarConsistencyTests.swift */; }; @@ -5175,6 +5177,8 @@ 64BF8B9B8328108560B40B7F /* CloudRemoteOperationStep.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudRemoteOperationStep.swift"; sourceTree = ""; }; B12438020000000000000002 /* CloudRenameCoordinator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CloudRenameCoordinator.swift; sourceTree = ""; }; C12575000000000000000051 /* CloudRenameOptimismTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudRenameOptimismTests.swift; sourceTree = ""; }; + 283DE1F547455A79CD6EC26D /* CloudRestoreReplayFixture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudRestoreReplayFixture.swift"; sourceTree = ""; }; + DD277217AB02219F074EDADA /* CloudRestoreReplayGridTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudRestoreReplayGridTests.swift"; sourceTree = ""; }; 8150EA26C949866059F3B6EA /* CloudSidebarAcceptanceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudSidebarAcceptanceTests.swift"; sourceTree = ""; }; 5DB4F197C29A4C03B61418A1 /* CloudSidebarAttentionLayoutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudSidebarAttentionLayoutTests.swift"; sourceTree = ""; }; B27A4938294E4A109BC6D2B3 /* CloudSidebarConsistencyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudSidebarConsistencyTests.swift; sourceTree = ""; }; @@ -12667,6 +12671,8 @@ 511EAB11A81C099E4F5A8ECF /* DeferredAgentResumeAdmissionOwnerTests.swift */, C7E3FFB9B84F6DE45250D5D5 /* CodexSessionStartDeadTurnTests.swift */, 0B0073F9140EA86D5ADEE233 /* CodexStaleTurnRestoreIntentTests.swift */, + 283DE1F547455A79CD6EC26D /* CloudRestoreReplayFixture.swift */, + DD277217AB02219F074EDADA /* CloudRestoreReplayGridTests.swift */ ); path = cmuxTests; sourceTree = ""; @@ -16699,6 +16705,8 @@ AC38407F09E94DA38B94F168 /* CloudProviderRefreshCoordinatorTests.swift in Sources */, C12449010000000000000001 /* CloudRemoteColorOwnershipTests.swift in Sources */, C12575000000000000000050 /* CloudRenameOptimismTests.swift in Sources */, + EFD5BEC316FCF70B7E4FDFBE /* CloudRestoreReplayFixture.swift in Sources */, + 9A1E85136C5F9BDC2E66DC08 /* CloudRestoreReplayGridTests.swift in Sources */, 46383D21CF46427F36EBEB7B /* CloudSidebarAcceptanceTests.swift in Sources */, 5E3484F75CE048CC95018CF1 /* CloudSidebarAttentionLayoutTests.swift in Sources */, D075FB64A68C4CA0A4042528 /* CloudSidebarConsistencyTests.swift in Sources */, diff --git a/cmuxTests/CloudRestoreReplayFixture.swift b/cmuxTests/CloudRestoreReplayFixture.swift new file mode 100644 index 000000000000..0f83333b25b0 --- /dev/null +++ b/cmuxTests/CloudRestoreReplayFixture.swift @@ -0,0 +1,98 @@ +import AppKit +import CmuxTerminal +import Foundation +import GhosttyKit +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +/// Owns a real manual-I/O Ghostty terminal and its scripted daemon socket. +@MainActor +final class CloudRestoreReplayFixture { + let surface: TerminalSurface + private let window: NSWindow + private let socket: CloudManualMirrorSocketFixture + private let session: CloudTuiManualMirrorSession + + init() throws { + _ = NSApplication.shared + socket = try CloudManualMirrorSocketFixture() + session = CloudTuiManualMirrorSession( + machineID: "restore-grid-test", terminalID: "term_restore_grid", + remoteSurfaceID: 17, onNeedsReconnect: {} + ) + surface = TerminalSurface( + tabId: UUID(), context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: nil, ioMode: .manualMirror, manualInputHandler: { _ in } + ) + surface.setManualIONoReflow(false) + window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 800, height: 600), + styleMask: [.titled, .closable], backing: .buffered, defer: false + ) + window.isReleasedWhenClosed = false + let content = try #require(window.contentView) + let hosted = surface.hostedView + hosted.frame = content.bounds + content.addSubview(hosted) + content.layoutSubtreeIfNeeded() + hosted.setVisibleInUI(false) + session.bind(surface: surface) + } + + func setGrid(columns: Int, rows: Int) async throws { + try await waitUntil { self.surface.hasLiveSurface } + let runtime = try #require(surface.surface) + #expect(ghostty_surface_set_grid_size(runtime, UInt16(columns), UInt16(rows), nil)) + // The app-facing size cache leads Ghostty's IO-thread resize. Read + // actual terminal rows through the existing render-grid export. + try await waitUntil { + let frame = self.surface.mobileRenderGridFrame( + stateSeq: 0, scrollbackLines: 0, includeTheme: false + )?.frame + return frame?.columns == columns && frame?.rows == rows + } + } + + func attach(replay: Data) async throws { + session.reconnect(socketPath: socket.socketPath) + let identify = try #require(await socket.nextCommand(timeout: .seconds(5))) + #expect(identify.cmd == "identify") + socket.send(["id": identify.id, "ok": true, "data": ["capabilities": ["attach-initial-size"]]]) + let registration = try #require(await socket.nextCommand(timeout: .seconds(5))) + #expect(registration.cmd == "set-client-info") + socket.send(["id": registration.id, "ok": true, "data": [:]]) + let attach = try #require(await socket.nextCommand(timeout: .seconds(5))) + #expect(attach.cmd == "attach-surface") + #expect(!attach.hasInitialSize, "Hidden restores must not claim their temporary grid") + socket.send(["id": attach.id, "ok": true, "data": [:]]) + try await deliver(replay, event: "vt-state", marker: "STATUS_READY") + } + + func deliver(_ bytes: Data, event: String, marker: String) async throws { + socket.send([ + "event": event, "surface": 17, "cols": 80, "rows": 24, + "data": bytes.base64EncodedString() + ]) + try await waitUntil { self.surface.readText(region: .screen)?.contains(marker) == true } + } + + func close() { + session.stop() + socket.close() + surface.teardownSurface() + window.orderOut(nil) + } + + private func waitUntil(_ condition: @MainActor () -> Bool) async throws { + let deadline = ContinuousClock.now + .seconds(5) + while !condition(), ContinuousClock.now < deadline { + try await Task.sleep(for: .milliseconds(10)) + } + try #require(condition(), "Timed out waiting for the native terminal state") + } +} diff --git a/cmuxTests/CloudRestoreReplayGridTests.swift b/cmuxTests/CloudRestoreReplayGridTests.swift new file mode 100644 index 000000000000..661a007627bf --- /dev/null +++ b/cmuxTests/CloudRestoreReplayGridTests.swift @@ -0,0 +1,63 @@ +import AppKit +import CmuxTerminal +import Foundation +import GhosttyKit +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +/// A restored hidden mirror must interpret the replay at the daemon's grid. +/// The final pane can have that same grid, so the daemon owes no resize replay +/// to repair cursor drift introduced while the pane was still bootstrapping. +@MainActor +@Suite(.serialized, .timeLimit(.minutes(1))) +struct CloudRestoreReplayGridTests { + @Test + func hiddenRestoreMatchesFreshAttachAfterSameSizeReveal() async throws { + let fresh = try CloudRestoreReplayFixture() + let restored = try CloudRestoreReplayFixture() + defer { fresh.close(); restored.close() } + + try await fresh.setGrid(columns: 80, rows: 24) + try await restored.setGrid(columns: 99, rows: 35) + try await fresh.attach(replay: replay) + try await restored.attach(replay: replay) + + // The real pane settles to the daemon's existing 80x24 size. A + // same-size resize succeeds without a `resized` replacement event. + try await restored.setGrid(columns: 80, rows: 24) + let turn = Data("\r\n4\r\nSTATUS_AFTER".utf8) + try await fresh.deliver(turn, event: "output", marker: "STATUS_AFTER") + try await restored.deliver(turn, event: "output", marker: "STATUS_AFTER") + + let expected = try #require(fresh.surface.readText(region: .screen)) + let actual = try #require(restored.surface.readText(region: .screen)) + #expect(expected.contains("> Ask Codex to do anything\n4\nSTATUS_AFTER")) + #expect(actual == expected, "Restoration changed the cursor or screen before the next TUI diff") + } + + /// Recorded from the bundled cmux-tui's byte attach, after an 80x24 + /// primary-screen TUI writes a wrapped paragraph and a composer band. + /// Like a real snapshot, it restores the cursor by absolute coordinates + /// after reconstructing wrapped rows; following output is incremental. + private var replay: Data { + Data(( + "\u{1B}[?12hAttach specimen\r\n\r\n\r\n" + + String(repeating: "A", count: 80) + + String(repeating: "B", count: 80) + + String(repeating: "C", count: 20) + + "\r\n\r\n\r\n\r\n> 2+2\r\n\r\n\r\n4\r\n\r\n" + + "\u{1B}[0m\u{1B}[48;2;60;64;72m> Ask Codex to do anything" + + String(repeating: " ", count: 54) + + "\u{1B}[0m\r\n\r\nSTATUS_READY\u{1B}[0m\u{1B}[15;3H" + + "\u{1B}[3g\u{1B}[9G\u{1B}H\u{1B}[17G\u{1B}H\u{1B}[25G\u{1B}H" + + "\u{1B}[33G\u{1B}H\u{1B}[41G\u{1B}H\u{1B}[49G\u{1B}H" + + "\u{1B}[57G\u{1B}H\u{1B}[65G\u{1B}H\u{1B}[73G\u{1B}H" + + "\u{1B}[15;3H\u{1B}[15;3H" + ).utf8) + } +} From 5dccec0af7aad7c2e20304c195b83c4b73c9c9d9 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 23 Sep 2026 18:55:51 -0700 Subject: [PATCH 02/49] test: reproduce lost Cloud geometry eligibility after hidden restore --- cmuxTests/CloudRestoreReplayFixture.swift | 19 ++++- cmuxTests/CloudRestoreReplayGridTests.swift | 82 ++++++++++----------- 2 files changed, 53 insertions(+), 48 deletions(-) diff --git a/cmuxTests/CloudRestoreReplayFixture.swift b/cmuxTests/CloudRestoreReplayFixture.swift index 0f83333b25b0..44f6d5c9e5f1 100644 --- a/cmuxTests/CloudRestoreReplayFixture.swift +++ b/cmuxTests/CloudRestoreReplayFixture.swift @@ -13,20 +13,22 @@ import Testing /// Owns a real manual-I/O Ghostty terminal and its scripted daemon socket. @MainActor final class CloudRestoreReplayFixture { + private let workspace = TerminalPortalTestWorkspace() let surface: TerminalSurface private let window: NSWindow - private let socket: CloudManualMirrorSocketFixture + let socket: CloudManualMirrorSocketFixture private let session: CloudTuiManualMirrorSession - init() throws { + init(initiallyClaimsGeometry: Bool = true) throws { _ = NSApplication.shared socket = try CloudManualMirrorSocketFixture() session = CloudTuiManualMirrorSession( machineID: "restore-grid-test", terminalID: "term_restore_grid", - remoteSurfaceID: 17, onNeedsReconnect: {} + remoteSurfaceID: 17, initiallyClaimsGeometry: initiallyClaimsGeometry, + onNeedsReconnect: {} ) surface = TerminalSurface( - tabId: UUID(), context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + tabId: workspace.id, context: GHOSTTY_SURFACE_CONTEXT_SPLIT, configTemplate: nil, ioMode: .manualMirror, manualInputHandler: { _ in } ) surface.setManualIONoReflow(false) @@ -41,6 +43,7 @@ final class CloudRestoreReplayFixture { content.addSubview(hosted) content.layoutSubtreeIfNeeded() hosted.setVisibleInUI(false) + hosted.setActive(false) session.bind(surface: surface) } @@ -71,8 +74,15 @@ final class CloudRestoreReplayFixture { #expect(!attach.hasInitialSize, "Hidden restores must not claim their temporary grid") socket.send(["id": attach.id, "ok": true, "data": [:]]) try await deliver(replay, event: "vt-state", marker: "STATUS_READY") + try await waitUntil { self.session.phase == .attached } } + func setVisible(_ visible: Bool) { + surface.hostedView.setVisibleInUI(visible) + } + + func focus() { session.claimGeometry() } + func deliver(_ bytes: Data, event: String, marker: String) async throws { socket.send([ "event": event, "surface": 17, "cols": 80, "rows": 24, @@ -86,6 +96,7 @@ final class CloudRestoreReplayFixture { socket.close() surface.teardownSurface() window.orderOut(nil) + workspace.tearDown() } private func waitUntil(_ condition: @MainActor () -> Bool) async throws { diff --git a/cmuxTests/CloudRestoreReplayGridTests.swift b/cmuxTests/CloudRestoreReplayGridTests.swift index 661a007627bf..410065f03739 100644 --- a/cmuxTests/CloudRestoreReplayGridTests.swift +++ b/cmuxTests/CloudRestoreReplayGridTests.swift @@ -1,7 +1,4 @@ -import AppKit -import CmuxTerminal import Foundation -import GhosttyKit import Testing #if canImport(cmux_DEV) @@ -10,54 +7,51 @@ import Testing @testable import cmux #endif -/// A restored hidden mirror must interpret the replay at the daemon's grid. -/// The final pane can have that same grid, so the daemon owes no resize replay -/// to repair cursor drift introduced while the pane was still bootstrapping. +/// A hidden restore releases its old geometry contribution. Reveal must +/// reclaim the final pane size without waiting for focus or a keystroke. @MainActor @Suite(.serialized, .timeLimit(.minutes(1))) struct CloudRestoreReplayGridTests { @Test - func hiddenRestoreMatchesFreshAttachAfterSameSizeReveal() async throws { - let fresh = try CloudRestoreReplayFixture() - let restored = try CloudRestoreReplayFixture() - defer { fresh.close(); restored.close() } + func hiddenRestoreReclaimsGeometryWithoutInput() async throws { + let fixture = try CloudRestoreReplayFixture() + defer { fixture.close() } + try await fixture.setGrid(columns: 99, rows: 35) - try await fresh.setGrid(columns: 80, rows: 24) - try await restored.setGrid(columns: 99, rows: 35) - try await fresh.attach(replay: replay) - try await restored.attach(replay: replay) + // The pane takes its normal visible -> hidden restoration edge before + // the machine connects. No terminal focus or input follows the reveal. + fixture.setVisible(true) + fixture.setVisible(false) + try await fixture.attach(replay: Data("STATUS_READY".utf8)) + fixture.setVisible(true) - // The real pane settles to the daemon's existing 80x24 size. A - // same-size resize succeeds without a `resized` replacement event. - try await restored.setGrid(columns: 80, rows: 24) - let turn = Data("\r\n4\r\nSTATUS_AFTER".utf8) - try await fresh.deliver(turn, event: "output", marker: "STATUS_AFTER") - try await restored.deliver(turn, event: "output", marker: "STATUS_AFTER") - - let expected = try #require(fresh.surface.readText(region: .screen)) - let actual = try #require(restored.surface.readText(region: .screen)) - #expect(expected.contains("> Ask Codex to do anything\n4\nSTATUS_AFTER")) - #expect(actual == expected, "Restoration changed the cursor or screen before the next TUI diff") + let report = try #require(await fixture.socket.nextCommand(timeout: .seconds(5))) + #expect(report.cmd == "resize-surface") + fixture.socket.send(["id": report.id, "ok": true, "data": ["outcome": "passive"]]) + let claim = try #require( + await fixture.socket.nextCommand(timeout: .seconds(5)), + "A visible restored pane must claim its reported grid without requiring focus" + ) + #expect(claim.cmd == "set-client-sizing") + #expect(claim.surface == 17) } - /// Recorded from the bundled cmux-tui's byte attach, after an 80x24 - /// primary-screen TUI writes a wrapped paragraph and a composer band. - /// Like a real snapshot, it restores the cursor by absolute coordinates - /// after reconstructing wrapped rows; following output is incremental. - private var replay: Data { - Data(( - "\u{1B}[?12hAttach specimen\r\n\r\n\r\n" - + String(repeating: "A", count: 80) - + String(repeating: "B", count: 80) - + String(repeating: "C", count: 20) - + "\r\n\r\n\r\n\r\n> 2+2\r\n\r\n\r\n4\r\n\r\n" - + "\u{1B}[0m\u{1B}[48;2;60;64;72m> Ask Codex to do anything" - + String(repeating: " ", count: 54) - + "\u{1B}[0m\r\n\r\nSTATUS_READY\u{1B}[0m\u{1B}[15;3H" - + "\u{1B}[3g\u{1B}[9G\u{1B}H\u{1B}[17G\u{1B}H\u{1B}[25G\u{1B}H" - + "\u{1B}[33G\u{1B}H\u{1B}[41G\u{1B}H\u{1B}[49G\u{1B}H" - + "\u{1B}[57G\u{1B}H\u{1B}[65G\u{1B}H\u{1B}[73G\u{1B}H" - + "\u{1B}[15;3H\u{1B}[15;3H" - ).utf8) + @Test + func intentionallyPassiveMirrorStillWaitsForExplicitFocus() async throws { + let fixture = try CloudRestoreReplayFixture(initiallyClaimsGeometry: false) + defer { fixture.close() } + try await fixture.setGrid(columns: 99, rows: 35) + fixture.setVisible(true) + fixture.setVisible(false) + try await fixture.attach(replay: Data("STATUS_READY".utf8)) + fixture.setVisible(true) + + let report = try #require(await fixture.socket.nextCommand(timeout: .seconds(5))) + #expect(report.cmd == "resize-surface") + fixture.socket.send(["id": report.id, "ok": true, "data": ["outcome": "passive"]]) + #expect(await fixture.socket.nextCommand(timeout: .milliseconds(200)) == nil) + fixture.focus() + let claim = try #require(await fixture.socket.nextCommand(timeout: .seconds(5))) + #expect(claim.cmd == "set-client-sizing") } } From b7e457f06b80b53cefae6098fcc3136e583e89cc Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 23 Sep 2026 18:58:15 -0700 Subject: [PATCH 03/49] fix: retain Cloud geometry claim policy across hidden restores --- Sources/Cloud/CloudTuiManualMirrorSession.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Cloud/CloudTuiManualMirrorSession.swift b/Sources/Cloud/CloudTuiManualMirrorSession.swift index 432e32574a75..6dc2737745e1 100644 --- a/Sources/Cloud/CloudTuiManualMirrorSession.swift +++ b/Sources/Cloud/CloudTuiManualMirrorSession.swift @@ -47,6 +47,7 @@ final class CloudTuiManualMirrorSession { private var attachResponseReceived = false private var claimInFlight = false private var geometryClaimed = false + /// Claim policy survives hiding; visibility fences the current connection's claim. private var geometryClaimEligible: Bool /// Older daemons do not know `set-client-sizing`. In that case the /// recorded `resize-surface` report is still useful, so the scheduler can @@ -238,7 +239,6 @@ final class CloudTuiManualMirrorSession { } } geometryClaimed = false - geometryClaimEligible = false claimUnsupported = false claimInFlight = false discardPendingSizingRequests() From 92126bd19e2fa4160b199c55517b8b8f8dab4b28 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 23 Sep 2026 19:42:23 -0700 Subject: [PATCH 04/49] test: assert restored Cloud resize dimensions --- cmuxTests/CloudManualMirrorSocketFixture.swift | 4 ++++ cmuxTests/CloudRestoreReplayGridTests.swift | 6 ++++++ 2 files changed, 10 insertions(+) diff --git a/cmuxTests/CloudManualMirrorSocketFixture.swift b/cmuxTests/CloudManualMirrorSocketFixture.swift index c5a691f64501..28144a156372 100644 --- a/cmuxTests/CloudManualMirrorSocketFixture.swift +++ b/cmuxTests/CloudManualMirrorSocketFixture.swift @@ -10,6 +10,8 @@ struct CloudManualMirrorFixtureCommand: Sendable { let expectedTerminalID: String? let id: UInt64 let surface: UInt64? + let columns: Int? + let rows: Int? let capabilities: [String] let hasInitialSize: Bool let imageOperation: String? @@ -28,6 +30,8 @@ struct CloudManualMirrorFixtureCommand: Sendable { inputBytes = (object["bytes"] as? String).flatMap { Data(base64Encoded: $0) } id = (object["id"] as? NSNumber)?.uint64Value ?? 0 surface = (object["surface"] as? NSNumber)?.uint64Value + columns = object["cols"] as? Int + rows = object["rows"] as? Int capabilities = object["capabilities"] as? [String] ?? [] hasInitialSize = object["cols"] != nil || object["rows"] != nil imageOperation = object["op"] as? String diff --git a/cmuxTests/CloudRestoreReplayGridTests.swift b/cmuxTests/CloudRestoreReplayGridTests.swift index 410065f03739..ee33583b7f0f 100644 --- a/cmuxTests/CloudRestoreReplayGridTests.swift +++ b/cmuxTests/CloudRestoreReplayGridTests.swift @@ -27,6 +27,9 @@ struct CloudRestoreReplayGridTests { let report = try #require(await fixture.socket.nextCommand(timeout: .seconds(5))) #expect(report.cmd == "resize-surface") + #expect(report.surface == 17) + #expect(report.columns == 99) + #expect(report.rows == 35) fixture.socket.send(["id": report.id, "ok": true, "data": ["outcome": "passive"]]) let claim = try #require( await fixture.socket.nextCommand(timeout: .seconds(5)), @@ -48,6 +51,9 @@ struct CloudRestoreReplayGridTests { let report = try #require(await fixture.socket.nextCommand(timeout: .seconds(5))) #expect(report.cmd == "resize-surface") + #expect(report.surface == 17) + #expect(report.columns == 99) + #expect(report.rows == 35) fixture.socket.send(["id": report.id, "ok": true, "data": ["outcome": "passive"]]) #expect(await fixture.socket.nextCommand(timeout: .milliseconds(200)) == nil) fixture.focus() From 22a187bc6343b8b897d69281101eb4ad4f71d69a Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 23 Sep 2026 19:56:17 -0700 Subject: [PATCH 05/49] fix: import workspace liveness in Codex restore policy --- Sources/CodexTurnRestoreIntentPolicy.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/CodexTurnRestoreIntentPolicy.swift b/Sources/CodexTurnRestoreIntentPolicy.swift index 2c79917337f3..d5d1df19d9e9 100644 --- a/Sources/CodexTurnRestoreIntentPolicy.swift +++ b/Sources/CodexTurnRestoreIntentPolicy.swift @@ -1,3 +1,4 @@ +import CmuxWorkspaces import Foundation /// Preserves one Codex restore intent after a stale turn owner exits. From 827d859b61ce2031043b3ddf1a9f47351e877742 Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Wed, 23 Sep 2026 20:07:45 -0700 Subject: [PATCH 06/49] chore: sync Cloud restore test wiring --- cmux.xcodeproj/project.pbxproj | 1 + 1 file changed, 1 insertion(+) diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 218ddcbc7466..10f5fd073085 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -12673,6 +12673,7 @@ 0B0073F9140EA86D5ADEE233 /* CodexStaleTurnRestoreIntentTests.swift */, 283DE1F547455A79CD6EC26D /* CloudRestoreReplayFixture.swift */, DD277217AB02219F074EDADA /* CloudRestoreReplayGridTests.swift */ + DD277217AB02219F074EDADA /* CloudRestoreReplayGridTests.swift */, ); path = cmuxTests; sourceTree = ""; From 62532fc59ac0403ad9c386f174752976a1e3fe73 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 23 Sep 2026 21:12:20 -0700 Subject: [PATCH 07/49] fix: remove duplicate Cloud restore test registration --- cmux.xcodeproj/project.pbxproj | 1 - 1 file changed, 1 deletion(-) diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 10f5fd073085..16c58ba05b07 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -12672,7 +12672,6 @@ C7E3FFB9B84F6DE45250D5D5 /* CodexSessionStartDeadTurnTests.swift */, 0B0073F9140EA86D5ADEE233 /* CodexStaleTurnRestoreIntentTests.swift */, 283DE1F547455A79CD6EC26D /* CloudRestoreReplayFixture.swift */, - DD277217AB02219F074EDADA /* CloudRestoreReplayGridTests.swift */ DD277217AB02219F074EDADA /* CloudRestoreReplayGridTests.swift */, ); path = cmuxTests; From 8b8c6699ecc928cb1ac3281c6258bc5fd639f124 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 23 Sep 2026 21:12:22 -0700 Subject: [PATCH 08/49] test: fence passive Cloud claims with protocol traffic --- cmuxTests/CloudRestoreReplayFixture.swift | 12 ++++++++++++ cmuxTests/CloudRestoreReplayGridTests.swift | 9 ++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/cmuxTests/CloudRestoreReplayFixture.swift b/cmuxTests/CloudRestoreReplayFixture.swift index 44f6d5c9e5f1..408c9c51d440 100644 --- a/cmuxTests/CloudRestoreReplayFixture.swift +++ b/cmuxTests/CloudRestoreReplayFixture.swift @@ -83,6 +83,18 @@ final class CloudRestoreReplayFixture { func focus() { session.claimGeometry() } + func expectInputAfterPendingResponses(marker: String) async throws { + let bytes = Data(marker.utf8) + // The marker follows earlier replies on the incoming stream. Once + // parsed, input is queued behind every command those replies emitted. + // Seeing it next proves absence without a timed observation window. + try await deliver(bytes, event: "output", marker: marker) + session.inputRouter.send(.bytes(bytes)) + let input = try #require(await socket.nextCommand(timeout: .seconds(5))) + #expect(input.cmd == "send") + #expect(input.inputBytes == bytes) + } + func deliver(_ bytes: Data, event: String, marker: String) async throws { socket.send([ "event": event, "surface": 17, "cols": 80, "rows": 24, diff --git a/cmuxTests/CloudRestoreReplayGridTests.swift b/cmuxTests/CloudRestoreReplayGridTests.swift index ee33583b7f0f..a59822e7c589 100644 --- a/cmuxTests/CloudRestoreReplayGridTests.swift +++ b/cmuxTests/CloudRestoreReplayGridTests.swift @@ -55,9 +55,16 @@ struct CloudRestoreReplayGridTests { #expect(report.columns == 99) #expect(report.rows == 35) fixture.socket.send(["id": report.id, "ok": true, "data": ["outcome": "passive"]]) - #expect(await fixture.socket.nextCommand(timeout: .milliseconds(200)) == nil) + try await fixture.expectInputAfterPendingResponses(marker: "PASSIVE_REPORT_APPLIED") fixture.focus() let claim = try #require(await fixture.socket.nextCommand(timeout: .seconds(5))) #expect(claim.cmd == "set-client-sizing") + #expect(claim.surface == 17) + fixture.socket.send([ + "event": "resized", "surface": 17, "cols": 99, "rows": 35, + "replay": Data("CLAIMED_GRID".utf8).base64EncodedString() + ]) + fixture.socket.send(["id": claim.id, "ok": true, "data": [:]]) + try await fixture.expectInputAfterPendingResponses(marker: "CLAIM_APPLIED") } } From 53dd942e063001f002c5b6c6175c71965935d353 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Wed, 23 Sep 2026 20:41:15 -0700 Subject: [PATCH 09/49] Read matchingObservation after it is declared in the restore liveness check The Codex restore-intent early return landed on the same line as the effectiveRestorableAgent guard and reads matchingObservation, which is declared a few lines later, so main stopped compiling: "use of local variable 'matchingObservation' before its declaration". Move the check below the declaration. The intervening statements only compute values, so the early return behaves exactly as intended. Co-Authored-By: Claude Opus 5.5 (1M context) (cherry picked from commit 85287d3a08267cadec76edce0b13ceaf00b1cd59) --- Sources/Workspace.swift | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 2ac7a825f0d2..a68db0774b94 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -637,7 +637,7 @@ extension Workspace { processPresence: agentProcessPresence ) ?? false } - guard let effectiveRestorableAgent else { return nil }; if CodexTurnRestoreIntentPolicy.shouldPreserveAfterOwnerExit(snapshot: effectiveRestorableAgent, binding: resumeBinding, processLiveness: matchingObservation?.processLiveness) { return true } + guard let effectiveRestorableAgent else { return nil } let confirmedRuntimeProcessIdentities = confirmedRuntimeAgentProcessIdentities( for: effectiveRestorableAgent, panelId: panelId, @@ -647,6 +647,13 @@ extension Workspace { kind: effectiveRestorableAgent.kind.rawValue, sessionId: effectiveRestorableAgent.sessionId ) + if CodexTurnRestoreIntentPolicy.shouldPreserveAfterOwnerExit( + snapshot: effectiveRestorableAgent, + binding: resumeBinding, + processLiveness: matchingObservation?.processLiveness + ) { + return true + } return (matchingObservation?.processLiveness ?? .unknown) .wasRunning( fallingBackTo: panelShellActivityStates[panelId], From ff2694fe11b717159a77eb674e93015f6fcc0cc5 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 23 Sep 2026 21:16:07 -0700 Subject: [PATCH 10/49] refactor: isolate workspace title debug formatting --- Sources/Debug/WorkspaceTitleLogPreview.swift | 17 +++++++++++++++++ Sources/Workspace+TitleOwnership.swift | 12 ++++++------ Sources/Workspace.swift | 14 -------------- cmux.xcodeproj/project.pbxproj | 4 ++++ 4 files changed, 27 insertions(+), 20 deletions(-) create mode 100644 Sources/Debug/WorkspaceTitleLogPreview.swift diff --git a/Sources/Debug/WorkspaceTitleLogPreview.swift b/Sources/Debug/WorkspaceTitleLogPreview.swift new file mode 100644 index 000000000000..4bd3fc190a54 --- /dev/null +++ b/Sources/Debug/WorkspaceTitleLogPreview.swift @@ -0,0 +1,17 @@ +#if DEBUG +import Foundation + +/// Escapes and bounds workspace title and description values for debug logs. +func workspaceTitleLogPreview(_ text: String?, limit: Int = 120) -> String { + guard let text else { return "nil" } + let escaped = text + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\n", with: "\\n") + .replacingOccurrences(of: "\r", with: "\\r") + .replacingOccurrences(of: "\t", with: "\\t") + if escaped.count <= limit { + return escaped + } + return "\(escaped.prefix(limit))..." +} +#endif diff --git a/Sources/Workspace+TitleOwnership.swift b/Sources/Workspace+TitleOwnership.swift index 95a51d66b360..444ad89b573f 100644 --- a/Sources/Workspace+TitleOwnership.swift +++ b/Sources/Workspace+TitleOwnership.swift @@ -65,8 +65,8 @@ extension Workspace { #if DEBUG cmuxDebugLog( "workspace.title.applyProcess workspace=\(id.uuidString.prefix(5)) " + - "from=\"\(debugWorkspaceDescriptionPreview(self.title, limit: 80))\" " + - "to=\"\(debugWorkspaceDescriptionPreview(title, limit: 80))\"" + "from=\"\(workspaceTitleLogPreview(self.title, limit: 80))\" " + + "to=\"\(workspaceTitleLogPreview(title, limit: 80))\"" ) #endif applyAutomaticTitle(title) @@ -183,7 +183,7 @@ extension Workspace { "workspace.title.updatePanel workspace=\(id.uuidString.prefix(5)) " + "panel=\(panelId.uuidString.prefix(5)) panels=\(panels.count) custom=\(customTitle == nil ? 0 : 1) " + "panelChanged=\(didMutatePanelTitle ? 1 : 0) workspaceChanged=\(didMutateWorkspaceTitle ? 1 : 0) " + - "title=\"\(debugWorkspaceDescriptionPreview(trimmed, limit: 80))\"" + "title=\"\(workspaceTitleLogPreview(trimmed, limit: 80))\"" ) } #endif @@ -227,7 +227,7 @@ extension Workspace { #if DEBUG cmuxDebugLog( "workspace.customTitle.write workspace=\(id.uuidString.prefix(8)) " + - "source=\(source) title=\"\(debugWorkspaceDescriptionPreview(trimmed, limit: 40))\"" + "source=\(source) title=\"\(workspaceTitleLogPreview(trimmed, limit: 40))\"" ) #endif return true @@ -248,8 +248,8 @@ extension Workspace { "inputNewlines=\(inputNewlines) " + "normalizedLen=\((normalizedDescription as NSString?)?.length ?? 0) " + "normalizedNewlines=\(normalizedNewlines) " + - "input=\"\(debugWorkspaceDescriptionPreview(description))\" " + - "normalized=\"\(debugWorkspaceDescriptionPreview(normalizedDescription))\"" + "input=\"\(workspaceTitleLogPreview(description))\" " + + "normalized=\"\(workspaceTitleLogPreview(normalizedDescription))\"" ) #endif guard customDescription != normalizedDescription else { return } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index a68db0774b94..019dffbbdc94 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -40,20 +40,6 @@ private func externalBrowserFallbackURL( return initialRequest.url } -#if DEBUG -func debugWorkspaceDescriptionPreview(_ text: String?, limit: Int = 120) -> String { - guard let text else { return "nil" } - let escaped = text - .replacingOccurrences(of: "\\", with: "\\\\") - .replacingOccurrences(of: "\n", with: "\\n") - .replacingOccurrences(of: "\r", with: "\\r") - .replacingOccurrences(of: "\t", with: "\\t") - if escaped.count <= limit { - return escaped - } - return "\(escaped.prefix(limit))..." -} -#endif private final class WorkspacePendingTerminalInputObserver: @unchecked Sendable { var observer: NSObjectProtocol? diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 16c58ba05b07..4e1863d2363f 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -4212,6 +4212,7 @@ 6047C0DE6047C0DE60470001 /* WorkspaceTerminalTabWorkingDirectoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6047C0DE6047C0DE60470002 /* WorkspaceTerminalTabWorkingDirectoryTests.swift */; }; 634AA6233A5BBF781FE83E89 /* WorkspaceTerminalWorkingDirectoryFallbackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08850489C70ECFACA540C2F3 /* WorkspaceTerminalWorkingDirectoryFallbackTests.swift */; }; 5732A0075732A0075732A007 /* WorkspaceTitlebarModeLayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5732B0075732B0075732B007 /* WorkspaceTitlebarModeLayer.swift */; }; + 06C603B76C75AA537C60B26D /* WorkspaceTitleLogPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEFBCF5073161C7A4FC341DB /* WorkspaceTitleLogPreview.swift */; }; 440AC5D24915463C9670F510 /* WorkspaceTitleProvenanceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E241BE1D74A7459C8D9C16BC /* WorkspaceTitleProvenanceTests.swift */; }; 7D7D087CAACD917D8163F844 /* WorkspaceTodoFeature.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE3CB8DEEC6E12B7D04D9B92 /* WorkspaceTodoFeature.swift */; }; AB12CD340000000000000001 /* WorkspaceTodoNotificationRegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB12CD340000000000000002 /* WorkspaceTodoNotificationRegressionTests.swift */; }; @@ -8383,6 +8384,7 @@ 6047C0DE6047C0DE60470002 /* WorkspaceTerminalTabWorkingDirectoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceTerminalTabWorkingDirectoryTests.swift; sourceTree = ""; }; 08850489C70ECFACA540C2F3 /* WorkspaceTerminalWorkingDirectoryFallbackTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceTerminalWorkingDirectoryFallbackTests.swift; sourceTree = ""; }; 5732B0075732B0075732B007 /* WorkspaceTitlebarModeLayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceTitlebarModeLayer.swift; sourceTree = ""; }; + BEFBCF5073161C7A4FC341DB /* WorkspaceTitleLogPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Debug/WorkspaceTitleLogPreview.swift; sourceTree = ""; }; E241BE1D74A7459C8D9C16BC /* WorkspaceTitleProvenanceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceTitleProvenanceTests.swift; sourceTree = ""; }; EE3CB8DEEC6E12B7D04D9B92 /* WorkspaceTodoFeature.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceTodoFeature.swift; sourceTree = ""; }; AB12CD340000000000000002 /* WorkspaceTodoNotificationRegressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceTodoNotificationRegressionTests.swift; sourceTree = ""; }; @@ -9776,6 +9778,7 @@ B8835100000000000000000C /* TextBoxPastePreparationService.swift */, D79030000000000000000002 /* TextBoxSubmitSurfaceControlling.swift */, C0DE7B3B0000000000000002 /* TextBoxInputTextViewDebug.swift */, + BEFBCF5073161C7A4FC341DB /* WorkspaceTitleLogPreview.swift */, C0DE7B370000000000000002 /* TextBoxSubmitAction.swift */, C0DE7B400000000000000002 /* TextBoxSubmitActionCycling.swift */, C0DE7B3A0000000000000002 /* TextBoxSubmitActionKind.swift */, @@ -16095,6 +16098,7 @@ F87920000000000000000047 /* WorkspaceTerminalFontSizeWindowDockSlot.swift in Sources */, 99230001992300019923000A /* WorkspaceTerminalStartupRestoreCommitOwner.swift in Sources */, 5732A0075732A0075732A007 /* WorkspaceTitlebarModeLayer.swift in Sources */, + 06C603B76C75AA537C60B26D /* WorkspaceTitleLogPreview.swift in Sources */, 7D7D087CAACD917D8163F844 /* WorkspaceTodoFeature.swift in Sources */, 249969F7EAC03A99E20C753B /* WorkspaceTodoPanel.swift in Sources */, 78F17D618B657B6CF929E327 /* WorkspaceTodoPanelView.swift in Sources */, From 15ba7c412607833e022d760d6047bb1857cf678b Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 23 Sep 2026 21:43:58 -0700 Subject: [PATCH 11/49] fix: preserve restore intent before process probing --- Sources/Workspace.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 019dffbbdc94..bb66398bd826 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -624,11 +624,6 @@ extension Workspace { ) ?? false } guard let effectiveRestorableAgent else { return nil } - let confirmedRuntimeProcessIdentities = confirmedRuntimeAgentProcessIdentities( - for: effectiveRestorableAgent, - panelId: panelId, - currentProcessIdentity: currentAgentProcessIdentity - ) let matchingObservation = restorableAgentObservation?.matchingAgentSession( kind: effectiveRestorableAgent.kind.rawValue, sessionId: effectiveRestorableAgent.sessionId @@ -640,6 +635,11 @@ extension Workspace { ) { return true } + let confirmedRuntimeProcessIdentities = confirmedRuntimeAgentProcessIdentities( + for: effectiveRestorableAgent, + panelId: panelId, + currentProcessIdentity: currentAgentProcessIdentity + ) return (matchingObservation?.processLiveness ?? .unknown) .wasRunning( fallingBackTo: panelShellActivityStates[panelId], From 2a2e09259b58d1fc0abde37de4493ec77c4bfa4e Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 23 Sep 2026 21:43:58 -0700 Subject: [PATCH 12/49] test: reproduce stale Cloud replay cells after restore --- cmuxTests/CloudRestoreReplayFixture.swift | 5 +++++ cmuxTests/CloudRestoreReplayGridTests.swift | 13 +++++++++++++ 2 files changed, 18 insertions(+) diff --git a/cmuxTests/CloudRestoreReplayFixture.swift b/cmuxTests/CloudRestoreReplayFixture.swift index 408c9c51d440..b96cd8069ef1 100644 --- a/cmuxTests/CloudRestoreReplayFixture.swift +++ b/cmuxTests/CloudRestoreReplayFixture.swift @@ -83,6 +83,11 @@ final class CloudRestoreReplayFixture { func focus() { session.claimGeometry() } + func seedLocalOutput(_ bytes: Data, marker: String) async throws { + surface.processRemoteOutput(bytes) + try await waitUntil { self.surface.readText(region: .screen)?.contains(marker) == true } + } + func expectInputAfterPendingResponses(marker: String) async throws { let bytes = Data(marker.utf8) // The marker follows earlier replies on the incoming stream. Once diff --git a/cmuxTests/CloudRestoreReplayGridTests.swift b/cmuxTests/CloudRestoreReplayGridTests.swift index a59822e7c589..8600fcce4b94 100644 --- a/cmuxTests/CloudRestoreReplayGridTests.swift +++ b/cmuxTests/CloudRestoreReplayGridTests.swift @@ -12,6 +12,19 @@ import Testing @MainActor @Suite(.serialized, .timeLimit(.minutes(1))) struct CloudRestoreReplayGridTests { + @Test + func restoredSnapshotReplacesStaleLocalCells() async throws { + let fixture = try CloudRestoreReplayFixture() + defer { fixture.close() } + try await fixture.setGrid(columns: 80, rows: 24) + try await fixture.seedLocalOutput(Data("STALE_COMPOSER".utf8), marker: "STALE_COMPOSER") + try await fixture.attach(replay: Data("FRESH_COMPOSER".utf8)) + + let screen = try #require(fixture.surface.readText(region: .screen)) + #expect(screen.contains("FRESH_COMPOSER")) + #expect(!screen.contains("STALE_COMPOSER")) + } + @Test func hiddenRestoreReclaimsGeometryWithoutInput() async throws { let fixture = try CloudRestoreReplayFixture() From 1a11606e1fe147ceb353ec30bd4594c469501be4 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 23 Sep 2026 21:44:18 -0700 Subject: [PATCH 13/49] fix: reset Cloud VT state for replacement replays --- .../Cloud/CloudTuiManualMirrorSession.swift | 29 +++++++++---------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/Sources/Cloud/CloudTuiManualMirrorSession.swift b/Sources/Cloud/CloudTuiManualMirrorSession.swift index 6dc2737745e1..d6e32cf3732b 100644 --- a/Sources/Cloud/CloudTuiManualMirrorSession.swift +++ b/Sources/Cloud/CloudTuiManualMirrorSession.swift @@ -56,7 +56,6 @@ final class CloudTuiManualMirrorSession { /// Retained for diagnostics and for a future targeted detach. Closing the /// socket is still the cleanup fence for peers without lease support. private var remoteLease: String? - private var replayNeedsReset = false /// The last sidecar fed to the local surface; the next one is applied as a delta from it. private var appliedRemoteColors = CloudTuiRemoteColors() private var hasReceivedRemoteReplay = false @@ -283,9 +282,6 @@ final class CloudTuiManualMirrorSession { /// a reset screen. private func tearDownConnection() { watchdog.cancel() - if hasReceivedRemoteReplay { - replayNeedsReset = true - } connectTask?.cancel() connectTask = nil eventTask?.cancel() @@ -566,9 +562,11 @@ final class CloudTuiManualMirrorSession { inputRouter.updateSurfaceID(surfaceID) } guard surfaceID == remoteSurfaceID else { return } - applyReplay(bytes, reset: replayNeedsReset) + // A snapshot replaces the local VT state. Reset first so cells, + // cursor state, alternate-screen mode, and SGR from a prior + // restore cannot survive where the replacement is shorter. + applyReplay(bytes) applyColors(colors) - replayNeedsReset = false hasReceivedRemoteReplay = true diagnosticReplayReceived = true if phase == .attached { finishDiagnostics() } @@ -585,7 +583,7 @@ final class CloudTuiManualMirrorSession { // `resized` carries a replacement replay, not an incremental // output chunk. Resetting first prevents old rows/cursor state from // surviving a shrink or a reconnect. - applyReplay(bytes, reset: true) + applyReplay(bytes) applyColors(colors) hasReceivedRemoteReplay = true diagnosticReplayReceived = true @@ -621,14 +619,15 @@ final class CloudTuiManualMirrorSession { } } - private func applyReplay(_ bytes: Data, reset: Bool) { - if reset { - // Drop every remote color before the reset rather than trusting - // RIS to do it: the replay's own sidecar re-applies the authored - // set in full, so the pane ends in the same state either way. - applyColors(CloudTuiRemoteColors()) - surface?.processRemoteOutput(Self.replayReset) - } + private func applyReplay(_ bytes: Data) { + // A snapshot/resized frame replaces the local VT state. Drop every + // remote color before the reset rather than trusting RIS to do it: the + // replay's sidecar re-applies the authored set in full, so the pane + // ends in the same state either way. Without this fence, cells and + // cursor/SGR state from a previous restore survive wherever the new + // replay is shorter. + applyColors(CloudTuiRemoteColors()) + surface?.processRemoteOutput(Self.replayReset) surface?.processRemoteOutput(bytes) } From 68fb24d4ac244ca015b46406398faa1b9ab9ae6a Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 23 Sep 2026 22:01:52 -0700 Subject: [PATCH 14/49] test: keep replay reset marker in restore fixture --- cmuxTests/CloudRestoreReplayGridTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmuxTests/CloudRestoreReplayGridTests.swift b/cmuxTests/CloudRestoreReplayGridTests.swift index 8600fcce4b94..301b885bb5e9 100644 --- a/cmuxTests/CloudRestoreReplayGridTests.swift +++ b/cmuxTests/CloudRestoreReplayGridTests.swift @@ -18,7 +18,7 @@ struct CloudRestoreReplayGridTests { defer { fixture.close() } try await fixture.setGrid(columns: 80, rows: 24) try await fixture.seedLocalOutput(Data("STALE_COMPOSER".utf8), marker: "STALE_COMPOSER") - try await fixture.attach(replay: Data("FRESH_COMPOSER".utf8)) + try await fixture.attach(replay: Data("FRESH_COMPOSER STATUS_READY".utf8)) let screen = try #require(fixture.surface.readText(region: .screen)) #expect(screen.contains("FRESH_COMPOSER")) From 223eb6730ac935ea919b79ed64d3ba280ad2a9a5 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 23 Sep 2026 22:11:46 -0700 Subject: [PATCH 15/49] fix: restore debug title formatter linkage --- Sources/Workspace+TitleOwnership.swift | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Sources/Workspace+TitleOwnership.swift b/Sources/Workspace+TitleOwnership.swift index 444ad89b573f..95a51d66b360 100644 --- a/Sources/Workspace+TitleOwnership.swift +++ b/Sources/Workspace+TitleOwnership.swift @@ -65,8 +65,8 @@ extension Workspace { #if DEBUG cmuxDebugLog( "workspace.title.applyProcess workspace=\(id.uuidString.prefix(5)) " + - "from=\"\(workspaceTitleLogPreview(self.title, limit: 80))\" " + - "to=\"\(workspaceTitleLogPreview(title, limit: 80))\"" + "from=\"\(debugWorkspaceDescriptionPreview(self.title, limit: 80))\" " + + "to=\"\(debugWorkspaceDescriptionPreview(title, limit: 80))\"" ) #endif applyAutomaticTitle(title) @@ -183,7 +183,7 @@ extension Workspace { "workspace.title.updatePanel workspace=\(id.uuidString.prefix(5)) " + "panel=\(panelId.uuidString.prefix(5)) panels=\(panels.count) custom=\(customTitle == nil ? 0 : 1) " + "panelChanged=\(didMutatePanelTitle ? 1 : 0) workspaceChanged=\(didMutateWorkspaceTitle ? 1 : 0) " + - "title=\"\(workspaceTitleLogPreview(trimmed, limit: 80))\"" + "title=\"\(debugWorkspaceDescriptionPreview(trimmed, limit: 80))\"" ) } #endif @@ -227,7 +227,7 @@ extension Workspace { #if DEBUG cmuxDebugLog( "workspace.customTitle.write workspace=\(id.uuidString.prefix(8)) " + - "source=\(source) title=\"\(workspaceTitleLogPreview(trimmed, limit: 40))\"" + "source=\(source) title=\"\(debugWorkspaceDescriptionPreview(trimmed, limit: 40))\"" ) #endif return true @@ -248,8 +248,8 @@ extension Workspace { "inputNewlines=\(inputNewlines) " + "normalizedLen=\((normalizedDescription as NSString?)?.length ?? 0) " + "normalizedNewlines=\(normalizedNewlines) " + - "input=\"\(workspaceTitleLogPreview(description))\" " + - "normalized=\"\(workspaceTitleLogPreview(normalizedDescription))\"" + "input=\"\(debugWorkspaceDescriptionPreview(description))\" " + + "normalized=\"\(debugWorkspaceDescriptionPreview(normalizedDescription))\"" ) #endif guard customDescription != normalizedDescription else { return } From 48043267e8a4d5d88a6bc00cd48f5903230172b8 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 23 Sep 2026 22:40:28 -0700 Subject: [PATCH 16/49] fix: stop passive Cloud mirror claim oscillation --- Sources/Cloud/CloudTuiManualMirrorSession.swift | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/Sources/Cloud/CloudTuiManualMirrorSession.swift b/Sources/Cloud/CloudTuiManualMirrorSession.swift index d6e32cf3732b..2b103a3d1541 100644 --- a/Sources/Cloud/CloudTuiManualMirrorSession.swift +++ b/Sources/Cloud/CloudTuiManualMirrorSession.swift @@ -20,7 +20,6 @@ final class CloudTuiManualMirrorSession { private(set) var remoteSurfaceID: UInt64 let inputRouter: CloudTuiManualIOInputRouter let imagePaste = CloudImagePasteCoordinator() - private let operations: CloudOperationRecorder? private var diagnosticContext: CloudOperationContext? private var creationAttachment: CloudCreationAttachment? @@ -47,6 +46,7 @@ final class CloudTuiManualMirrorSession { private var attachResponseReceived = false private var claimInFlight = false private var geometryClaimed = false + private var geometryClaimBlockedByPeer = false /// Claim policy survives hiding; visibility fences the current connection's claim. private var geometryClaimEligible: Bool /// Older daemons do not know `set-client-sizing`. In that case the @@ -237,7 +237,7 @@ final class CloudTuiManualMirrorSession { ) } } - geometryClaimed = false + (geometryClaimed, geometryClaimBlockedByPeer) = (false, false) claimUnsupported = false claimInFlight = false discardPendingSizingRequests() @@ -293,7 +293,7 @@ final class CloudTuiManualMirrorSession { pendingRequests.removeAll(keepingCapacity: true) attachResponseReceived = false claimInFlight = false - geometryClaimed = false + (geometryClaimed, geometryClaimBlockedByPeer) = (false, false) claimUnsupported = false remoteLease = nil serverCapabilities.removeAll(keepingCapacity: true) @@ -419,7 +419,7 @@ final class CloudTuiManualMirrorSession { /// is also used by the composed explicit-input callback. func claimGeometry() { guard surface?.isRendererPortalVisible == true else { return } - geometryClaimEligible = true + (geometryClaimEligible, geometryClaimBlockedByPeer) = (true, false) // Another local projection may have claimed the shared terminal since // our last report. Treat an explicit focus/input edge as a fresh claim // opportunity instead of trusting the stale local flag. @@ -829,7 +829,7 @@ final class CloudTuiManualMirrorSession { // Another view owns this terminal's geometry. Keep the local // sample, but make the explicit claim the next operation so a // focused pane can take authority back deterministically. - geometryClaimed = false + (geometryClaimed, geometryClaimBlockedByPeer) = (false, true) claimUnsupported = false } // A report is useful even when it was passive. Hold the newest @@ -967,6 +967,7 @@ final class CloudTuiManualMirrorSession { surface?.isRendererPortalVisible == true, surface?.isNativeViewInRealWindow == true, geometryClaimEligible, + !geometryClaimBlockedByPeer, !geometryClaimed, !claimUnsupported, !claimInFlight, From 2fdaef3566bcc9b01f8921d2cb7c6c8449e3bde0 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 23 Sep 2026 23:01:14 -0700 Subject: [PATCH 17/49] fix: block rejected cross-client Cloud sizing claims --- Sources/Cloud/CloudTuiManualMirrorSession.swift | 2 +- cmuxTests/CloudRestoreReplayGridTests.swift | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/Cloud/CloudTuiManualMirrorSession.swift b/Sources/Cloud/CloudTuiManualMirrorSession.swift index 2b103a3d1541..2c80ff6d5490 100644 --- a/Sources/Cloud/CloudTuiManualMirrorSession.swift +++ b/Sources/Cloud/CloudTuiManualMirrorSession.swift @@ -825,7 +825,7 @@ final class CloudTuiManualMirrorSession { transitionToDisconnected(reason: .rejected("attachment superseded")) return } - if outcome == "passive" { + if outcome == "passive" || accepted == false { // Another view owns this terminal's geometry. Keep the local // sample, but make the explicit claim the next operation so a // focused pane can take authority back deterministically. diff --git a/cmuxTests/CloudRestoreReplayGridTests.swift b/cmuxTests/CloudRestoreReplayGridTests.swift index 301b885bb5e9..3320334d34c0 100644 --- a/cmuxTests/CloudRestoreReplayGridTests.swift +++ b/cmuxTests/CloudRestoreReplayGridTests.swift @@ -43,7 +43,7 @@ struct CloudRestoreReplayGridTests { #expect(report.surface == 17) #expect(report.columns == 99) #expect(report.rows == 35) - fixture.socket.send(["id": report.id, "ok": true, "data": ["outcome": "passive"]]) + fixture.socket.send(["id": report.id, "ok": true, "data": ["outcome": "passive", "accepted": false]]) let claim = try #require( await fixture.socket.nextCommand(timeout: .seconds(5)), "A visible restored pane must claim its reported grid without requiring focus" @@ -67,7 +67,7 @@ struct CloudRestoreReplayGridTests { #expect(report.surface == 17) #expect(report.columns == 99) #expect(report.rows == 35) - fixture.socket.send(["id": report.id, "ok": true, "data": ["outcome": "passive"]]) + fixture.socket.send(["id": report.id, "ok": true, "data": ["outcome": "passive", "accepted": false]]) try await fixture.expectInputAfterPendingResponses(marker: "PASSIVE_REPORT_APPLIED") fixture.focus() let claim = try #require(await fixture.socket.nextCommand(timeout: .seconds(5))) From f7a3bc7317113fb51c7fca722a1434eb62902e89 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 23 Sep 2026 23:05:53 -0700 Subject: [PATCH 18/49] fix: wait for Cloud resize outcome before claiming --- Sources/Cloud/CloudTuiManualMirrorSession.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Cloud/CloudTuiManualMirrorSession.swift b/Sources/Cloud/CloudTuiManualMirrorSession.swift index 2c80ff6d5490..9003327f1bb1 100644 --- a/Sources/Cloud/CloudTuiManualMirrorSession.swift +++ b/Sources/Cloud/CloudTuiManualMirrorSession.swift @@ -971,7 +971,7 @@ final class CloudTuiManualMirrorSession { !geometryClaimed, !claimUnsupported, !claimInFlight, - resizeScheduler.inFlight != nil || resizeScheduler.lastAcknowledged != nil, + resizeScheduler.lastAcknowledged != nil, let connection else { return } manualMirrorLogger.info("geometry terminal=\(self.terminalID, privacy: .private(mask: .hash)) decision=claim") claimInFlight = true From 5de372fda57c0573e7174305560db716439b05c9 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 23 Sep 2026 23:30:12 -0700 Subject: [PATCH 19/49] test: allow automatic restore claim response --- cmuxTests/CloudRestoreReplayGridTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmuxTests/CloudRestoreReplayGridTests.swift b/cmuxTests/CloudRestoreReplayGridTests.swift index 3320334d34c0..63ffd167b84e 100644 --- a/cmuxTests/CloudRestoreReplayGridTests.swift +++ b/cmuxTests/CloudRestoreReplayGridTests.swift @@ -43,7 +43,7 @@ struct CloudRestoreReplayGridTests { #expect(report.surface == 17) #expect(report.columns == 99) #expect(report.rows == 35) - fixture.socket.send(["id": report.id, "ok": true, "data": ["outcome": "passive", "accepted": false]]) + fixture.socket.send(["id": report.id, "ok": true, "data": [:]]) let claim = try #require( await fixture.socket.nextCommand(timeout: .seconds(5)), "A visible restored pane must claim its reported grid without requiring focus" From dce99b41947d0cde16fdf11f90d0876d5acd945a Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 24 Sep 2026 00:08:37 -0700 Subject: [PATCH 20/49] fix: distinguish passive Cloud lease outcomes --- Sources/Cloud/CloudTuiManualMirrorSession.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Cloud/CloudTuiManualMirrorSession.swift b/Sources/Cloud/CloudTuiManualMirrorSession.swift index 9003327f1bb1..85e22e28d528 100644 --- a/Sources/Cloud/CloudTuiManualMirrorSession.swift +++ b/Sources/Cloud/CloudTuiManualMirrorSession.swift @@ -825,7 +825,7 @@ final class CloudTuiManualMirrorSession { transitionToDisconnected(reason: .rejected("attachment superseded")) return } - if outcome == "passive" || accepted == false { + if outcome == "passive" { // Another view owns this terminal's geometry. Keep the local // sample, but make the explicit claim the next operation so a // focused pane can take authority back deterministically. From 509b9842c9c26aed80fcd16ddf1dcc246a6d6c8d Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 24 Sep 2026 10:02:54 -0700 Subject: [PATCH 21/49] fix: preserve explicit Cloud claim intent --- Sources/Cloud/CloudTuiManualMirrorSession.swift | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/Sources/Cloud/CloudTuiManualMirrorSession.swift b/Sources/Cloud/CloudTuiManualMirrorSession.swift index 85e22e28d528..ca293fcb3cb7 100644 --- a/Sources/Cloud/CloudTuiManualMirrorSession.swift +++ b/Sources/Cloud/CloudTuiManualMirrorSession.swift @@ -47,7 +47,7 @@ final class CloudTuiManualMirrorSession { private var claimInFlight = false private var geometryClaimed = false private var geometryClaimBlockedByPeer = false - /// Claim policy survives hiding; visibility fences the current connection's claim. + private var explicitGeometryClaimPending = false private var geometryClaimEligible: Bool /// Older daemons do not know `set-client-sizing`. In that case the /// recorded `resize-surface` report is still useful, so the scheduler can @@ -238,6 +238,7 @@ final class CloudTuiManualMirrorSession { } } (geometryClaimed, geometryClaimBlockedByPeer) = (false, false) + explicitGeometryClaimPending = false claimUnsupported = false claimInFlight = false discardPendingSizingRequests() @@ -294,6 +295,7 @@ final class CloudTuiManualMirrorSession { attachResponseReceived = false claimInFlight = false (geometryClaimed, geometryClaimBlockedByPeer) = (false, false) + explicitGeometryClaimPending = false claimUnsupported = false remoteLease = nil serverCapabilities.removeAll(keepingCapacity: true) @@ -419,10 +421,7 @@ final class CloudTuiManualMirrorSession { /// is also used by the composed explicit-input callback. func claimGeometry() { guard surface?.isRendererPortalVisible == true else { return } - (geometryClaimEligible, geometryClaimBlockedByPeer) = (true, false) - // Another local projection may have claimed the shared terminal since - // our last report. Treat an explicit focus/input edge as a fresh claim - // opportunity instead of trusting the stale local flag. + (geometryClaimEligible, geometryClaimBlockedByPeer, explicitGeometryClaimPending) = (true, false, true) geometryClaimed = false claimUnsupported = false sendClaimIfNeeded() @@ -826,10 +825,7 @@ final class CloudTuiManualMirrorSession { return } if outcome == "passive" { - // Another view owns this terminal's geometry. Keep the local - // sample, but make the explicit claim the next operation so a - // focused pane can take authority back deterministically. - (geometryClaimed, geometryClaimBlockedByPeer) = (false, true) + (geometryClaimed, geometryClaimBlockedByPeer) = (false, !explicitGeometryClaimPending) claimUnsupported = false } // A report is useful even when it was passive. Hold the newest @@ -898,6 +894,7 @@ final class CloudTuiManualMirrorSession { requestID: requestID ) ) + explicitGeometryClaimPending = false } private func sendAttach() { From 4c0fa871250789c2840ebe909589924b7c03bc16 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 24 Sep 2026 10:22:18 -0700 Subject: [PATCH 22/49] fix: demote Cloud mirror after cross-client rejection --- Sources/Cloud/CloudTuiManualMirrorSession.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Cloud/CloudTuiManualMirrorSession.swift b/Sources/Cloud/CloudTuiManualMirrorSession.swift index ca293fcb3cb7..2c3668cbc4de 100644 --- a/Sources/Cloud/CloudTuiManualMirrorSession.swift +++ b/Sources/Cloud/CloudTuiManualMirrorSession.swift @@ -824,7 +824,7 @@ final class CloudTuiManualMirrorSession { transitionToDisconnected(reason: .rejected("attachment superseded")) return } - if outcome == "passive" { + if outcome == "passive" || (accepted == false && geometryClaimed) { (geometryClaimed, geometryClaimBlockedByPeer) = (false, !explicitGeometryClaimPending) claimUnsupported = false } From 99329a1615b3ec33c6fd78b5d666a34407adac79 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 24 Sep 2026 10:37:02 -0700 Subject: [PATCH 23/49] fix: retain pending Cloud claims through handshake --- Sources/Cloud/CloudTuiManualMirrorSession.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Cloud/CloudTuiManualMirrorSession.swift b/Sources/Cloud/CloudTuiManualMirrorSession.swift index 2c3668cbc4de..180967b55214 100644 --- a/Sources/Cloud/CloudTuiManualMirrorSession.swift +++ b/Sources/Cloud/CloudTuiManualMirrorSession.swift @@ -845,6 +845,7 @@ final class CloudTuiManualMirrorSession { claimInFlight = false if ok, surface?.isRendererPortalVisible == true { geometryClaimed = true + explicitGeometryClaimPending = false claimUnsupported = false } else if Self.isUnsupportedClaimError(error) { // Keep compatibility with protocol-v5/v6 peers. Their @@ -894,7 +895,6 @@ final class CloudTuiManualMirrorSession { requestID: requestID ) ) - explicitGeometryClaimPending = false } private func sendAttach() { From 9bd3588deaa8d368f47c262127f775deec039e33 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 24 Sep 2026 11:00:03 -0700 Subject: [PATCH 24/49] fix: ignore no-op Cloud resize acknowledgements --- Sources/Cloud/CloudTuiManualMirrorSession.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Cloud/CloudTuiManualMirrorSession.swift b/Sources/Cloud/CloudTuiManualMirrorSession.swift index 180967b55214..a2ec225b6581 100644 --- a/Sources/Cloud/CloudTuiManualMirrorSession.swift +++ b/Sources/Cloud/CloudTuiManualMirrorSession.swift @@ -824,7 +824,7 @@ final class CloudTuiManualMirrorSession { transitionToDisconnected(reason: .rejected("attachment superseded")) return } - if outcome == "passive" || (accepted == false && geometryClaimed) { + if outcome == "passive" || (accepted == false && geometryClaimed && lastRemoteGrid != requestedGrid) { (geometryClaimed, geometryClaimBlockedByPeer) = (false, !explicitGeometryClaimPending) claimUnsupported = false } From 6a363494d08f017af4cd37c8b460d08e03119ae7 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 24 Sep 2026 11:28:25 -0700 Subject: [PATCH 25/49] fix: defer cross-client Cloud loss until replay state --- Sources/Cloud/CloudTuiManualMirrorSession.swift | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/Sources/Cloud/CloudTuiManualMirrorSession.swift b/Sources/Cloud/CloudTuiManualMirrorSession.swift index a2ec225b6581..9616639648c3 100644 --- a/Sources/Cloud/CloudTuiManualMirrorSession.swift +++ b/Sources/Cloud/CloudTuiManualMirrorSession.swift @@ -48,6 +48,7 @@ final class CloudTuiManualMirrorSession { private var geometryClaimed = false private var geometryClaimBlockedByPeer = false private var explicitGeometryClaimPending = false + private var geometryClaimLossPending = false private var geometryClaimEligible: Bool /// Older daemons do not know `set-client-sizing`. In that case the /// recorded `resize-surface` report is still useful, so the scheduler can @@ -237,7 +238,7 @@ final class CloudTuiManualMirrorSession { ) } } - (geometryClaimed, geometryClaimBlockedByPeer) = (false, false) + (geometryClaimed, geometryClaimBlockedByPeer, geometryClaimLossPending) = (false, false, false) explicitGeometryClaimPending = false claimUnsupported = false claimInFlight = false @@ -294,7 +295,7 @@ final class CloudTuiManualMirrorSession { pendingRequests.removeAll(keepingCapacity: true) attachResponseReceived = false claimInFlight = false - (geometryClaimed, geometryClaimBlockedByPeer) = (false, false) + (geometryClaimed, geometryClaimBlockedByPeer, geometryClaimLossPending) = (false, false, false) explicitGeometryClaimPending = false claimUnsupported = false remoteLease = nil @@ -421,7 +422,7 @@ final class CloudTuiManualMirrorSession { /// is also used by the composed explicit-input callback. func claimGeometry() { guard surface?.isRendererPortalVisible == true else { return } - (geometryClaimEligible, geometryClaimBlockedByPeer, explicitGeometryClaimPending) = (true, false, true) + (geometryClaimEligible, geometryClaimBlockedByPeer, explicitGeometryClaimPending, geometryClaimLossPending) = (true, false, true, false) geometryClaimed = false claimUnsupported = false sendClaimIfNeeded() @@ -572,6 +573,7 @@ final class CloudTuiManualMirrorSession { updatePresentationEpisode() synchronizePresentation() lastRemoteGrid = CloudTuiManualIOGrid(columns: columns, rows: rows) + if geometryClaimLossPending { geometryClaimLossPending = false; geometryClaimBlockedByPeer = !explicitGeometryClaimPending && lastRemoteGrid != resizeScheduler.desired } reconcileRemoteGrid() case let .output(surfaceID, bytes, colors): guard surfaceID == remoteSurfaceID else { return } @@ -590,6 +592,7 @@ final class CloudTuiManualMirrorSession { updatePresentationEpisode() synchronizePresentation() lastRemoteGrid = CloudTuiManualIOGrid(columns: columns, rows: rows) + if geometryClaimLossPending { geometryClaimLossPending = false; geometryClaimBlockedByPeer = !explicitGeometryClaimPending && lastRemoteGrid != resizeScheduler.desired } reconcileRemoteGrid() case let .colorsChanged(surfaceID, colors): guard surfaceID == remoteSurfaceID else { return } @@ -824,12 +827,12 @@ final class CloudTuiManualMirrorSession { transitionToDisconnected(reason: .rejected("attachment superseded")) return } - if outcome == "passive" || (accepted == false && geometryClaimed && lastRemoteGrid != requestedGrid) { - (geometryClaimed, geometryClaimBlockedByPeer) = (false, !explicitGeometryClaimPending) + if outcome == "passive" || (accepted == false && geometryClaimed && lastRemoteGrid != nil && lastRemoteGrid != requestedGrid) { + (geometryClaimed, geometryClaimBlockedByPeer, geometryClaimLossPending) = (false, !explicitGeometryClaimPending, false) claimUnsupported = false + } else if accepted == false && geometryClaimed && lastRemoteGrid == nil { + geometryClaimLossPending = true } - // A report is useful even when it was passive. Hold the newest - // sample while the explicit geometry claim is in flight. let next = resizeScheduler.acknowledge( requestedGrid, canSend: geometryClaimed || claimUnsupported From 9d90d5eb5119579344f5ff2776caa50ebed3e8ba Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 24 Sep 2026 12:03:09 -0700 Subject: [PATCH 26/49] fix: clear Cloud ownership after replay confirms peer loss --- Sources/Cloud/CloudTuiManualMirrorSession.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/Cloud/CloudTuiManualMirrorSession.swift b/Sources/Cloud/CloudTuiManualMirrorSession.swift index 9616639648c3..00eb621d9698 100644 --- a/Sources/Cloud/CloudTuiManualMirrorSession.swift +++ b/Sources/Cloud/CloudTuiManualMirrorSession.swift @@ -573,7 +573,7 @@ final class CloudTuiManualMirrorSession { updatePresentationEpisode() synchronizePresentation() lastRemoteGrid = CloudTuiManualIOGrid(columns: columns, rows: rows) - if geometryClaimLossPending { geometryClaimLossPending = false; geometryClaimBlockedByPeer = !explicitGeometryClaimPending && lastRemoteGrid != resizeScheduler.desired } + if geometryClaimLossPending { geometryClaimLossPending = false; geometryClaimBlockedByPeer = !explicitGeometryClaimPending && lastRemoteGrid != resizeScheduler.desired; geometryClaimed = geometryClaimed && !geometryClaimBlockedByPeer } reconcileRemoteGrid() case let .output(surfaceID, bytes, colors): guard surfaceID == remoteSurfaceID else { return } @@ -592,7 +592,7 @@ final class CloudTuiManualMirrorSession { updatePresentationEpisode() synchronizePresentation() lastRemoteGrid = CloudTuiManualIOGrid(columns: columns, rows: rows) - if geometryClaimLossPending { geometryClaimLossPending = false; geometryClaimBlockedByPeer = !explicitGeometryClaimPending && lastRemoteGrid != resizeScheduler.desired } + if geometryClaimLossPending { geometryClaimLossPending = false; geometryClaimBlockedByPeer = !explicitGeometryClaimPending && lastRemoteGrid != resizeScheduler.desired; geometryClaimed = geometryClaimed && !geometryClaimBlockedByPeer } reconcileRemoteGrid() case let .colorsChanged(surfaceID, colors): guard surfaceID == remoteSurfaceID else { return } From 82b100abf5b7c7f82132851e3ead45ece66e5c4e Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 24 Sep 2026 13:31:22 -0700 Subject: [PATCH 27/49] test: cover legacy applied resize responses --- cmuxTests/CloudRestoreReplayGridTests.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cmuxTests/CloudRestoreReplayGridTests.swift b/cmuxTests/CloudRestoreReplayGridTests.swift index 63ffd167b84e..b8adfc554956 100644 --- a/cmuxTests/CloudRestoreReplayGridTests.swift +++ b/cmuxTests/CloudRestoreReplayGridTests.swift @@ -43,7 +43,9 @@ struct CloudRestoreReplayGridTests { #expect(report.surface == 17) #expect(report.columns == 99) #expect(report.rows == 35) - fixture.socket.send(["id": report.id, "ok": true, "data": [:]]) + // Legacy resize-surface replies use accepted=false for an applied + // report; the first visible mirror must still promote itself. + fixture.socket.send(["id": report.id, "ok": true, "data": ["accepted": false, "outcome": "applied"]]) let claim = try #require( await fixture.socket.nextCommand(timeout: .seconds(5)), "A visible restored pane must claim its reported grid without requiring focus" From 58d41057d74b2cb71a078e31fae593a437e32b7b Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Thu, 24 Sep 2026 13:48:42 -0700 Subject: [PATCH 28/49] test: preserve authored Cloud colors across sidecar-free replay --- cmuxTests/CloudRestoreReplayFixture.swift | 8 ++++--- cmuxTests/CloudRestoreReplayGridTests.swift | 25 +++++++++++++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/cmuxTests/CloudRestoreReplayFixture.swift b/cmuxTests/CloudRestoreReplayFixture.swift index b96cd8069ef1..c66eed9c682a 100644 --- a/cmuxTests/CloudRestoreReplayFixture.swift +++ b/cmuxTests/CloudRestoreReplayFixture.swift @@ -100,11 +100,13 @@ final class CloudRestoreReplayFixture { #expect(input.inputBytes == bytes) } - func deliver(_ bytes: Data, event: String, marker: String) async throws { - socket.send([ + func deliver(_ bytes: Data, event: String, marker: String, colors: [String: Any]? = nil) async throws { + var payload: [String: Any] = [ "event": event, "surface": 17, "cols": 80, "rows": 24, "data": bytes.base64EncodedString() - ]) + ] + if let colors { payload["colors"] = colors } + socket.send(payload) try await waitUntil { self.surface.readText(region: .screen)?.contains(marker) == true } } diff --git a/cmuxTests/CloudRestoreReplayGridTests.swift b/cmuxTests/CloudRestoreReplayGridTests.swift index b8adfc554956..319f051cf28a 100644 --- a/cmuxTests/CloudRestoreReplayGridTests.swift +++ b/cmuxTests/CloudRestoreReplayGridTests.swift @@ -12,6 +12,31 @@ import Testing @MainActor @Suite(.serialized, .timeLimit(.minutes(1))) struct CloudRestoreReplayGridTests { + @Test(arguments: ["vt-state", "resized"]) + func replayWithoutSidecarPreservesAuthoredColors(event: String) async throws { + let fixture = try CloudRestoreReplayFixture() + defer { fixture.close() } + try await fixture.setGrid(columns: 80, rows: 24) + try await fixture.attach(replay: Data("STATUS_READY".utf8)) + try await fixture.deliver( + Data("AUTHORED".utf8), event: "vt-state", marker: "AUTHORED", + colors: ["overrides": ["fg": "#123456", "bg": "#654321"]] + ) + try await fixture.expectInputAfterPendingResponses(marker: "COLOR_APPLIED") + let before = try #require(fixture.surface.mobileRenderGridFrame( + stateSeq: 0, scrollbackLines: 0, includeTheme: true + )?.frame) + #expect(before.terminalForeground == "#123456") + #expect(before.terminalBackground == "#654321") + try await fixture.deliver(Data("REPLACEMENT".utf8), event: event, marker: "REPLACEMENT") + try await fixture.expectInputAfterPendingResponses(marker: "REPLAY_APPLIED") + let after = try #require(fixture.surface.mobileRenderGridFrame( + stateSeq: 0, scrollbackLines: 0, includeTheme: true + )?.frame) + #expect(after.terminalForeground == before.terminalForeground) + #expect(after.terminalBackground == before.terminalBackground) + } + @Test func restoredSnapshotReplacesStaleLocalCells() async throws { let fixture = try CloudRestoreReplayFixture() From dc9e8af58407cc927efe83063b4f3cdc33a28f73 Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Thu, 24 Sep 2026 13:49:01 -0700 Subject: [PATCH 29/49] fix: retain authored colors when Cloud replay omits sidecar --- .../Cloud/CloudTuiManualMirrorSession.swift | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/Sources/Cloud/CloudTuiManualMirrorSession.swift b/Sources/Cloud/CloudTuiManualMirrorSession.swift index 6276d078877e..ea0d8596dd84 100644 --- a/Sources/Cloud/CloudTuiManualMirrorSession.swift +++ b/Sources/Cloud/CloudTuiManualMirrorSession.swift @@ -567,8 +567,7 @@ final class CloudTuiManualMirrorSession { // A snapshot replaces the local VT state. Reset first so cells, // cursor state, alternate-screen mode, and SGR from a prior // restore cannot survive where the replacement is shorter. - applyReplay(bytes) - applyColors(colors) + applyReplay(bytes, colors: colors) hasReceivedRemoteReplay = true diagnosticReplayReceived = true if phase == .attached { finishDiagnostics() } @@ -586,8 +585,7 @@ final class CloudTuiManualMirrorSession { // `resized` carries a replacement replay, not an incremental // output chunk. Resetting first prevents old rows/cursor state from // surviving a shrink or a reconnect. - applyReplay(bytes) - applyColors(colors) + applyReplay(bytes, colors: colors) hasReceivedRemoteReplay = true diagnosticReplayReceived = true if phase == .attached { finishDiagnostics() } @@ -623,16 +621,14 @@ final class CloudTuiManualMirrorSession { } } - private func applyReplay(_ bytes: Data) { - // A snapshot/resized frame replaces the local VT state. Drop every - // remote color before the reset rather than trusting RIS to do it: the - // replay's sidecar re-applies the authored set in full, so the pane - // ends in the same state either way. Without this fence, cells and - // cursor/SGR state from a previous restore survive wherever the new - // replay is shorter. + private func applyReplay(_ bytes: Data, colors: CloudTuiRemoteColors?) { + // A sidecar replaces authored colors; an absent sidecar preserves them. + // Restore the authoritative set after resetting the replacement VT state. + let replayColors = colors ?? appliedRemoteColors applyColors(CloudTuiRemoteColors()) surface?.processRemoteOutput(Self.replayReset) surface?.processRemoteOutput(bytes) + applyColors(replayColors) } /// The replay is theme-portable: it carries no palette or default-color From bbc466cc098629525d4cdd967a5d59c9f402c08d Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 24 Sep 2026 14:17:08 -0700 Subject: [PATCH 30/49] test: cover Cloud replay grid alignment --- cmuxTests/CloudRestoreReplayFixture.swift | 23 +++++++++++++++++---- cmuxTests/CloudRestoreReplayGridTests.swift | 9 ++++++-- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/cmuxTests/CloudRestoreReplayFixture.swift b/cmuxTests/CloudRestoreReplayFixture.swift index b96cd8069ef1..7898f6fc0be8 100644 --- a/cmuxTests/CloudRestoreReplayFixture.swift +++ b/cmuxTests/CloudRestoreReplayFixture.swift @@ -61,7 +61,16 @@ final class CloudRestoreReplayFixture { } } - func attach(replay: Data) async throws { + func expectGrid(columns: Int, rows: Int) async throws { + try await waitUntil { + let frame = self.surface.mobileRenderGridFrame( + stateSeq: 0, scrollbackLines: 0, includeTheme: false + )?.frame + return frame?.columns == columns && frame?.rows == rows + } + } + + func attach(replay: Data, columns: Int = 80, rows: Int = 24) async throws { session.reconnect(socketPath: socket.socketPath) let identify = try #require(await socket.nextCommand(timeout: .seconds(5))) #expect(identify.cmd == "identify") @@ -73,7 +82,7 @@ final class CloudRestoreReplayFixture { #expect(attach.cmd == "attach-surface") #expect(!attach.hasInitialSize, "Hidden restores must not claim their temporary grid") socket.send(["id": attach.id, "ok": true, "data": [:]]) - try await deliver(replay, event: "vt-state", marker: "STATUS_READY") + try await deliver(replay, event: "vt-state", columns: columns, rows: rows, marker: "STATUS_READY") try await waitUntil { self.session.phase == .attached } } @@ -100,9 +109,15 @@ final class CloudRestoreReplayFixture { #expect(input.inputBytes == bytes) } - func deliver(_ bytes: Data, event: String, marker: String) async throws { + func deliver( + _ bytes: Data, + event: String, + columns: Int = 80, + rows: Int = 24, + marker: String + ) async throws { socket.send([ - "event": event, "surface": 17, "cols": 80, "rows": 24, + "event": event, "surface": 17, "cols": columns, "rows": rows, "data": bytes.base64EncodedString() ]) try await waitUntil { self.surface.readText(region: .screen)?.contains(marker) == true } diff --git a/cmuxTests/CloudRestoreReplayGridTests.swift b/cmuxTests/CloudRestoreReplayGridTests.swift index b8adfc554956..268c538ba460 100644 --- a/cmuxTests/CloudRestoreReplayGridTests.swift +++ b/cmuxTests/CloudRestoreReplayGridTests.swift @@ -16,13 +16,18 @@ struct CloudRestoreReplayGridTests { func restoredSnapshotReplacesStaleLocalCells() async throws { let fixture = try CloudRestoreReplayFixture() defer { fixture.close() } - try await fixture.setGrid(columns: 80, rows: 24) + try await fixture.setGrid(columns: 107, rows: 25) try await fixture.seedLocalOutput(Data("STALE_COMPOSER".utf8), marker: "STALE_COMPOSER") - try await fixture.attach(replay: Data("FRESH_COMPOSER STATUS_READY".utf8)) + try await fixture.attach( + replay: Data("FRESH_COMPOSER STATUS_READY".utf8), + columns: 105, + rows: 25 + ) let screen = try #require(fixture.surface.readText(region: .screen)) #expect(screen.contains("FRESH_COMPOSER")) #expect(!screen.contains("STALE_COMPOSER")) + try await fixture.expectGrid(columns: 105, rows: 25) } @Test From 8ed24b2f89e7cb236e2bbff87c5a0632d0307791 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 24 Sep 2026 14:18:11 -0700 Subject: [PATCH 31/49] fix: align Cloud replay with remote grid --- .../TerminalSurface+ReplaySizing.swift | 24 +++++++++++++++++++ .../Cloud/CloudTuiManualMirrorSession.swift | 4 ++-- 2 files changed, 26 insertions(+), 2 deletions(-) create mode 100644 Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+ReplaySizing.swift diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+ReplaySizing.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+ReplaySizing.swift new file mode 100644 index 000000000000..99ebe10547f2 --- /dev/null +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+ReplaySizing.swift @@ -0,0 +1,24 @@ +import GhosttyKit + +extension TerminalSurface { + /// Aligns a manual-I/O surface with the grid authored by an incoming + /// replacement replay before its VT bytes are parsed. The assigned-grid pin + /// keeps later AppKit layout passes on the same geometry; the authoritative + /// grid call closes the hidden-restore gap where no pixel sizing pass has + /// established `lastUncappedPixelWidth` yet. + @MainActor + @discardableResult + public func prepareForRemoteReplay(columns: Int, rows: Int) -> Bool { + guard ioMode.usesManualIO, + (2...Int(UInt16.max)).contains(columns), + (2...Int(UInt16.max)).contains(rows) else { return false } + setAssignedGrid(columns: columns, rows: rows) + guard let runtime = liveSurfaceForGhosttyAccess(reason: "remoteReplayGrid") else { return false } + return ghostty_surface_set_grid_size( + runtime, + UInt16(columns), + UInt16(rows), + nil + ) + } +} diff --git a/Sources/Cloud/CloudTuiManualMirrorSession.swift b/Sources/Cloud/CloudTuiManualMirrorSession.swift index 6276d078877e..1c95ecf8f943 100644 --- a/Sources/Cloud/CloudTuiManualMirrorSession.swift +++ b/Sources/Cloud/CloudTuiManualMirrorSession.swift @@ -16,7 +16,6 @@ private let manualMirrorLogger = Logger(subsystem: "com.cmuxterm.app", category: @MainActor final class CloudTuiManualMirrorSession { private static let replayReset = Data([0x1B, 0x63, 0x1B, 0x5B, 0x33, 0x4A]) - let machineID: String let terminalID: String private(set) var remoteSurfaceID: UInt64 @@ -564,6 +563,7 @@ final class CloudTuiManualMirrorSession { inputRouter.updateSurfaceID(surfaceID) } guard surfaceID == remoteSurfaceID else { return } + surface?.prepareForRemoteReplay(columns: columns, rows: rows) // A snapshot replaces the local VT state. Reset first so cells, // cursor state, alternate-screen mode, and SGR from a prior // restore cannot survive where the replacement is shorter. @@ -583,6 +583,7 @@ final class CloudTuiManualMirrorSession { applyColors(colors) case let .resized(surfaceID, columns, rows, bytes, colors): guard surfaceID == remoteSurfaceID else { return } + surface?.prepareForRemoteReplay(columns: columns, rows: rows) // `resized` carries a replacement replay, not an incremental // output chunk. Resetting first prevents old rows/cursor state from // surviving a shrink or a reconnect. @@ -622,7 +623,6 @@ final class CloudTuiManualMirrorSession { break } } - private func applyReplay(_ bytes: Data) { // A snapshot/resized frame replaces the local VT state. Drop every // remote color before the reset rather than trusting RIS to do it: the From e568942ba0329908803ab0b14242cd1e83b41e28 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 24 Sep 2026 14:29:35 -0700 Subject: [PATCH 32/49] fix: keep Cloud replay geometry transient --- .../Surface/TerminalSurface+ReplaySizing.swift | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+ReplaySizing.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+ReplaySizing.swift index 99ebe10547f2..2c22c551673a 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+ReplaySizing.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+ReplaySizing.swift @@ -2,17 +2,15 @@ import GhosttyKit extension TerminalSurface { /// Aligns a manual-I/O surface with the grid authored by an incoming - /// replacement replay before its VT bytes are parsed. The assigned-grid pin - /// keeps later AppKit layout passes on the same geometry; the authoritative - /// grid call closes the hidden-restore gap where no pixel sizing pass has - /// established `lastUncappedPixelWidth` yet. + /// replacement replay before its VT bytes are parsed. This is a transient + /// parser/renderer fence: the next real pane-geometry pass can return to + /// the local desired grid and negotiate that size with the remote PTY. @MainActor @discardableResult public func prepareForRemoteReplay(columns: Int, rows: Int) -> Bool { guard ioMode.usesManualIO, (2...Int(UInt16.max)).contains(columns), (2...Int(UInt16.max)).contains(rows) else { return false } - setAssignedGrid(columns: columns, rows: rows) guard let runtime = liveSurfaceForGhosttyAccess(reason: "remoteReplayGrid") else { return false } return ghostty_surface_set_grid_size( runtime, From 1e6f928426a469886c70bedd8fcd6f536a26e17b Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 24 Sep 2026 14:54:50 -0700 Subject: [PATCH 33/49] fix: preserve Cloud sizing intent across replay --- ...dTuiManualMirrorSession+ReplaySizing.swift | 25 +++++++++++++++++++ .../Cloud/CloudTuiManualMirrorSession.swift | 6 ++++- 2 files changed, 30 insertions(+), 1 deletion(-) create mode 100644 Sources/Cloud/CloudTuiManualMirrorSession+ReplaySizing.swift diff --git a/Sources/Cloud/CloudTuiManualMirrorSession+ReplaySizing.swift b/Sources/Cloud/CloudTuiManualMirrorSession+ReplaySizing.swift new file mode 100644 index 000000000000..8dadfe23408a --- /dev/null +++ b/Sources/Cloud/CloudTuiManualMirrorSession+ReplaySizing.swift @@ -0,0 +1,25 @@ +import CmuxTerminal + +extension CloudTuiManualMirrorSession { + /// Keep the local pane's last real geometry while a hidden replay briefly + /// aligns Ghostty to the remote snapshot grid. + func rememberReplaySizingSampleIfHidden() { + guard pendingReplaySizingSample == nil, + surface?.isRendererPortalVisible != true, + let sample = surface?.rawSizingSample(), + sample.columns > 1, + sample.rows > 1 else { return } + pendingReplaySizingSample = sample + } + + /// Re-publish the saved local geometry on reveal; the current Ghostty grid + /// may still describe the remote replay and fail pane-pixel validation. + @discardableResult + func applyPendingReplaySizingSampleIfVisible() -> Bool { + guard surface?.isRendererPortalVisible == true, + let sample = pendingReplaySizingSample else { return false } + pendingReplaySizingSample = nil + apply(size: sample, validatePanePixels: false) + return true + } +} diff --git a/Sources/Cloud/CloudTuiManualMirrorSession.swift b/Sources/Cloud/CloudTuiManualMirrorSession.swift index 6c0919acf776..23bc507b2f05 100644 --- a/Sources/Cloud/CloudTuiManualMirrorSession.swift +++ b/Sources/Cloud/CloudTuiManualMirrorSession.swift @@ -29,7 +29,7 @@ final class CloudTuiManualMirrorSession { private var diagnosticDeadline: Task? private(set) var diagnosticFailure: CloudDiagnosticFailure? private var diagnosticReference: String? - private weak var surface: TerminalSurface? + weak var surface: TerminalSurface? private let onNeedsReconnect: @MainActor () -> Void private let commandBuilder: CloudTuiManualIOCommand private var connection: CloudTuiManualIOConnection? @@ -62,6 +62,7 @@ final class CloudTuiManualMirrorSession { private var appliedRemoteColors = CloudTuiRemoteColors() private var hasReceivedRemoteReplay = false private var lastRemoteGrid: CloudTuiManualIOGrid? + var pendingReplaySizingSample: TerminalSurfaceRawSizingSample? private(set) var phase: CloudTuiManualMirrorPhase = .idle { didSet { if phase == .disconnected, oldValue != .disconnected, diagnosticContext != nil { @@ -326,6 +327,7 @@ final class CloudTuiManualMirrorSession { let sample = surface.rawSizingSample() else { return } + if applyPendingReplaySizingSampleIfVisible() { return } apply(size: sample, validatePanePixels: true) } /// Starts or rebinds the byte attachment to the current link socket. @@ -563,6 +565,7 @@ final class CloudTuiManualMirrorSession { inputRouter.updateSurfaceID(surfaceID) } guard surfaceID == remoteSurfaceID else { return } + rememberReplaySizingSampleIfHidden() surface?.prepareForRemoteReplay(columns: columns, rows: rows) // A snapshot replaces the local VT state. Reset first so cells, // cursor state, alternate-screen mode, and SGR from a prior @@ -582,6 +585,7 @@ final class CloudTuiManualMirrorSession { applyColors(colors) case let .resized(surfaceID, columns, rows, bytes, colors): guard surfaceID == remoteSurfaceID else { return } + rememberReplaySizingSampleIfHidden() surface?.prepareForRemoteReplay(columns: columns, rows: rows) // `resized` carries a replacement replay, not an incremental // output chunk. Resetting first prevents old rows/cursor state from From c78ffdce332e7f8b468944b65abff265d76c0263 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 24 Sep 2026 15:04:51 -0700 Subject: [PATCH 34/49] fix: keep replay sizing helpers in app target --- ...dTuiManualMirrorSession+Capabilities.swift | 22 ++++++++++++++++ ...dTuiManualMirrorSession+ReplaySizing.swift | 25 ------------------- 2 files changed, 22 insertions(+), 25 deletions(-) delete mode 100644 Sources/Cloud/CloudTuiManualMirrorSession+ReplaySizing.swift diff --git a/Sources/Cloud/CloudTuiManualMirrorSession+Capabilities.swift b/Sources/Cloud/CloudTuiManualMirrorSession+Capabilities.swift index 96dccfb5447a..dabdea246f3f 100644 --- a/Sources/Cloud/CloudTuiManualMirrorSession+Capabilities.swift +++ b/Sources/Cloud/CloudTuiManualMirrorSession+Capabilities.swift @@ -13,4 +13,26 @@ extension CloudTuiManualMirrorSession { || error.contains("unsupported") || error.contains("unrecognized command") } + + /// Keep the local pane's last real geometry while a hidden replay briefly + /// aligns Ghostty to the remote snapshot grid. + func rememberReplaySizingSampleIfHidden() { + guard pendingReplaySizingSample == nil, + surface?.isRendererPortalVisible != true, + let sample = surface?.rawSizingSample(), + sample.columns > 1, + sample.rows > 1 else { return } + pendingReplaySizingSample = sample + } + + /// Re-publish the saved local geometry on reveal; the current Ghostty grid + /// may still describe the remote replay and fail pane-pixel validation. + @discardableResult + func applyPendingReplaySizingSampleIfVisible() -> Bool { + guard surface?.isRendererPortalVisible == true, + let sample = pendingReplaySizingSample else { return false } + pendingReplaySizingSample = nil + apply(size: sample, validatePanePixels: false) + return true + } } diff --git a/Sources/Cloud/CloudTuiManualMirrorSession+ReplaySizing.swift b/Sources/Cloud/CloudTuiManualMirrorSession+ReplaySizing.swift deleted file mode 100644 index 8dadfe23408a..000000000000 --- a/Sources/Cloud/CloudTuiManualMirrorSession+ReplaySizing.swift +++ /dev/null @@ -1,25 +0,0 @@ -import CmuxTerminal - -extension CloudTuiManualMirrorSession { - /// Keep the local pane's last real geometry while a hidden replay briefly - /// aligns Ghostty to the remote snapshot grid. - func rememberReplaySizingSampleIfHidden() { - guard pendingReplaySizingSample == nil, - surface?.isRendererPortalVisible != true, - let sample = surface?.rawSizingSample(), - sample.columns > 1, - sample.rows > 1 else { return } - pendingReplaySizingSample = sample - } - - /// Re-publish the saved local geometry on reveal; the current Ghostty grid - /// may still describe the remote replay and fail pane-pixel validation. - @discardableResult - func applyPendingReplaySizingSampleIfVisible() -> Bool { - guard surface?.isRendererPortalVisible == true, - let sample = pendingReplaySizingSample else { return false } - pendingReplaySizingSample = nil - apply(size: sample, validatePanePixels: false) - return true - } -} From cb2dc5809acb92c205aa24c994bdeae8fa6729d4 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 24 Sep 2026 15:56:57 -0700 Subject: [PATCH 35/49] test: reproduce Cloud replay shifting sparse screens with history --- cmux-tui/crates/ghostty-vt/src/terminal.rs | 23 ++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/cmux-tui/crates/ghostty-vt/src/terminal.rs b/cmux-tui/crates/ghostty-vt/src/terminal.rs index 131547861a20..92e1fcc8c964 100644 --- a/cmux-tui/crates/ghostty-vt/src/terminal.rs +++ b/cmux-tui/crates/ghostty-vt/src/terminal.rs @@ -5236,6 +5236,29 @@ mod tests { assert_eq!(target.viewport_text().unwrap(), expected); } + #[test] + fn vt_replay_preserves_blank_tail_after_history() { + let mut source = Terminal::new(20, 8, 100, Callbacks::default()).unwrap(); + for _ in 0..12 { + source.vt_write(b"history\r\n"); + } + source.vt_write(b"\x1b[2J\x1b[HHEADER\x1b[5;1H> Ask Codex\x1b[6;1HSTATUS\x1b[5;3H"); + let expected = source.viewport_text().unwrap(); + let replay = source.vt_replay_bounded_theme_portable(128 * 1024).unwrap(); + let mut restored = Terminal::new(20, 8, 100, Callbacks::default()).unwrap(); + restored.vt_write(&replay); + + assert_eq!(restored.viewport_text().unwrap(), expected); + assert_eq!(restored.cursor_position(), source.cursor_position()); + + // A TUI continues with absolute-cell diffs after attaching. Its header, + // composer and cursor must still agree on the same physical rows. + let update = b"\x1b[5;3HInput\x1b[6;1HDONE\x1b[5;8H"; + source.vt_write(update); + restored.vt_write(update); + assert_eq!(restored.viewport_text().unwrap(), source.viewport_text().unwrap()); + } + #[test] fn theme_portable_replay_retains_aliases_for_admitted_kitty_images() { let mut source = Terminal::new(20, 4, 100, Callbacks::default()).unwrap(); From 91601b841df586ce7386ac63295966e2320d32dd Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 24 Sep 2026 15:58:29 -0700 Subject: [PATCH 36/49] revert: remove speculative Cloud replay grid overrides --- .../TerminalSurface+ReplaySizing.swift | 22 ----------------- ...dTuiManualMirrorSession+Capabilities.swift | 22 ----------------- .../Cloud/CloudTuiManualMirrorSession.swift | 10 +++----- cmuxTests/CloudRestoreReplayFixture.swift | 24 ++++--------------- cmuxTests/CloudRestoreReplayGridTests.swift | 9 ++----- 5 files changed, 9 insertions(+), 78 deletions(-) delete mode 100644 Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+ReplaySizing.swift diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+ReplaySizing.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+ReplaySizing.swift deleted file mode 100644 index 2c22c551673a..000000000000 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+ReplaySizing.swift +++ /dev/null @@ -1,22 +0,0 @@ -import GhosttyKit - -extension TerminalSurface { - /// Aligns a manual-I/O surface with the grid authored by an incoming - /// replacement replay before its VT bytes are parsed. This is a transient - /// parser/renderer fence: the next real pane-geometry pass can return to - /// the local desired grid and negotiate that size with the remote PTY. - @MainActor - @discardableResult - public func prepareForRemoteReplay(columns: Int, rows: Int) -> Bool { - guard ioMode.usesManualIO, - (2...Int(UInt16.max)).contains(columns), - (2...Int(UInt16.max)).contains(rows) else { return false } - guard let runtime = liveSurfaceForGhosttyAccess(reason: "remoteReplayGrid") else { return false } - return ghostty_surface_set_grid_size( - runtime, - UInt16(columns), - UInt16(rows), - nil - ) - } -} diff --git a/Sources/Cloud/CloudTuiManualMirrorSession+Capabilities.swift b/Sources/Cloud/CloudTuiManualMirrorSession+Capabilities.swift index dabdea246f3f..96dccfb5447a 100644 --- a/Sources/Cloud/CloudTuiManualMirrorSession+Capabilities.swift +++ b/Sources/Cloud/CloudTuiManualMirrorSession+Capabilities.swift @@ -13,26 +13,4 @@ extension CloudTuiManualMirrorSession { || error.contains("unsupported") || error.contains("unrecognized command") } - - /// Keep the local pane's last real geometry while a hidden replay briefly - /// aligns Ghostty to the remote snapshot grid. - func rememberReplaySizingSampleIfHidden() { - guard pendingReplaySizingSample == nil, - surface?.isRendererPortalVisible != true, - let sample = surface?.rawSizingSample(), - sample.columns > 1, - sample.rows > 1 else { return } - pendingReplaySizingSample = sample - } - - /// Re-publish the saved local geometry on reveal; the current Ghostty grid - /// may still describe the remote replay and fail pane-pixel validation. - @discardableResult - func applyPendingReplaySizingSampleIfVisible() -> Bool { - guard surface?.isRendererPortalVisible == true, - let sample = pendingReplaySizingSample else { return false } - pendingReplaySizingSample = nil - apply(size: sample, validatePanePixels: false) - return true - } } diff --git a/Sources/Cloud/CloudTuiManualMirrorSession.swift b/Sources/Cloud/CloudTuiManualMirrorSession.swift index 23bc507b2f05..ea0d8596dd84 100644 --- a/Sources/Cloud/CloudTuiManualMirrorSession.swift +++ b/Sources/Cloud/CloudTuiManualMirrorSession.swift @@ -16,6 +16,7 @@ private let manualMirrorLogger = Logger(subsystem: "com.cmuxterm.app", category: @MainActor final class CloudTuiManualMirrorSession { private static let replayReset = Data([0x1B, 0x63, 0x1B, 0x5B, 0x33, 0x4A]) + let machineID: String let terminalID: String private(set) var remoteSurfaceID: UInt64 @@ -29,7 +30,7 @@ final class CloudTuiManualMirrorSession { private var diagnosticDeadline: Task? private(set) var diagnosticFailure: CloudDiagnosticFailure? private var diagnosticReference: String? - weak var surface: TerminalSurface? + private weak var surface: TerminalSurface? private let onNeedsReconnect: @MainActor () -> Void private let commandBuilder: CloudTuiManualIOCommand private var connection: CloudTuiManualIOConnection? @@ -62,7 +63,6 @@ final class CloudTuiManualMirrorSession { private var appliedRemoteColors = CloudTuiRemoteColors() private var hasReceivedRemoteReplay = false private var lastRemoteGrid: CloudTuiManualIOGrid? - var pendingReplaySizingSample: TerminalSurfaceRawSizingSample? private(set) var phase: CloudTuiManualMirrorPhase = .idle { didSet { if phase == .disconnected, oldValue != .disconnected, diagnosticContext != nil { @@ -327,7 +327,6 @@ final class CloudTuiManualMirrorSession { let sample = surface.rawSizingSample() else { return } - if applyPendingReplaySizingSampleIfVisible() { return } apply(size: sample, validatePanePixels: true) } /// Starts or rebinds the byte attachment to the current link socket. @@ -565,8 +564,6 @@ final class CloudTuiManualMirrorSession { inputRouter.updateSurfaceID(surfaceID) } guard surfaceID == remoteSurfaceID else { return } - rememberReplaySizingSampleIfHidden() - surface?.prepareForRemoteReplay(columns: columns, rows: rows) // A snapshot replaces the local VT state. Reset first so cells, // cursor state, alternate-screen mode, and SGR from a prior // restore cannot survive where the replacement is shorter. @@ -585,8 +582,6 @@ final class CloudTuiManualMirrorSession { applyColors(colors) case let .resized(surfaceID, columns, rows, bytes, colors): guard surfaceID == remoteSurfaceID else { return } - rememberReplaySizingSampleIfHidden() - surface?.prepareForRemoteReplay(columns: columns, rows: rows) // `resized` carries a replacement replay, not an incremental // output chunk. Resetting first prevents old rows/cursor state from // surviving a shrink or a reconnect. @@ -625,6 +620,7 @@ final class CloudTuiManualMirrorSession { break } } + private func applyReplay(_ bytes: Data, colors: CloudTuiRemoteColors?) { // A sidecar replaces authored colors; an absent sidecar preserves them. // Restore the authoritative set after resetting the replacement VT state. diff --git a/cmuxTests/CloudRestoreReplayFixture.swift b/cmuxTests/CloudRestoreReplayFixture.swift index 10e69f1f7923..c66eed9c682a 100644 --- a/cmuxTests/CloudRestoreReplayFixture.swift +++ b/cmuxTests/CloudRestoreReplayFixture.swift @@ -61,16 +61,7 @@ final class CloudRestoreReplayFixture { } } - func expectGrid(columns: Int, rows: Int) async throws { - try await waitUntil { - let frame = self.surface.mobileRenderGridFrame( - stateSeq: 0, scrollbackLines: 0, includeTheme: false - )?.frame - return frame?.columns == columns && frame?.rows == rows - } - } - - func attach(replay: Data, columns: Int = 80, rows: Int = 24) async throws { + func attach(replay: Data) async throws { session.reconnect(socketPath: socket.socketPath) let identify = try #require(await socket.nextCommand(timeout: .seconds(5))) #expect(identify.cmd == "identify") @@ -82,7 +73,7 @@ final class CloudRestoreReplayFixture { #expect(attach.cmd == "attach-surface") #expect(!attach.hasInitialSize, "Hidden restores must not claim their temporary grid") socket.send(["id": attach.id, "ok": true, "data": [:]]) - try await deliver(replay, event: "vt-state", columns: columns, rows: rows, marker: "STATUS_READY") + try await deliver(replay, event: "vt-state", marker: "STATUS_READY") try await waitUntil { self.session.phase == .attached } } @@ -109,16 +100,9 @@ final class CloudRestoreReplayFixture { #expect(input.inputBytes == bytes) } - func deliver( - _ bytes: Data, - event: String, - columns: Int = 80, - rows: Int = 24, - marker: String, - colors: [String: Any]? = nil - ) async throws { + func deliver(_ bytes: Data, event: String, marker: String, colors: [String: Any]? = nil) async throws { var payload: [String: Any] = [ - "event": event, "surface": 17, "cols": columns, "rows": rows, + "event": event, "surface": 17, "cols": 80, "rows": 24, "data": bytes.base64EncodedString() ] if let colors { payload["colors"] = colors } diff --git a/cmuxTests/CloudRestoreReplayGridTests.swift b/cmuxTests/CloudRestoreReplayGridTests.swift index 1ee94aa3156a..319f051cf28a 100644 --- a/cmuxTests/CloudRestoreReplayGridTests.swift +++ b/cmuxTests/CloudRestoreReplayGridTests.swift @@ -41,18 +41,13 @@ struct CloudRestoreReplayGridTests { func restoredSnapshotReplacesStaleLocalCells() async throws { let fixture = try CloudRestoreReplayFixture() defer { fixture.close() } - try await fixture.setGrid(columns: 107, rows: 25) + try await fixture.setGrid(columns: 80, rows: 24) try await fixture.seedLocalOutput(Data("STALE_COMPOSER".utf8), marker: "STALE_COMPOSER") - try await fixture.attach( - replay: Data("FRESH_COMPOSER STATUS_READY".utf8), - columns: 105, - rows: 25 - ) + try await fixture.attach(replay: Data("FRESH_COMPOSER STATUS_READY".utf8)) let screen = try #require(fixture.surface.readText(region: .screen)) #expect(screen.contains("FRESH_COMPOSER")) #expect(!screen.contains("STALE_COMPOSER")) - try await fixture.expectGrid(columns: 105, rows: 25) } @Test From d2d6aa3ce99a5234b0ae64b92bfe900de54bcd70 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 24 Sep 2026 16:10:11 -0700 Subject: [PATCH 37/49] fix: refresh Cloud renderer after replay application --- .../TerminalSurface+RemoteReplay.swift | 23 +++++++++++++++++++ .../TerminalSurfaceRemoteOutputLane.swift | 9 +++++++- .../Cloud/CloudTuiManualMirrorSession.swift | 14 +++++++---- 3 files changed, 40 insertions(+), 6 deletions(-) create mode 100644 Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RemoteReplay.swift diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RemoteReplay.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RemoteReplay.swift new file mode 100644 index 000000000000..427aa3c5595f --- /dev/null +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RemoteReplay.swift @@ -0,0 +1,23 @@ +import Foundation +import GhosttyKit + +extension TerminalSurface { + /// Enqueues replacement output and refreshes after the parser has applied it. + /// + /// A Cloud snapshot is a replacement state, so refreshing when its bytes + /// are merely admitted can present the previous IOSurface contents. The + /// completion runs after the generation FIFO has parsed the bytes. + @MainActor + public func processRemoteReplay( + _ data: Data, + onApplied: @escaping @MainActor @Sendable () -> Void + ) { + guard !data.isEmpty, + let surface = liveSurfaceForGhosttyAccess(reason: "remoteReplay") else { + processRemoteOutput(data) + return + } + flushPendingRemoteOutput(to: surface) + remoteOutputLane.enqueue(data, to: surface, onApplied: onApplied) + } +} diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurfaceRemoteOutputLane.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurfaceRemoteOutputLane.swift index c1143f5a9e10..22b613cef3c6 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurfaceRemoteOutputLane.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurfaceRemoteOutputLane.swift @@ -30,7 +30,11 @@ final class TerminalSurfaceRemoteOutputLane: @unchecked Sendable { } /// Enqueues one ordered output batch and its refresh signal. - func enqueue(_ data: Data, to surface: ghostty_surface_t) { + func enqueue( + _ data: Data, + to surface: ghostty_surface_t, + onApplied: (@MainActor @Sendable () -> Void)? = nil + ) { guard !data.isEmpty else { return } // Raw pointers are represented as bits across the Sendable queue // boundary; the lane fence owns the native lifetime until this work @@ -51,6 +55,9 @@ final class TerminalSurfaceRemoteOutputLane: @unchecked Sendable { ghostty_surface_process_output(surface, baseAddress, UInt(rawBuffer.count)) } ghostty_surface_refresh(surface) + if let onApplied { + Task { @MainActor in onApplied() } + } } } } diff --git a/Sources/Cloud/CloudTuiManualMirrorSession.swift b/Sources/Cloud/CloudTuiManualMirrorSession.swift index ea0d8596dd84..2341ffb389c5 100644 --- a/Sources/Cloud/CloudTuiManualMirrorSession.swift +++ b/Sources/Cloud/CloudTuiManualMirrorSession.swift @@ -620,15 +620,19 @@ final class CloudTuiManualMirrorSession { break } } - private func applyReplay(_ bytes: Data, colors: CloudTuiRemoteColors?) { // A sidecar replaces authored colors; an absent sidecar preserves them. // Restore the authoritative set after resetting the replacement VT state. let replayColors = colors ?? appliedRemoteColors - applyColors(CloudTuiRemoteColors()) - surface?.processRemoteOutput(Self.replayReset) - surface?.processRemoteOutput(bytes) - applyColors(replayColors) + var replay = CloudTuiRemoteColors().oscDelta(from: appliedRemoteColors) + replay.append(Self.replayReset) + replay.append(bytes) + replay.append(replayColors.oscBytes) + appliedRemoteColors = replayColors + guard let surface else { return } + surface.processRemoteReplay(replay) { [weak surface] in + surface?.forceRefresh(reason: "cloud.replay.applied") + } } /// The replay is theme-portable: it carries no palette or default-color From 90fa111b4928a5121a17d45edcdb2b65cc4bd3b3 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 24 Sep 2026 16:50:28 -0700 Subject: [PATCH 38/49] fix: preserve replay history and protect tagged resources --- .../DiagnosticLocalization.swift | 32 ++++++++++-- cmux-tui/crates/ghostty-vt/src/terminal.rs | 24 ++++++--- scripts/reload.sh | 49 ++++++++++++------- ...test_reload_build_only_keeps_tagged_app.sh | 8 +++ 4 files changed, 84 insertions(+), 29 deletions(-) diff --git a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticLocalization.swift b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticLocalization.swift index 0a176e9e1f18..93ce55dc6f73 100644 --- a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticLocalization.swift +++ b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticLocalization.swift @@ -2,6 +2,8 @@ import Foundation /// Resolves diagnostic copy from the shared package's locale catalog. struct DiagnosticLocalization: Sendable { + private final class BundleFinder {} + let locale: Locale private let bundle: Bundle @@ -23,16 +25,17 @@ struct DiagnosticLocalization: Sendable { } private static func bundle(for locale: Locale) -> Bundle { - languageBundle(for: locale) ?? .module + languageBundle(for: locale) ?? packageResourceBundle ?? .main } private static func languageBundle(for locale: Locale) -> Bundle? { + guard let packageResourceBundle else { return nil } let identifiers = Bundle.preferredLocalizations( - from: Bundle.module.localizations, + from: packageResourceBundle.localizations, forPreferences: [locale.identifier] ) for identifier in identifiers { - guard let path = Bundle.module.path( + guard let path = packageResourceBundle.path( forResource: identifier, ofType: "lproj" ), let bundle = Bundle(path: path) else { continue } @@ -40,4 +43,27 @@ struct DiagnosticLocalization: Sendable { } return nil } + + /// SwiftPM normally synthesizes `Bundle.module` for this lookup. That + /// accessor traps when a tagged app is replaced while its previous process + /// is still starting, because the old process can briefly observe a bundle + /// whose package resources have moved. Keep the lookup optional so + /// diagnostics fall back to their supplied English defaults instead of + /// turning startup telemetry into a process-wide fatal error. + private static let packageResourceBundle: Bundle? = { + let bundleName = "CMUXMobileCore_CMUXMobileCore" + let resourceRoots = [ + Bundle.main.resourceURL, + Bundle(for: BundleFinder.self).resourceURL, + Bundle.main.bundleURL, + ] + for root in resourceRoots { + guard let root else { continue } + let url = root.appendingPathComponent(bundleName + ".bundle") + if let bundle = Bundle(url: url) { + return bundle + } + } + return nil + }() } diff --git a/cmux-tui/crates/ghostty-vt/src/terminal.rs b/cmux-tui/crates/ghostty-vt/src/terminal.rs index 92e1fcc8c964..2af35ebc7a90 100644 --- a/cmux-tui/crates/ghostty-vt/src/terminal.rs +++ b/cmux-tui/crates/ghostty-vt/src/terminal.rs @@ -3582,18 +3582,28 @@ impl Terminal { segment_ends.insert(range.start); } segment_ends.insert(range.end); - // A replay without image placement anchors can let the target terminal - // recreate soft wraps naturally. Placement commands depend on physical - // row cursor positions, so retain the legacy row-delimited form for any - // range that intersects an occupied placement span. - let preserve_soft_wrap = !insert_at_start && !has_placement_anchor; - let mut bytes = Vec::new(); let mut insertion_offsets = BTreeMap::new(); let mut segment_start = range.start; let replay_rows = range.end - range.start + 1; let screen_rows = u64::from(self.rows().max(1)); - let history_bearing = replay_rows > screen_rows; + // A replay range can contain exactly one viewport of rows while still + // starting in scrollback. That happens when the formatter's scrollbar + // coordinates include a small sparse prefix before the active screen. + // Treat any range that starts before the physical viewport as + // history-bearing so its row breaks scroll that prefix out of the + // target viewport instead of leaving stale history above the TUI. + let viewport_start = self + .scrollbar() + .map(|scrollbar| scrollbar.total.saturating_sub(screen_rows)) + .unwrap_or(0); + let history_bearing = range.start < viewport_start || replay_rows > screen_rows; + // A replay without image placement anchors can let the target terminal + // recreate soft wraps naturally. Placement commands and history-bearing + // ranges depend on physical row cursor positions, so retain the + // row-delimited form for those cases. + let preserve_soft_wrap = + !history_bearing && !insert_at_start && !has_placement_anchor; let mut emitted_breaks = 0usize; for segment_end in segment_ends { if segment_end < segment_start { diff --git a/scripts/reload.sh b/scripts/reload.sh index 0564699edf4d..2f2a4e541e87 100755 --- a/scripts/reload.sh +++ b/scripts/reload.sh @@ -2019,16 +2019,6 @@ if ! /usr/bin/codesign --force --sign - --timestamp=none --generate-entitlement- exit 1 fi fi -if [[ "$BUILD_ONLY" -eq 1 && -n "${TAG_APP_STAGING_PATH:-}" ]]; then - # Keep the staged artifact separate from the running tagged app. This mode is - # explicitly for compilation/validation and must not mutate the active bundle. - APP_PATH="$TAG_APP_STAGING_PATH" -elif [[ -n "${TAG_APP_FINAL_PATH:-}" && -n "${TAG_APP_STAGING_PATH:-}" ]]; then - rm -rf "$TAG_APP_FINAL_PATH" - mv "$TAG_APP_STAGING_PATH" "$TAG_APP_FINAL_PATH" - APP_PATH="$TAG_APP_FINAL_PATH" -fi -CLI_PATH="$APP_PATH/Contents/Resources/bin/cmux" TAG_LAUNCHD_LABEL="" TAG_LAUNCHD_DOMAIN="" @@ -2037,22 +2027,43 @@ if [[ -n "${TAG_SLUG:-}" ]]; then TAG_LAUNCHD_DOMAIN="gui/$(id -u)" fi -# Tag mode: always terminate the existing same-tag instance after a successful build, -# even without --launch. A stale tagged app pinned to this bundle id would otherwise -# keep running against freshly-overwritten resources, and macOS would foreground it -# instead of launching the newly built binary when the user cmd-clicks the .app. +# Terminate the existing same-tag instance before replacing its bundle. The +# running process resolves SwiftPM resources through its app path; removing +# that path first can make Bundle.module trap during startup while the old +# process is still initializing. if [[ -n "$TAG" && "$BUILD_ONLY" -ne 1 ]]; then /usr/bin/osascript -e "tell application id \"${BUNDLE_ID}\" to quit" >/dev/null 2>&1 || true sleep 0.3 - pkill -f "${APP_NAME}.app/Contents/MacOS/${BASE_APP_NAME}" || true - sleep 0.3 - # Tagged --launch runs are handed off to launchd so they survive the terminal or - # automation process that invoked reload.sh. Remove a still-registered prior job - # after giving the app a chance to quit gracefully. + TAG_PROCESS_PATTERN="${APP_NAME}.app/Contents/MacOS/${BASE_APP_NAME}" + pkill -f "$TAG_PROCESS_PATTERN" || true + for _ in {1..20}; do + if ! pgrep -f "$TAG_PROCESS_PATTERN" >/dev/null 2>&1; then + break + fi + sleep 0.1 + done + # A startup process may not service its quit event yet. Do not replace the + # resource-bearing bundle while it is still mapped; force only this tagged + # executable after the bounded graceful window. + pkill -KILL -f "$TAG_PROCESS_PATTERN" >/dev/null 2>&1 || true + # Tagged --launch runs are handed off to launchd so they survive the terminal + # or automation process that invoked reload.sh. Remove a still-registered + # prior job before publishing the replacement bundle. /bin/launchctl bootout "$TAG_LAUNCHD_DOMAIN/$TAG_LAUNCHD_LABEL" >/dev/null 2>&1 || true /bin/launchctl remove "$TAG_LAUNCHD_LABEL" >/dev/null 2>&1 || true fi +if [[ "$BUILD_ONLY" -eq 1 && -n "${TAG_APP_STAGING_PATH:-}" ]]; then + # Keep the staged artifact separate from the running tagged app. This mode is + # explicitly for compilation/validation and must not mutate the active bundle. + APP_PATH="$TAG_APP_STAGING_PATH" +elif [[ -n "${TAG_APP_FINAL_PATH:-}" && -n "${TAG_APP_STAGING_PATH:-}" ]]; then + rm -rf "$TAG_APP_FINAL_PATH" + mv "$TAG_APP_STAGING_PATH" "$TAG_APP_FINAL_PATH" + APP_PATH="$TAG_APP_FINAL_PATH" +fi +CLI_PATH="$APP_PATH/Contents/Resources/bin/cmux" + if [[ "$BUILD_ONLY" -eq 1 ]]; then CAN_PUBLISH_RELOAD_STATE=0 RELOAD_PUBLICATION_SKIP_REASON="build-only mode left the running tagged app and tag state unchanged" diff --git a/tests/test_reload_build_only_keeps_tagged_app.sh b/tests/test_reload_build_only_keeps_tagged_app.sh index e776ff996877..2035750bedca 100755 --- a/tests/test_reload_build_only_keeps_tagged_app.sh +++ b/tests/test_reload_build_only_keeps_tagged_app.sh @@ -61,3 +61,11 @@ set -e || fail "collision refusal did not explain the protected bundle name" echo "PASS: --build-only rejects a name override that aliases the running tagged bundle" +# A normal tagged reload must stop the previous process before replacing the +# final app path. Otherwise a process that is still starting can lose its +# SwiftPM resource bundle and trap in Bundle.module during diagnostics setup. +terminate_line="$(grep -n 'TAG_PROCESS_PATTERN=' "$RELOAD" | head -n1 | cut -d: -f1)" +replace_line="$(grep -n 'rm -rf "\$TAG_APP_FINAL_PATH"' "$RELOAD" | head -n1 | cut -d: -f1)" +[[ -n "$terminate_line" && -n "$replace_line" && "$terminate_line" -lt "$replace_line" ]] \ + || fail "normal tagged reload can replace the app bundle before terminating the prior process" +echo "PASS: normal tagged reload terminates before replacing the final app bundle" From 054dc50a18bfeac80b1db4ceacb5ef84f468ee05 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 24 Sep 2026 16:58:05 -0700 Subject: [PATCH 39/49] style: apply hosted replay formatting --- cmux-tui/crates/ghostty-vt/src/terminal.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/cmux-tui/crates/ghostty-vt/src/terminal.rs b/cmux-tui/crates/ghostty-vt/src/terminal.rs index 2af35ebc7a90..705fc80f1f63 100644 --- a/cmux-tui/crates/ghostty-vt/src/terminal.rs +++ b/cmux-tui/crates/ghostty-vt/src/terminal.rs @@ -3602,8 +3602,7 @@ impl Terminal { // recreate soft wraps naturally. Placement commands and history-bearing // ranges depend on physical row cursor positions, so retain the // row-delimited form for those cases. - let preserve_soft_wrap = - !history_bearing && !insert_at_start && !has_placement_anchor; + let preserve_soft_wrap = !history_bearing && !insert_at_start && !has_placement_anchor; let mut emitted_breaks = 0usize; for segment_end in segment_ends { if segment_end < segment_start { From 6ac603e9f31902d23fce3790deceea7381d9a761 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 24 Sep 2026 17:17:10 -0700 Subject: [PATCH 40/49] fix: use terminal history boundary for replay --- cmux-tui/crates/ghostty-vt/src/terminal.rs | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/cmux-tui/crates/ghostty-vt/src/terminal.rs b/cmux-tui/crates/ghostty-vt/src/terminal.rs index 705fc80f1f63..7a7967750c86 100644 --- a/cmux-tui/crates/ghostty-vt/src/terminal.rs +++ b/cmux-tui/crates/ghostty-vt/src/terminal.rs @@ -3588,16 +3588,13 @@ impl Terminal { let replay_rows = range.end - range.start + 1; let screen_rows = u64::from(self.rows().max(1)); // A replay range can contain exactly one viewport of rows while still - // starting in scrollback. That happens when the formatter's scrollbar - // coordinates include a small sparse prefix before the active screen. - // Treat any range that starts before the physical viewport as - // history-bearing so its row breaks scroll that prefix out of the - // target viewport instead of leaving stale history above the TUI. - let viewport_start = self - .scrollbar() - .map(|scrollbar| scrollbar.total.saturating_sub(screen_rows)) - .unwrap_or(0); - let history_bearing = range.start < viewport_start || replay_rows > screen_rows; + // starting in scrollback. Use Ghostty's history boundary rather than + // the formatter scrollbar total, whose sparse prefix can be offset + // from the screen coordinate space. Row breaks then scroll that + // prefix out of the target viewport instead of leaving stale history + // above the TUI. + let history_start = u64::from(self.history_rows()); + let history_bearing = range.start < history_start || replay_rows > screen_rows; // A replay without image placement anchors can let the target terminal // recreate soft wraps naturally. Placement commands and history-bearing // ranges depend on physical row cursor positions, so retain the From 96d568658ae23f9daed714ed79c681a546566e6f Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 24 Sep 2026 17:30:09 -0700 Subject: [PATCH 41/49] fix: delimit replay rows when scrollback exists --- cmux-tui/crates/ghostty-vt/src/terminal.rs | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/cmux-tui/crates/ghostty-vt/src/terminal.rs b/cmux-tui/crates/ghostty-vt/src/terminal.rs index 7a7967750c86..669f6d1d0349 100644 --- a/cmux-tui/crates/ghostty-vt/src/terminal.rs +++ b/cmux-tui/crates/ghostty-vt/src/terminal.rs @@ -3587,14 +3587,11 @@ impl Terminal { let mut segment_start = range.start; let replay_rows = range.end - range.start + 1; let screen_rows = u64::from(self.rows().max(1)); - // A replay range can contain exactly one viewport of rows while still - // starting in scrollback. Use Ghostty's history boundary rather than - // the formatter scrollbar total, whose sparse prefix can be offset - // from the screen coordinate space. Row breaks then scroll that - // prefix out of the target viewport instead of leaving stale history - // above the TUI. - let history_start = u64::from(self.history_rows()); - let history_bearing = range.start < history_start || replay_rows > screen_rows; + // A replay with retained scrollback can contain exactly one viewport + // of rows while still carrying a sparse history prefix in Ghostty's + // screen coordinate space. Keep every row boundary in that case so + // the target cannot retain stale history above the active TUI. + let history_bearing = self.history_rows() > 0 || replay_rows > screen_rows; // A replay without image placement anchors can let the target terminal // recreate soft wraps naturally. Placement commands and history-bearing // ranges depend on physical row cursor positions, so retain the From 4784eb2bd28550db93d141398f5350266ac7fa11 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 24 Sep 2026 21:25:05 -0700 Subject: [PATCH 42/49] fix: preserve Cloud replay trailing rows --- ghostty | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ghostty b/ghostty index c5c31ce81913..a3e9304c5d19 160000 --- a/ghostty +++ b/ghostty @@ -1 +1 @@ -Subproject commit c5c31ce819131ebb2deb4c4d4a75beffe4340c8d +Subproject commit a3e9304c5d19c8667f58a342830f774579c74472 From cbc0118145586fbb02ef00ef317f38c0ad181d00 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 24 Sep 2026 22:06:26 -0700 Subject: [PATCH 43/49] ci: pin GhosttyKit for replay fix --- docs/ghostty-fork.md | 31 +++++++++++++++++++++++-------- scripts/ghosttykit-checksums.txt | 1 + 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/docs/ghostty-fork.md b/docs/ghostty-fork.md index cc9f2823dbef..4c3649af64cb 100644 --- a/docs/ghostty-fork.md +++ b/docs/ghostty-fork.md @@ -12,12 +12,26 @@ When we change the fork, update this document and the parent submodule SHA. ## Current fork changes -The submodule pinned by this branch is `c5c31ce819`, the upstream Ghostty -merge commit for PR #218 after the embedded-environment lifetime fix from PR -#227 was merged. It preserves cmux's Cloud loopback link-detection changes -while adding the localhost-port punctuation fix and owned POSIX environment -snapshots for embedded hosts. This SHA is reachable from `manaflow-ai/ghostty` -main and is the release target for cmux's GhosttyKit build workflow. +### Cloud restore replay trailing rows + +- Commit: `a3e9304c5d19c8667f58a342830f774579c74472` +- Summary: preserve trailing physical blank rows until the VT cursor/state + restoration footer when replay requests cursor restoration. Normal formatter + output and soft-wrap behavior are unchanged. +- Verification: hosted Ghostty test workflow passed before the GhosttyKit build; + the cmux replay regression is `vt_replay_preserves_blank_tail_after_history`. +- Artifact: + https://github.com/manaflow-ai/ghostty/releases/tag/xcframework-a3e9304c5d19c8667f58a342830f774579c74472-crashsubdir-cmux-crash-sentry-off-noi18n-v2 +- SHA-256 `98697b9a49b36e835e900f716ac054cf2476d97bf40ea2742454e735ac5aa3a9` + is pinned in `scripts/ghosttykit-checksums.txt`. + +The submodule pinned by this branch is `a3e9304c5d`, a cmux-only replay fix on +top of `c5c31ce819`, the upstream Ghostty merge commit for PR #218 after the +embedded-environment lifetime fix from PR #227 was merged. The replay fix +preserves physical blank rows until cursor/state restoration completes, so a +restored Cloud grid cannot regain stale history rows. The base SHA preserves +cmux's Cloud loopback link-detection changes while adding the localhost-port +punctuation fix and owned POSIX environment snapshots for embedded hosts. The previous pin `35ae29b7c2` is the merge of fork `main` at `3869e81a0` into the Cloud loopback link-detection branch (`46428d790`, bare localhost port links, @@ -34,7 +48,7 @@ fork changes below, including tokened iOS render dispositions, VT formatter cursor restoration, VT stream-boundary visibility, and Hangul canonical font resolution. -### Current feature pin +### Base feature pin - Branch: - https://github.com/manaflow-ai/ghostty/tree/main (contains the Hangul fix @@ -42,7 +56,8 @@ resolution. fork `main`, on the Cloud loopback link-detection branch) - Commit: - `c5c31ce819` (upstream merge of Ghostty #218 after #227; preserves Cloud - loopback behavior and is reachable from `manaflow-ai/ghostty:main`) + loopback behavior and is reachable from `manaflow-ai/ghostty:main`; the + current branch adds `a3e9304c5d` above it) - Summary: - Fixes localhost-port sentence punctuation and owns POSIX environment snapshots retained by embedded Ghostty, on top of the diff --git a/scripts/ghosttykit-checksums.txt b/scripts/ghosttykit-checksums.txt index 549492f1d1b8..fecd0a14dacb 100644 --- a/scripts/ghosttykit-checksums.txt +++ b/scripts/ghosttykit-checksums.txt @@ -130,3 +130,4 @@ abd40f6e472d57f2d4bb182004bb5f3fac8df961 fdb0f7e844fa086a410f0b1df23badf2b0503c0 35ae29b7c2bcee7c721d515d0096a9bc3f3242bb 6f83f20842140a782c8029156aabe92c246a181682794f01ad0a30ca43c76620 370f08cf15a6ab646b9a291f72af034bb0960fb3 ec53b8992b466ecd9cc87b42754188fe504898ff0b139f54b3eef1dc6a441233 c5c31ce819131ebb2deb4c4d4a75beffe4340c8d 4f75749a168712a2b456840309d9603a94039e97a453bd3f98c3ed945826fe7a +a3e9304c5d19c8667f58a342830f774579c74472 98697b9a49b36e835e900f716ac054cf2476d97bf40ea2742454e735ac5aa3a9 From fc2b529eb39c67ed4c0debe3eb6677319cfa9be9 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 24 Sep 2026 22:34:14 -0700 Subject: [PATCH 44/49] fix: pin attested iroh Swift artifact revision --- Packages/Shared/CmuxIrohTransport/Package.resolved | 3 +-- Packages/Shared/CmuxIrohTransport/Package.swift | 2 +- Packages/Shared/CmuxIrxTransport/Package.resolved | 3 +-- Packages/Shared/CmuxIrxTransport/Package.swift | 2 +- 4 files changed, 4 insertions(+), 6 deletions(-) diff --git a/Packages/Shared/CmuxIrohTransport/Package.resolved b/Packages/Shared/CmuxIrohTransport/Package.resolved index c14e6dfd49fa..b7f2e8932482 100644 --- a/Packages/Shared/CmuxIrohTransport/Package.resolved +++ b/Packages/Shared/CmuxIrohTransport/Package.resolved @@ -6,8 +6,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/manaflow-ai/iroh-ffi.git", "state" : { - "revision" : "8da966cad36377e4e55569f80dcde1be91d30a5b", - "version" : "1.0.2-cmux.7.ios17.2" + "revision" : "51607f3031d9ec1453c258527db5d0735077c631" } } ], diff --git a/Packages/Shared/CmuxIrohTransport/Package.swift b/Packages/Shared/CmuxIrohTransport/Package.swift index 7fbd8a20b18a..109001509758 100644 --- a/Packages/Shared/CmuxIrohTransport/Package.swift +++ b/Packages/Shared/CmuxIrohTransport/Package.swift @@ -18,7 +18,7 @@ let package = Package( .package(path: "../CMUXMobileCore"), .package( url: "https://github.com/manaflow-ai/iroh-ffi.git", - exact: "1.0.2-cmux.7.ios17.2" + revision: "51607f3031d9ec1453c258527db5d0735077c631" ), ], targets: [ diff --git a/Packages/Shared/CmuxIrxTransport/Package.resolved b/Packages/Shared/CmuxIrxTransport/Package.resolved index 9d3d360482e2..1ab85812d325 100644 --- a/Packages/Shared/CmuxIrxTransport/Package.resolved +++ b/Packages/Shared/CmuxIrxTransport/Package.resolved @@ -6,8 +6,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/manaflow-ai/iroh-ffi.git", "state" : { - "revision" : "8da966cad36377e4e55569f80dcde1be91d30a5b", - "version" : "1.0.2-cmux.7.ios17.2" + "revision" : "51607f3031d9ec1453c258527db5d0735077c631" } } ], diff --git a/Packages/Shared/CmuxIrxTransport/Package.swift b/Packages/Shared/CmuxIrxTransport/Package.swift index bd2e992da95e..e2098b796c03 100644 --- a/Packages/Shared/CmuxIrxTransport/Package.swift +++ b/Packages/Shared/CmuxIrxTransport/Package.swift @@ -21,7 +21,7 @@ let package = Package( .package(path: "../CmuxIrohTransport"), .package( url: "https://github.com/manaflow-ai/iroh-ffi.git", - exact: "1.0.2-cmux.7.ios17.2" + revision: "51607f3031d9ec1453c258527db5d0735077c631" ), ], targets: [ From 1d9082a7aca931d82b34a6be656fdd954f7e0bd7 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 24 Sep 2026 22:41:21 -0700 Subject: [PATCH 45/49] chore: update iroh package lockfiles --- ios/cmux.xcworkspace/xcshareddata/swiftpm/Package.resolved | 3 +-- ios/cmuxPackage/Package.resolved | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/ios/cmux.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/cmux.xcworkspace/xcshareddata/swiftpm/Package.resolved index 390394897df9..cd35861fc4df 100644 --- a/ios/cmux.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ios/cmux.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -15,8 +15,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/manaflow-ai/iroh-ffi.git", "state" : { - "revision" : "8da966cad36377e4e55569f80dcde1be91d30a5b", - "version" : "1.0.2-cmux.7.ios17.2" + "revision" : "51607f3031d9ec1453c258527db5d0735077c631" } }, { diff --git a/ios/cmuxPackage/Package.resolved b/ios/cmuxPackage/Package.resolved index 641ae9f38c55..61e83da71e2f 100644 --- a/ios/cmuxPackage/Package.resolved +++ b/ios/cmuxPackage/Package.resolved @@ -15,8 +15,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/manaflow-ai/iroh-ffi.git", "state" : { - "revision" : "8da966cad36377e4e55569f80dcde1be91d30a5b", - "version" : "1.0.2-cmux.7.ios17.2" + "revision" : "51607f3031d9ec1453c258527db5d0735077c631" } }, { From ddeb03d391d9e24047235ef002460cc6672e1590 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 24 Sep 2026 22:47:08 -0700 Subject: [PATCH 46/49] fix: pin published iroh Swift release --- Packages/Shared/CmuxIrohTransport/Package.resolved | 3 ++- Packages/Shared/CmuxIrohTransport/Package.swift | 2 +- Packages/Shared/CmuxIrxTransport/Package.resolved | 3 ++- Packages/Shared/CmuxIrxTransport/Package.swift | 2 +- ios/cmux.xcworkspace/xcshareddata/swiftpm/Package.resolved | 3 ++- ios/cmuxPackage/Package.resolved | 3 ++- 6 files changed, 10 insertions(+), 6 deletions(-) diff --git a/Packages/Shared/CmuxIrohTransport/Package.resolved b/Packages/Shared/CmuxIrohTransport/Package.resolved index b7f2e8932482..0f99e4e776fa 100644 --- a/Packages/Shared/CmuxIrohTransport/Package.resolved +++ b/Packages/Shared/CmuxIrohTransport/Package.resolved @@ -6,7 +6,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/manaflow-ai/iroh-ffi.git", "state" : { - "revision" : "51607f3031d9ec1453c258527db5d0735077c631" + "revision" : "af08f0e1b9bb3ddb839210b175738d5761fea686", + "version" : "1.0.2-cmux.7.ios17.3" } } ], diff --git a/Packages/Shared/CmuxIrohTransport/Package.swift b/Packages/Shared/CmuxIrohTransport/Package.swift index 109001509758..4b8cb0154d0a 100644 --- a/Packages/Shared/CmuxIrohTransport/Package.swift +++ b/Packages/Shared/CmuxIrohTransport/Package.swift @@ -18,7 +18,7 @@ let package = Package( .package(path: "../CMUXMobileCore"), .package( url: "https://github.com/manaflow-ai/iroh-ffi.git", - revision: "51607f3031d9ec1453c258527db5d0735077c631" + exact: "1.0.2-cmux.7.ios17.3" ), ], targets: [ diff --git a/Packages/Shared/CmuxIrxTransport/Package.resolved b/Packages/Shared/CmuxIrxTransport/Package.resolved index 1ab85812d325..2cb07f5ac28f 100644 --- a/Packages/Shared/CmuxIrxTransport/Package.resolved +++ b/Packages/Shared/CmuxIrxTransport/Package.resolved @@ -6,7 +6,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/manaflow-ai/iroh-ffi.git", "state" : { - "revision" : "51607f3031d9ec1453c258527db5d0735077c631" + "revision" : "af08f0e1b9bb3ddb839210b175738d5761fea686", + "version" : "1.0.2-cmux.7.ios17.3" } } ], diff --git a/Packages/Shared/CmuxIrxTransport/Package.swift b/Packages/Shared/CmuxIrxTransport/Package.swift index e2098b796c03..debd5e278e28 100644 --- a/Packages/Shared/CmuxIrxTransport/Package.swift +++ b/Packages/Shared/CmuxIrxTransport/Package.swift @@ -21,7 +21,7 @@ let package = Package( .package(path: "../CmuxIrohTransport"), .package( url: "https://github.com/manaflow-ai/iroh-ffi.git", - revision: "51607f3031d9ec1453c258527db5d0735077c631" + exact: "1.0.2-cmux.7.ios17.3" ), ], targets: [ diff --git a/ios/cmux.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/cmux.xcworkspace/xcshareddata/swiftpm/Package.resolved index cd35861fc4df..3afd759224ba 100644 --- a/ios/cmux.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ios/cmux.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -15,7 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/manaflow-ai/iroh-ffi.git", "state" : { - "revision" : "51607f3031d9ec1453c258527db5d0735077c631" + "revision" : "af08f0e1b9bb3ddb839210b175738d5761fea686", + "version" : "1.0.2-cmux.7.ios17.3" } }, { diff --git a/ios/cmuxPackage/Package.resolved b/ios/cmuxPackage/Package.resolved index 61e83da71e2f..fd355a301b1e 100644 --- a/ios/cmuxPackage/Package.resolved +++ b/ios/cmuxPackage/Package.resolved @@ -15,7 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/manaflow-ai/iroh-ffi.git", "state" : { - "revision" : "51607f3031d9ec1453c258527db5d0735077c631" + "revision" : "af08f0e1b9bb3ddb839210b175738d5761fea686", + "version" : "1.0.2-cmux.7.ios17.3" } }, { From 4a0bd3ab5e7cc72912679050c2f866b8ac391977 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 24 Sep 2026 22:58:17 -0700 Subject: [PATCH 47/49] chore: update Xcode package lockfile --- .../project.xcworkspace/xcshareddata/swiftpm/Package.resolved | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmux.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/cmux.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 606f3cbf7a1e..3f05350f097c 100644 --- a/cmux.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/cmux.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/manaflow-ai/iroh-ffi.git", "state" : { - "revision" : "8da966cad36377e4e55569f80dcde1be91d30a5b", - "version" : "1.0.2-cmux.7.ios17.2" + "revision" : "af08f0e1b9bb3ddb839210b175738d5761fea686", + "version" : "1.0.2-cmux.7.ios17.3" } }, { From 4f98bd3ecbe3f2b05e326294afa2f9935dd53358 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 24 Sep 2026 23:04:00 -0700 Subject: [PATCH 48/49] fix: align Xcode iroh package requirement --- cmux.xcodeproj/project.pbxproj | 2 +- docs/iroh-v2/SECURITY-AUDIT.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index fa7b0217f302..655ca954a046 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -18244,7 +18244,7 @@ repositoryURL = "https://github.com/manaflow-ai/iroh-ffi.git"; requirement = { kind = exactVersion; - version = "1.0.2-cmux.7.ios17.2"; + version = "1.0.2-cmux.7.ios17.3"; }; }; A5001232 /* XCRemoteSwiftPackageReference "Sparkle" */ = { diff --git a/docs/iroh-v2/SECURITY-AUDIT.md b/docs/iroh-v2/SECURITY-AUDIT.md index bb8a89df820b..1be92efbd39d 100644 --- a/docs/iroh-v2/SECURITY-AUDIT.md +++ b/docs/iroh-v2/SECURITY-AUDIT.md @@ -132,8 +132,8 @@ Drizzle's TypeScript loader. Pinning the transitive copy to `0.28.1` matches the version already used by Wrangler. `bun audit` then reports no advisories; the Drizzle loader transform, Worker check and workerd tests pass. -The pinned Iroh FFI release is `1.0.2-cmux.7.ios17.2`, source -`8da966cad36377e4e55569f80dcde1be91d30a5b`. Querying its 483 registry lockfile +The pinned Iroh FFI release is `1.0.2-cmux.7.ios17.3`, source +`af08f0e1b9bb3ddb839210b175738d5761fea686`. Querying its 483 registry lockfile entries against OSV identified three patchable advisories: | Package | Fix in [iroh-ffi #16](https://github.com/manaflow-ai/iroh-ffi/pull/16) | Reachability qualification | From 86504fb13ad565c1e0fb0d84a2f601fd15aad52d Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 25 Sep 2026 00:10:02 -0700 Subject: [PATCH 49/49] fix: import Cloud package for team picker --- Sources/AppDelegate+CloudTeamPicker.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/AppDelegate+CloudTeamPicker.swift b/Sources/AppDelegate+CloudTeamPicker.swift index c5d1b1aaad92..6870e640cb88 100644 --- a/Sources/AppDelegate+CloudTeamPicker.swift +++ b/Sources/AppDelegate+CloudTeamPicker.swift @@ -1,4 +1,5 @@ import AppKit +import CmuxCloud extension AppDelegate { @MainActor