From 19cbfb1a06f57dae8c8b0e5bfa80b3fd52a0d214 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 6 Sep 2026 09:40:54 -0700 Subject: [PATCH 01/14] test: cover Codex native title tab sync --- .../CodexNativeTitleStoreTests.swift | 64 +++++++++++++++++++ cmuxTests/SetAutoTitleSocketTests.swift | 36 +++++++++++ 2 files changed, 100 insertions(+) create mode 100644 Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/CodexNativeTitleStoreTests.swift diff --git a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/CodexNativeTitleStoreTests.swift b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/CodexNativeTitleStoreTests.swift new file mode 100644 index 000000000000..61c81ba79e90 --- /dev/null +++ b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/CodexNativeTitleStoreTests.swift @@ -0,0 +1,64 @@ +import Foundation +import SQLite3 +import Testing + +@testable import CMUXAgentLaunch + +struct CodexNativeTitleStoreTests { + private enum FixtureError: Error { + case database + } + + @Test func readsTheNativeThreadTitleForAnExactSession() throws { + let fixture = try makeDatabase(sessionId: "codex-session", title: "測試 cmux 與 codex Tab 同步") + defer { try? FileManager.default.removeItem(at: fixture.directory) } + + #expect(CodexNativeTitleStore.title(forSessionId: fixture.sessionId, dbPath: fixture.database.path) == "測試 cmux 與 codex Tab 同步") + } + + @Test(arguments: ["missing-session", ""]) + func returnsNilWhenTheTitleCannotBeResolved(sessionId: String) throws { + let fixture = try makeDatabase(sessionId: "codex-session", title: "existing title") + defer { try? FileManager.default.removeItem(at: fixture.directory) } + + #expect(CodexNativeTitleStore.title(forSessionId: sessionId, dbPath: fixture.database.path) == nil) + let missingDatabase = fixture.directory.appendingPathComponent("missing.sqlite") + #expect(CodexNativeTitleStore.title(forSessionId: sessionId, dbPath: missingDatabase.path) == nil) + } + + private func makeDatabase(sessionId: String, title: String) throws -> (directory: URL, database: URL, sessionId: String) { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-codex-title-test-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let database = directory.appendingPathComponent("state_5.sqlite") + + var connection: OpaquePointer? + guard sqlite3_open(database.path, &connection) == SQLITE_OK, let connection else { + throw FixtureError.database + } + defer { sqlite3_close(connection) } + + let schema = """ + CREATE TABLE threads ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + archived INTEGER NOT NULL DEFAULT 0 + ) + """ + guard sqlite3_exec(connection, schema, nil, nil, nil) == SQLITE_OK else { + throw FixtureError.database + } + + var statement: OpaquePointer? + guard sqlite3_prepare_v2(connection, "INSERT INTO threads (id, title) VALUES (?1, ?2)", -1, &statement, nil) == SQLITE_OK, + let statement else { + throw FixtureError.database + } + defer { sqlite3_finalize(statement) } + let transient = unsafeBitCast(OpaquePointer(bitPattern: -1), to: sqlite3_destructor_type.self) + sqlite3_bind_text(statement, 1, sessionId, -1, transient) + sqlite3_bind_text(statement, 2, title, -1, transient) + guard sqlite3_step(statement) == SQLITE_DONE else { throw FixtureError.database } + return (directory, database, sessionId) + } +} diff --git a/cmuxTests/SetAutoTitleSocketTests.swift b/cmuxTests/SetAutoTitleSocketTests.swift index bcf12e7fa80c..411f4fb172fa 100644 --- a/cmuxTests/SetAutoTitleSocketTests.swift +++ b/cmuxTests/SetAutoTitleSocketTests.swift @@ -293,6 +293,42 @@ import Testing } } + @Test func codexNativeTitleSyncAppliesToRawPanelTitleWithAutoNamingDisabled() throws { + try withAutoNamingSetting(false) { + try withManager { _, workspace in + let pane = try #require(workspace.bonsplitController.allPaneIds.first) + let panelId = try #require(workspace.newTerminalSurface(inPane: pane, focus: true)?.id) + + let envelope = try call(method: "surface.sync_codex_native_title", params: [ + "workspace_id": workspace.id.uuidString, + "panel_id": panelId.uuidString, + "title": "測試 cmux 與 codex Tab 同步" + ]) + let result = try #require(envelope["result"] as? [String: Any]) + #expect(result["applied"] as? Bool == true) + #expect(workspace.panelTitles[panelId] == "測試 cmux 與 codex Tab 同步") + #expect(workspace.panelCustomTitles[panelId] == nil) + } + } + } + + @Test func codexNativeTitleSyncPreservesExistingCustomPanelTitle() throws { + try withManager { _, workspace in + let pane = try #require(workspace.bonsplitController.allPaneIds.first) + let panelId = try #require(workspace.newTerminalSurface(inPane: pane, focus: true)?.id) + _ = workspace.setPanelCustomTitle(panelId: panelId, title: "my renamed tab", source: .user) + + let envelope = try call(method: "surface.sync_codex_native_title", params: [ + "workspace_id": workspace.id.uuidString, + "panel_id": panelId.uuidString, + "title": "fresh Codex conversation title" + ]) + #expect(envelope["ok"] as? Bool == true) + #expect(workspace.panelCustomTitles[panelId] == "my renamed tab") + #expect(workspace.panelCustomTitleSources[panelId] == .user) + } + } + @Test func malformedParamsProduceCleanErrors() throws { try withAutoNamingSetting(true) { try withManager { _, workspace in From be25860aebd6392fe5798c5f28924e588669d8b7 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 6 Sep 2026 09:55:44 -0700 Subject: [PATCH 02/14] fix: sync Codex native titles to terminal tabs --- CLI/CMUXCLI+CodexFireAndForgetHooks.swift | 75 +++++++++++++++++++ CLI/cmux.swift | 21 ++++++ .../CodexNativeTitleStore.swift | 53 +++++++++++++ .../Wire/ControlCommandExecutionPolicy.swift | 2 + .../ControlCommandExecutionPolicyTests.swift | 2 +- Sources/TerminalController.swift | 45 +++++++++++ 6 files changed, 197 insertions(+), 1 deletion(-) create mode 100644 Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexNativeTitleStore.swift diff --git a/CLI/CMUXCLI+CodexFireAndForgetHooks.swift b/CLI/CMUXCLI+CodexFireAndForgetHooks.swift index de765293e55a..1cee19f9db84 100644 --- a/CLI/CMUXCLI+CodexFireAndForgetHooks.swift +++ b/CLI/CMUXCLI+CodexFireAndForgetHooks.swift @@ -77,6 +77,81 @@ extension CMUXCLI { } } + /// Resolves Codex's own thread title in a detached process and sends the + /// plain title to the app. The stop hook must stay short and the app socket + /// handler must not perform Codex database I/O on the main actor. + func runCodexNativeTitleSyncHook( + commandArgs: [String], + environment: [String: String], + client: SocketClient, + telemetry: CLISocketSentryTelemetry + ) { + guard let sessionId = optionValue(commandArgs, name: "--session"), + let workspaceId = optionValue(commandArgs, name: "--workspace"), + let surfaceId = optionValue(commandArgs, name: "--surface"), + !sessionId.isEmpty, !workspaceId.isEmpty, !surfaceId.isEmpty else { + telemetry.breadcrumb("codex-hook.native-title-sync.invalid-target") + return + } + guard let title = CodexNativeTitleStore.title( + forSessionId: sessionId, + codexHome: normalizedHookValue(environment["CODEX_HOME"]) + ) else { + telemetry.breadcrumb("codex-hook.native-title-sync.no-title") + return + } + _ = try? client.sendV2(method: "surface.sync_codex_native_title", params: [ + "workspace_id": workspaceId, + "panel_id": surfaceId, + "title": title + ]) + telemetry.breadcrumb("codex-hook.native-title-sync.sent") + } + + /// Starts the title lookup after the synchronous Codex Stop hook returns. + /// The title is already authoritative in Codex's state database, so this + /// path does not invoke a summarizer or depend on Workspace Auto-Naming. + func spawnDetachedCodexNativeTitleSync( + sessionId: String, + workspaceId: String, + surfaceId: String, + environment: [String: String], + telemetry: CLISocketSentryTelemetry + ) { + guard !sessionId.isEmpty, !workspaceId.isEmpty, !surfaceId.isEmpty else { return } + let selfPath: String = { + if let first = ProcessInfo.processInfo.arguments.first, + first.hasPrefix("/"), + FileManager.default.isExecutableFile(atPath: first) { + return first + } + if let bundled = normalizedHookValue(environment["CMUX_BUNDLED_CLI_PATH"]), + FileManager.default.isExecutableFile(atPath: bundled) { + return bundled + } + return "cmux" + }() + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/sh") + process.arguments = [ + "-c", + "nohup \"$0\" hooks codex sync-native-title --session \"$1\" --workspace \"$2\" --surface \"$3\" /dev/null 2>&1 &", + selfPath, + sessionId, + workspaceId, + surfaceId + ] + process.environment = environment + process.standardInput = FileHandle.nullDevice + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + do { + try process.run() + } catch { + telemetry.breadcrumb("codex-hook.native-title-sync.spawn-failed") + } + } + /// Emit, NUL-separated to stdout, the exact codex arg list the wrapper must /// splice ahead of the user's args to enable cmux hooks for one Codex /// invocation without rewriting the user's Codex configuration. Returns diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 9a2d701252ab..09af5dadba58 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -34119,6 +34119,17 @@ export default CMUXSessionRestore; return } + if def.name == "codex", subcommand == "sync-native-title" { + runCodexNativeTitleSyncHook( + commandArgs: hookArgs, + environment: env, + client: client, + telemetry: telemetry + ) + print("OK") + return + } + if subcommand == "auto-name", autoNamingSource(for: def) != nil { // Detached re-invocation spawned from the codex Stop hook (see // spawnDetachedAgentAutoName): runs the full naming pass without @@ -36521,6 +36532,16 @@ export default CMUXSessionRestore; sendAgentFeedTelemetry(workspaceId: workspaceId, surfaceId: surfaceId) } + if def.name == "codex", !suppressVisibleMutations, !sessionId.isEmpty { + spawnDetachedCodexNativeTitleSync( + sessionId: sessionId, + workspaceId: workspaceId, + surfaceId: surfaceId, + environment: env, + telemetry: telemetry + ) + } + // Opt-in auto-naming for generic-agent sessions: a detached pass so the // summarization subprocess never blocks this short sync hook. // Gate the fork on the live setting (one cheap socket probe) so a diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexNativeTitleStore.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexNativeTitleStore.swift new file mode 100644 index 000000000000..b17259fa5cfd --- /dev/null +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexNativeTitleStore.swift @@ -0,0 +1,53 @@ +import Foundation +import SQLite3 + +/// Reads Codex's authoritative native thread title from its state database. +/// Callers can provide a Codex home or database path for alternate profiles +/// and deterministic fixture tests. +public enum CodexNativeTitleStore { + /// Returns the non-empty title for an active session, or nil when Codex's + /// database, session row, or title is unavailable. + public static func title( + forSessionId sessionId: String, + codexHome: String? = nil, + dbPath: String? = nil + ) -> String? { + let normalizedSessionId = sessionId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedSessionId.isEmpty else { return nil } + let databasePath = dbPath ?? databasePath(forCodexHome: codexHome) + + var database: OpaquePointer? + guard sqlite3_open_v2(databasePath, &database, SQLITE_OPEN_READONLY, nil) == SQLITE_OK, + let database else { + sqlite3_close(database) + return nil + } + defer { sqlite3_close(database) } + sqlite3_busy_timeout(database, 250) + + var statement: OpaquePointer? + let query = "SELECT title FROM threads WHERE id = ?1 AND archived = 0 LIMIT 1" + guard sqlite3_prepare_v2(database, query, -1, &statement, nil) == SQLITE_OK, + let statement else { + sqlite3_finalize(statement) + return nil + } + defer { sqlite3_finalize(statement) } + + let transient = unsafeBitCast(OpaquePointer(bitPattern: -1), to: sqlite3_destructor_type.self) + sqlite3_bind_text(statement, 1, normalizedSessionId, -1, transient) + guard sqlite3_step(statement) == SQLITE_ROW, + let titleBytes = sqlite3_column_text(statement, 0) else { + return nil + } + let title = String(cString: titleBytes).trimmingCharacters(in: .whitespacesAndNewlines) + return title.isEmpty ? nil : title + } + + private static func databasePath(forCodexHome codexHome: String?) -> String { + let home = (codexHome ?? "~/.codex") as NSString + return URL(fileURLWithPath: home.expandingTildeInPath, isDirectory: true) + .appendingPathComponent("state_5.sqlite", isDirectory: false) + .path + } +} diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift index 42829088540e..e8a33f16d8b1 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift @@ -299,6 +299,7 @@ public enum ControlCommandExecutionPolicy: Sendable, Equatable { "notification.create_for_target", "notification.create_for_caller", "workspace.set_auto_title", + "surface.sync_codex_native_title", // The v2 resolution reads (tranche D of issue #5757) — the implicit // handle-normalization reads nearly every CLI invocation pays 1-3 of. // Their nonisolated coordinator bodies @@ -354,6 +355,7 @@ public enum ControlCommandExecutionPolicy: Sendable, Equatable { "notification.create_for_target", "notification.create_for_caller", "workspace.set_auto_title", + "surface.sync_codex_native_title", // The v2 resolution reads: non-blocking single-hop snapshot reads // whose hop collapses inline on a main-thread caller, so they are // safe by construction — and cmuxTests drive them through diff --git a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandExecutionPolicyTests.swift b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandExecutionPolicyTests.swift index 8d4f9669dd60..9bbc542c1bd2 100644 --- a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandExecutionPolicyTests.swift +++ b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandExecutionPolicyTests.swift @@ -272,7 +272,7 @@ struct ControlCommandExecutionPolicyTests { for method in [ "notification.create", "notification.create_for_surface", "notification.create_for_target", "notification.create_for_caller", - "workspace.set_auto_title", + "workspace.set_auto_title", "surface.sync_codex_native_title", ] { let policy = ControlCommandExecutionPolicy(forMethod: method) #expect(policy == .socketWorker(mainThreadCallable: true), "\(method)") diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index f4aed680c5af..d45fcf73bbc3 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -1221,6 +1221,7 @@ class TerminalController { "notification.create_for_target", "notification.create_for_caller", "workspace.set_auto_title", + "surface.sync_codex_native_title", ] nonisolated func socketWorkerV2Response(handling parsedRequest: ControlRequest) -> String? { @@ -2753,6 +2754,8 @@ class TerminalController { return v2Result(id: id, self.v2WorkspaceCloudVMBind(params: params)) case "workspace.set_auto_title": return v2Result(id: id, self.v2WorkspaceSetAutoTitle(params: params)) + case "surface.sync_codex_native_title": + return v2Result(id: id, self.v2SurfaceSyncCodexNativeTitle(params: params)) // Settings/session/feedback: session.restore_previous, settings.open, and // feedback.open handled by ControlCommandCoordinator. @@ -3087,6 +3090,7 @@ class TerminalController { "workspace.prompt_submit", "workspace.rename", "workspace.set_auto_title", + "surface.sync_codex_native_title", "workspace.group.list", "workspace.group.create", "workspace.group.ungroup", @@ -4408,6 +4412,47 @@ class TerminalController { v2MainSync { AppDelegate.shared?.tabManagerFor(tabId: workspaceId) } } + /// `surface.sync_codex_native_title`: applies Codex's already-resolved + /// native thread title to the panel's raw title tier, the same tier used + /// by OSC terminal-title updates. The detached CLI hook owns the database + /// read; this app-side handler only resolves the panel and mutates + /// in-memory workspace state. + private func v2SurfaceSyncCodexNativeTitle(params: [String: Any]) -> V2CallResult { + guard let title = v2String(params, "title")?.trimmingCharacters(in: .whitespacesAndNewlines), + !title.isEmpty else { + return .err(code: "invalid_params", message: "Missing or invalid title", data: nil) + } + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + guard let workspaceId = v2UUID(params, "workspace_id") else { + return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil) + } + guard let panelId = v2UUID(params, "panel_id") else { + return .err(code: "invalid_params", message: "Missing or invalid panel_id", data: nil) + } + + var found = false + var applied = false + v2MainSync { + guard let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) else { return } + let resolvedPanelId = workspace.panels[panelId] != nil + ? panelId + : workspace.panelIdFromSurfaceId(TabID(uuid: panelId)) + guard let resolvedPanelId else { return } + found = true + applied = workspace.updatePanelTitle(panelId: resolvedPanelId, title: title) + } + + guard found else { + return .err(code: "not_found", message: "Panel not found", data: [ + "workspace_id": workspaceId.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId) + ]) + } + return .ok(["applied": applied]) + } + // MARK: - V2 Workspace Methods @MainActor From 9cd72d4ffe88dbf827bb3bc218dac4df9a84140c Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 6 Sep 2026 10:38:52 -0700 Subject: [PATCH 03/14] fix: harden Codex native title sync --- CLI/CMUXCLI+CodexFireAndForgetHooks.swift | 22 ++-- .../CodexNativeTitleStore.swift | 28 +++-- .../CodexNativeTitleStoreTests.swift | 13 ++- .../Wire/ControlCommandExecutionPolicy.swift | 1 - .../ControlCommandExecutionPolicyTests.swift | 9 +- Resources/Localizable.xcstrings | 102 ++++++++++++++++++ ...erminalController+ControlSocketAsync.swift | 10 ++ Sources/TerminalController.swift | 62 +++++++++-- cmuxTests/SetAutoTitleSocketTests.swift | 57 ++++++++-- 9 files changed, 264 insertions(+), 40 deletions(-) diff --git a/CLI/CMUXCLI+CodexFireAndForgetHooks.swift b/CLI/CMUXCLI+CodexFireAndForgetHooks.swift index 1cee19f9db84..6ce6575e60a1 100644 --- a/CLI/CMUXCLI+CodexFireAndForgetHooks.swift +++ b/CLI/CMUXCLI+CodexFireAndForgetHooks.swift @@ -93,19 +93,23 @@ extension CMUXCLI { telemetry.breadcrumb("codex-hook.native-title-sync.invalid-target") return } - guard let title = CodexNativeTitleStore.title( - forSessionId: sessionId, + let titleStore = CodexNativeTitleStore( codexHome: normalizedHookValue(environment["CODEX_HOME"]) - ) else { + ) + guard let title = titleStore.title(forSessionId: sessionId) else { telemetry.breadcrumb("codex-hook.native-title-sync.no-title") return } - _ = try? client.sendV2(method: "surface.sync_codex_native_title", params: [ - "workspace_id": workspaceId, - "panel_id": surfaceId, - "title": title - ]) - telemetry.breadcrumb("codex-hook.native-title-sync.sent") + do { + _ = try client.sendV2(method: "surface.sync_codex_native_title", params: [ + "workspace_id": workspaceId, + "panel_id": surfaceId, + "title": title + ]) + telemetry.breadcrumb("codex-hook.native-title-sync.sent") + } catch { + telemetry.breadcrumb("codex-hook.native-title-sync.send-failed") + } } /// Starts the title lookup after the synchronous Codex Stop hook returns. diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexNativeTitleStore.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexNativeTitleStore.swift index b17259fa5cfd..27825e639dec 100644 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexNativeTitleStore.swift +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexNativeTitleStore.swift @@ -2,19 +2,27 @@ import Foundation import SQLite3 /// Reads Codex's authoritative native thread title from its state database. -/// Callers can provide a Codex home or database path for alternate profiles -/// and deterministic fixture tests. -public enum CodexNativeTitleStore { +public struct CodexNativeTitleStore: Sendable { + private let databasePath: String + + /// Creates a title store for the Codex profile at `codexHome`. + public init(codexHome: String? = nil) { + self.databasePath = Self.databasePath(forCodexHome: codexHome) + } + + /// Creates a title store for a database path. + /// + /// This initializer is internal so production callers select a Codex home, + /// while package tests can inject deterministic SQLite fixtures. + init(databasePath: String) { + self.databasePath = databasePath + } + /// Returns the non-empty title for an active session, or nil when Codex's /// database, session row, or title is unavailable. - public static func title( - forSessionId sessionId: String, - codexHome: String? = nil, - dbPath: String? = nil - ) -> String? { + public func title(forSessionId sessionId: String) -> String? { let normalizedSessionId = sessionId.trimmingCharacters(in: .whitespacesAndNewlines) guard !normalizedSessionId.isEmpty else { return nil } - let databasePath = dbPath ?? databasePath(forCodexHome: codexHome) var database: OpaquePointer? guard sqlite3_open_v2(databasePath, &database, SQLITE_OPEN_READONLY, nil) == SQLITE_OK, @@ -23,7 +31,6 @@ public enum CodexNativeTitleStore { return nil } defer { sqlite3_close(database) } - sqlite3_busy_timeout(database, 250) var statement: OpaquePointer? let query = "SELECT title FROM threads WHERE id = ?1 AND archived = 0 LIMIT 1" @@ -44,6 +51,7 @@ public enum CodexNativeTitleStore { return title.isEmpty ? nil : title } + /// Builds the state-database path for a Codex home directory. private static func databasePath(forCodexHome codexHome: String?) -> String { let home = (codexHome ?? "~/.codex") as NSString return URL(fileURLWithPath: home.expandingTildeInPath, isDirectory: true) diff --git a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/CodexNativeTitleStoreTests.swift b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/CodexNativeTitleStoreTests.swift index 61c81ba79e90..8ae33999b551 100644 --- a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/CodexNativeTitleStoreTests.swift +++ b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/CodexNativeTitleStoreTests.swift @@ -9,23 +9,30 @@ struct CodexNativeTitleStoreTests { case database } + /// Reads the title associated with the requested Codex session only. @Test func readsTheNativeThreadTitleForAnExactSession() throws { let fixture = try makeDatabase(sessionId: "codex-session", title: "測試 cmux 與 codex Tab 同步") defer { try? FileManager.default.removeItem(at: fixture.directory) } - #expect(CodexNativeTitleStore.title(forSessionId: fixture.sessionId, dbPath: fixture.database.path) == "測試 cmux 與 codex Tab 同步") + let store = CodexNativeTitleStore(databasePath: fixture.database.path) + #expect(store.title(forSessionId: fixture.sessionId) == "測試 cmux 與 codex Tab 同步") } + /// Returns no title for missing, empty, or unavailable session data. @Test(arguments: ["missing-session", ""]) func returnsNilWhenTheTitleCannotBeResolved(sessionId: String) throws { let fixture = try makeDatabase(sessionId: "codex-session", title: "existing title") defer { try? FileManager.default.removeItem(at: fixture.directory) } - #expect(CodexNativeTitleStore.title(forSessionId: sessionId, dbPath: fixture.database.path) == nil) + let store = CodexNativeTitleStore(databasePath: fixture.database.path) + #expect(store.title(forSessionId: sessionId) == nil) let missingDatabase = fixture.directory.appendingPathComponent("missing.sqlite") - #expect(CodexNativeTitleStore.title(forSessionId: sessionId, dbPath: missingDatabase.path) == nil) + let missingStore = CodexNativeTitleStore(databasePath: missingDatabase.path) + #expect(missingStore.title(forSessionId: sessionId) == nil) } + /// Creates a temporary SQLite database that mirrors the Codex thread table. + /// Creates a temporary SQLite database that mirrors the Codex thread table. private func makeDatabase(sessionId: String, title: String) throws -> (directory: URL, database: URL, sessionId: String) { let directory = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-codex-title-test-\(UUID().uuidString)", isDirectory: true) diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift index e8a33f16d8b1..7978cf5f7edd 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift @@ -355,7 +355,6 @@ public enum ControlCommandExecutionPolicy: Sendable, Equatable { "notification.create_for_target", "notification.create_for_caller", "workspace.set_auto_title", - "surface.sync_codex_native_title", // The v2 resolution reads: non-blocking single-hop snapshot reads // whose hop collapses inline on a main-thread caller, so they are // safe by construction — and cmuxTests drive them through diff --git a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandExecutionPolicyTests.swift b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandExecutionPolicyTests.swift index 9bbc542c1bd2..e6b380ea567e 100644 --- a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandExecutionPolicyTests.swift +++ b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandExecutionPolicyTests.swift @@ -272,7 +272,7 @@ struct ControlCommandExecutionPolicyTests { for method in [ "notification.create", "notification.create_for_surface", "notification.create_for_target", "notification.create_for_caller", - "workspace.set_auto_title", "surface.sync_codex_native_title", + "workspace.set_auto_title", ] { let policy = ControlCommandExecutionPolicy(forMethod: method) #expect(policy == .socketWorker(mainThreadCallable: true), "\(method)") @@ -283,6 +283,13 @@ struct ControlCommandExecutionPolicyTests { #expect(ControlCommandExecutionPolicy(forMethod: "notification.clear") == .mainActor) } + @Test func codexNativeTitleSyncRunsAsyncOnTheWorker() { + #expect( + ControlCommandExecutionPolicy(forMethod: "surface.sync_codex_native_title") + == .socketWorker(mainThreadCallable: false) + ) + } + @Test func v1CommandsDefaultToTheMainActor() { for command in [ "right_sidebar", "focus_surface", diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index d26295f50651..bba74b85987e 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -341,6 +341,108 @@ } } }, + "socket.surfaceSyncCodexNativeTitle.invalidTitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Missing or invalid title" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タイトルが指定されていないか無効です" + } + } + } + }, + "socket.surfaceSyncCodexNativeTitle.asyncDispatchRequired": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "surface.sync_codex_native_title requires asynchronous socket dispatch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "surface.sync_codex_native_title には非同期ソケットディスパッチが必要です" + } + } + } + }, + "socket.surfaceSyncCodexNativeTitle.tabManagerUnavailable": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "TabManager not available" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "TabManager は利用できません" + } + } + } + }, + "socket.surfaceSyncCodexNativeTitle.workspaceIdInvalid": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Missing or invalid workspace_id" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "workspace_id が指定されていないか無効です" + } + } + } + }, + "socket.surfaceSyncCodexNativeTitle.panelIdInvalid": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Missing or invalid panel_id" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "panel_id が指定されていないか無効です" + } + } + } + }, + "socket.surfaceSyncCodexNativeTitle.panelNotFound": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Panel not found" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "パネルが見つかりません" + } + } + } + }, "socket.error.unknownMethod": { "extractionState": "manual", "localizations": { diff --git a/Sources/TerminalController+ControlSocketAsync.swift b/Sources/TerminalController+ControlSocketAsync.swift index 025019c2876d..02b07df210c7 100644 --- a/Sources/TerminalController+ControlSocketAsync.swift +++ b/Sources/TerminalController+ControlSocketAsync.swift @@ -98,6 +98,16 @@ extension TerminalController { action: action ) } + if authorizedRequest.method == "surface.sync_codex_native_title" { + return await v2MainAsync { + self.v2Result( + id: authorizedRequest.id?.foundationObject, + self.v2SurfaceSyncCodexNativeTitle( + params: authorizedRequest.params.mapValues(\.foundationObject) + ) + ) + } + } if policy.runsOnSocketWorker { // Terminal rename performs an awaited cloud-link mutation. Keep the // actual socket connection task asynchronous instead of parking a diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index e42be7eaa6af..33ae39167e0e 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -1221,7 +1221,6 @@ class TerminalController { "notification.create_for_target", "notification.create_for_caller", "workspace.set_auto_title", - "surface.sync_codex_native_title", ] nonisolated func socketWorkerV2Response(handling parsedRequest: ControlRequest) -> String? { @@ -1230,6 +1229,16 @@ class TerminalController { if let workspaceParamError = v2UnsupportedWorkspaceAliasError(method: request.method, params: request.params) { return v2Result(id: request.id, workspaceParamError) } + if request.method == "surface.sync_codex_native_title" { + return v2Error( + id: request.id, + code: "invalid_dispatch", + message: String( + localized: "socket.surfaceSyncCodexNativeTitle.asyncDispatchRequired", + defaultValue: "surface.sync_codex_native_title requires asynchronous socket dispatch" + ) + ) + } if Self.socketWorkerCoordinatorHopMethods.contains(request.method) { // Mirror processParsedV2Command's tail: one main hop for the // command body, encode after the hop on this worker thread. @@ -4438,16 +4447,44 @@ class TerminalController { private func v2SurfaceSyncCodexNativeTitle(params: [String: Any]) -> V2CallResult { guard let title = v2String(params, "title")?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty else { - return .err(code: "invalid_params", message: "Missing or invalid title", data: nil) + return .err( + code: "invalid_params", + message: String( + localized: "socket.surfaceSyncCodexNativeTitle.invalidTitle", + defaultValue: "Missing or invalid title" + ), + data: nil + ) } guard let tabManager = v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) + return .err( + code: "unavailable", + message: String( + localized: "socket.surfaceSyncCodexNativeTitle.tabManagerUnavailable", + defaultValue: "TabManager not available" + ), + data: nil + ) } guard let workspaceId = v2UUID(params, "workspace_id") else { - return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil) + return .err( + code: "invalid_params", + message: String( + localized: "socket.surfaceSyncCodexNativeTitle.workspaceIdInvalid", + defaultValue: "Missing or invalid workspace_id" + ), + data: nil + ) } guard let panelId = v2UUID(params, "panel_id") else { - return .err(code: "invalid_params", message: "Missing or invalid panel_id", data: nil) + return .err( + code: "invalid_params", + message: String( + localized: "socket.surfaceSyncCodexNativeTitle.panelIdInvalid", + defaultValue: "Missing or invalid panel_id" + ), + data: nil + ) } var found = false @@ -4463,10 +4500,17 @@ class TerminalController { } guard found else { - return .err(code: "not_found", message: "Panel not found", data: [ - "workspace_id": workspaceId.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId) - ]) + return .err( + code: "not_found", + message: String( + localized: "socket.surfaceSyncCodexNativeTitle.panelNotFound", + defaultValue: "Panel not found" + ), + data: [ + "workspace_id": workspaceId.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId) + ] + ) } return .ok(["applied": applied]) } diff --git a/cmuxTests/SetAutoTitleSocketTests.swift b/cmuxTests/SetAutoTitleSocketTests.swift index 411f4fb172fa..eec28368c6e5 100644 --- a/cmuxTests/SetAutoTitleSocketTests.swift +++ b/cmuxTests/SetAutoTitleSocketTests.swift @@ -27,6 +27,18 @@ import Testing return try decodeResponse(TerminalController.shared.handleSocketLine(requestLine)) } + /// Sends a request through the asynchronous socket dispatcher used by + /// real control-socket connections. + private func callAsync(method: String, params: [String: Any]) async throws -> [String: Any] { + let request: [String: Any] = ["id": method, "method": method, "params": params] + let requestData = try JSONSerialization.data(withJSONObject: request) + let requestLine = try #require(String(data: requestData, encoding: .utf8)) + let response = try await #require( + TerminalController.shared.processCommandUsingSocketExecutionPolicyAsync(requestLine) + ) + return try decodeResponse(response) + } + /// Runs `body` with the auto-naming setting forced to `enabled`, restoring /// the user's previous value afterwards. private func withAutoNamingSetting(_ enabled: Bool, _ body: () throws -> T) rethrows -> T { @@ -43,6 +55,24 @@ import Testing return try body() } + /// Runs an asynchronous test body with the auto-naming setting restored. + private func withAutoNamingSettingAsync( + _ enabled: Bool, + _ body: () async throws -> T + ) async rethrows -> T { + let key = AutomationCatalogSection().workspaceAutoNaming.userDefaultsKey + let previous = UserDefaults.standard.object(forKey: key) + UserDefaults.standard.set(enabled, forKey: key) + defer { + if let previous { + UserDefaults.standard.set(previous, forKey: key) + } else { + UserDefaults.standard.removeObject(forKey: key) + } + } + return try await body() + } + /// Runs `body` with the auto-naming agent override set to `slug`, restoring /// the user's previous value afterwards. private func withAutoNamingAgentSetting(_ slug: String, _ body: () throws -> T) rethrows -> T { @@ -67,6 +97,17 @@ import Testing return try body(manager, workspace) } + /// Runs an asynchronous test body with an isolated active tab manager. + private func withManagerAsync( + _ body: @MainActor (TabManager, Workspace) async throws -> T + ) async throws -> T { + let manager = TabManager(autoWelcomeIfNeeded: false) + let workspace = try #require(manager.tabs.first) + TerminalController.shared.setActiveTabManager(manager) + defer { TerminalController.shared.setActiveTabManager(nil) } + return try await body(manager, workspace) + } + @Test func probeReportsSummarizerAgentOverride() throws { try withAutoNamingSetting(true) { // "auto" override → no summarizer_agent (null). @@ -293,13 +334,14 @@ import Testing } } - @Test func codexNativeTitleSyncAppliesToRawPanelTitleWithAutoNamingDisabled() throws { - try withAutoNamingSetting(false) { - try withManager { _, workspace in + /// Applies a native Codex title without requiring auto-naming. + @Test func codexNativeTitleSyncAppliesToRawPanelTitleWithAutoNamingDisabled() async throws { + try await withAutoNamingSettingAsync(false) { + try await withManagerAsync { _, workspace in let pane = try #require(workspace.bonsplitController.allPaneIds.first) let panelId = try #require(workspace.newTerminalSurface(inPane: pane, focus: true)?.id) - let envelope = try call(method: "surface.sync_codex_native_title", params: [ + let envelope = try await callAsync(method: "surface.sync_codex_native_title", params: [ "workspace_id": workspace.id.uuidString, "panel_id": panelId.uuidString, "title": "測試 cmux 與 codex Tab 同步" @@ -312,13 +354,14 @@ import Testing } } - @Test func codexNativeTitleSyncPreservesExistingCustomPanelTitle() throws { - try withManager { _, workspace in + /// Leaves an explicitly renamed panel unchanged when Codex syncs a title. + @Test func codexNativeTitleSyncPreservesExistingCustomPanelTitle() async throws { + try await withManagerAsync { _, workspace in let pane = try #require(workspace.bonsplitController.allPaneIds.first) let panelId = try #require(workspace.newTerminalSurface(inPane: pane, focus: true)?.id) _ = workspace.setPanelCustomTitle(panelId: panelId, title: "my renamed tab", source: .user) - let envelope = try call(method: "surface.sync_codex_native_title", params: [ + let envelope = try await callAsync(method: "surface.sync_codex_native_title", params: [ "workspace_id": workspace.id.uuidString, "panel_id": panelId.uuidString, "title": "fresh Codex conversation title" From eaf9356afe84f7bed8804185e349fbb37f1ffce6 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 6 Sep 2026 10:51:26 -0700 Subject: [PATCH 04/14] fix: expose Codex title handler to async bridge --- Sources/TerminalController.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 33ae39167e0e..8841fed3c893 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -4444,7 +4444,8 @@ class TerminalController { /// by OSC terminal-title updates. The detached CLI hook owns the database /// read; this app-side handler only resolves the panel and mutates /// in-memory workspace state. - private func v2SurfaceSyncCodexNativeTitle(params: [String: Any]) -> V2CallResult { + /// Applies the title on the main actor for the asynchronous socket bridge. + func v2SurfaceSyncCodexNativeTitle(params: [String: Any]) -> V2CallResult { guard let title = v2String(params, "title")?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty else { return .err( From a848541c31a9f19f2e89595e4d75763d7760fd43 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 6 Sep 2026 10:52:21 -0700 Subject: [PATCH 05/14] fix: capture title sync bridge explicitly --- Sources/TerminalController+ControlSocketAsync.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/TerminalController+ControlSocketAsync.swift b/Sources/TerminalController+ControlSocketAsync.swift index 02b07df210c7..7bf3db87fda7 100644 --- a/Sources/TerminalController+ControlSocketAsync.swift +++ b/Sources/TerminalController+ControlSocketAsync.swift @@ -99,7 +99,7 @@ extension TerminalController { ) } if authorizedRequest.method == "surface.sync_codex_native_title" { - return await v2MainAsync { + return await self.v2MainAsync { self.v2Result( id: authorizedRequest.id?.foundationObject, self.v2SurfaceSyncCodexNativeTitle( From e82c6c97463c17a81a49227510783feffcf836e0 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 6 Sep 2026 10:58:43 -0700 Subject: [PATCH 06/14] fix: retry Codex title reads during SQLite contention --- .../Sources/CMUXAgentLaunch/CodexNativeTitleStore.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexNativeTitleStore.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexNativeTitleStore.swift index 27825e639dec..728aa746f030 100644 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexNativeTitleStore.swift +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexNativeTitleStore.swift @@ -43,7 +43,11 @@ public struct CodexNativeTitleStore: Sendable { let transient = unsafeBitCast(OpaquePointer(bitPattern: -1), to: sqlite3_destructor_type.self) sqlite3_bind_text(statement, 1, normalizedSessionId, -1, transient) - guard sqlite3_step(statement) == SQLITE_ROW, + var stepResult = sqlite3_step(statement) + for _ in 0..<3 where stepResult == SQLITE_BUSY { + stepResult = sqlite3_step(statement) + } + guard stepResult == SQLITE_ROW, let titleBytes = sqlite3_column_text(statement, 0) else { return nil } From 2c6f02fcc08b0692b7112c3dd3b8b531f92ac3a3 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 6 Sep 2026 11:30:40 -0700 Subject: [PATCH 07/14] test: cover Codex visible tab header reconciliation --- .../CodexNativeTitleStoreTests.swift | 1 - cmuxTests/SetAutoTitleSocketTests.swift | 21 +++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/CodexNativeTitleStoreTests.swift b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/CodexNativeTitleStoreTests.swift index 8ae33999b551..7211a7a19682 100644 --- a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/CodexNativeTitleStoreTests.swift +++ b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/CodexNativeTitleStoreTests.swift @@ -31,7 +31,6 @@ struct CodexNativeTitleStoreTests { #expect(missingStore.title(forSessionId: sessionId) == nil) } - /// Creates a temporary SQLite database that mirrors the Codex thread table. /// Creates a temporary SQLite database that mirrors the Codex thread table. private func makeDatabase(sessionId: String, title: String) throws -> (directory: URL, database: URL, sessionId: String) { let directory = FileManager.default.temporaryDirectory diff --git a/cmuxTests/SetAutoTitleSocketTests.swift b/cmuxTests/SetAutoTitleSocketTests.swift index eec28368c6e5..48f576610528 100644 --- a/cmuxTests/SetAutoTitleSocketTests.swift +++ b/cmuxTests/SetAutoTitleSocketTests.swift @@ -354,6 +354,27 @@ import Testing } } + /// Repairs the visible tab header when the raw title was already stored. + @Test func codexNativeTitleSyncReconcilesVisibleTabHeader() async throws { + try await withManagerAsync { _, workspace in + let pane = try #require(workspace.bonsplitController.allPaneIds.first) + let panelId = try #require(workspace.newTerminalSurface(inPane: pane, focus: false)?.id) + let tabId = try #require(workspace.surfaceIdFromPanelId(panelId)) + let title = "Codex conversation title" + workspace.panelTitles[panelId] = title + + #expect(workspace.bonsplitController.tab(tabId)?.title != title) + let envelope = try await callAsync(method: "surface.sync_codex_native_title", params: [ + "workspace_id": workspace.id.uuidString, + "panel_id": panelId.uuidString, + "title": title + ]) + let result = try #require(envelope["result"] as? [String: Any]) + #expect(result["applied"] as? Bool == true) + #expect(workspace.bonsplitController.tab(tabId)?.title == title) + } + } + /// Leaves an explicitly renamed panel unchanged when Codex syncs a title. @Test func codexNativeTitleSyncPreservesExistingCustomPanelTitle() async throws { try await withManagerAsync { _, workspace in From 436ec7c63b9906219316b5debf767812a762cd60 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 6 Sep 2026 11:31:20 -0700 Subject: [PATCH 08/14] fix: reconcile stale Codex tab headers --- Sources/Workspace+TitleOwnership.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/Workspace+TitleOwnership.swift b/Sources/Workspace+TitleOwnership.swift index 877d1c2ddf43..6e355bf1ee3b 100644 --- a/Sources/Workspace+TitleOwnership.swift +++ b/Sources/Workspace+TitleOwnership.swift @@ -133,7 +133,7 @@ extension Workspace { didMutatePanelTitle = true } - if didMutatePanelTitle, + if !isRemoteTmuxMirror, let tabId = surfaceIdFromPanelId(panelId), let panel = panels[panelId], let existing = bonsplitController.tab(tabId) { @@ -147,6 +147,7 @@ extension Workspace { title: titleUpdate, hasCustomTitle: hasCustomTitle ) + didMutate = true } } From 8dcce729ff591269c653852a4504368979192ebe Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 6 Sep 2026 12:08:27 -0700 Subject: [PATCH 09/14] fix: use SQLite busy timeout for native title lookup --- .../Sources/CMUXAgentLaunch/CodexNativeTitleStore.swift | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexNativeTitleStore.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexNativeTitleStore.swift index 728aa746f030..b391d2b9b209 100644 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexNativeTitleStore.swift +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexNativeTitleStore.swift @@ -31,6 +31,7 @@ public struct CodexNativeTitleStore: Sendable { return nil } defer { sqlite3_close(database) } + sqlite3_busy_timeout(database, 250) var statement: OpaquePointer? let query = "SELECT title FROM threads WHERE id = ?1 AND archived = 0 LIMIT 1" @@ -43,11 +44,7 @@ public struct CodexNativeTitleStore: Sendable { let transient = unsafeBitCast(OpaquePointer(bitPattern: -1), to: sqlite3_destructor_type.self) sqlite3_bind_text(statement, 1, normalizedSessionId, -1, transient) - var stepResult = sqlite3_step(statement) - for _ in 0..<3 where stepResult == SQLITE_BUSY { - stepResult = sqlite3_step(statement) - } - guard stepResult == SQLITE_ROW, + guard sqlite3_step(statement) == SQLITE_ROW, let titleBytes = sqlite3_column_text(statement, 0) else { return nil } From 51013557febd3f46f30e01d7b5a4d33add08154d Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 8 Sep 2026 08:56:36 -0700 Subject: [PATCH 10/14] fix: compile async title socket test --- cmuxTests/SetAutoTitleSocketTests.swift | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/cmuxTests/SetAutoTitleSocketTests.swift b/cmuxTests/SetAutoTitleSocketTests.swift index 48f576610528..bd007214e69f 100644 --- a/cmuxTests/SetAutoTitleSocketTests.swift +++ b/cmuxTests/SetAutoTitleSocketTests.swift @@ -33,9 +33,8 @@ import Testing let request: [String: Any] = ["id": method, "method": method, "params": params] let requestData = try JSONSerialization.data(withJSONObject: request) let requestLine = try #require(String(data: requestData, encoding: .utf8)) - let response = try await #require( - TerminalController.shared.processCommandUsingSocketExecutionPolicyAsync(requestLine) - ) + let rawResponse = await TerminalController.shared.processCommandUsingSocketExecutionPolicyAsync(requestLine) + let response = try #require(rawResponse) return try decodeResponse(response) } From fb92dc39af66593dcf7e3f033daa596d52c3a601 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 8 Sep 2026 09:11:12 -0700 Subject: [PATCH 11/14] fix: refresh window title for native Codex sync --- Sources/TabManager.swift | 26 +++++++++++-------- Sources/TerminalController.swift | 6 ++++- cmuxTests/SetAutoTitleSocketTests.swift | 33 +++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 11 deletions(-) diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index b160f16e6b26..a576bad19157 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -3849,17 +3849,15 @@ class TabManager: ObservableObject { func flushPendingPanelTitleUpdatesForWorkspaceSnapshot() { panelTitleUpdateCoalescer.flushNow() } - private func updatePanelTitle(tabId: UUID, panelId: UUID, title: String, sourceSurface: TerminalSurface) { - guard let tab = workspacesById[tabId], - let terminalPanel = tab.terminalPanel(for: panelId), - terminalPanel.surface === sourceSurface else { return } + + @discardableResult + func updatePanelTitle(tabId: UUID, panelId: UUID, title: String) -> Bool { + guard let tab = workspacesById[tabId] else { return false } let previousDisplayTitle = resolvedWorkspaceDisplayTitle(for: tab).trimmingCharacters(in: .whitespacesAndNewlines) - _ = tab.updatePanelTitle(panelId: panelId, title: title) - guard !tab.isRemoteTmuxMirror else { return } - if tab.focusedPanelId == panelId { - if selectedTabId == tabId { - updateWindowTitle(for: tab) - } + let applied = tab.updatePanelTitle(panelId: panelId, title: title) + guard !tab.isRemoteTmuxMirror else { return applied } + if tab.focusedPanelId == panelId, selectedTabId == tabId { + updateWindowTitle(for: tab) } let currentDisplayTitle = resolvedWorkspaceDisplayTitle(for: tab).trimmingCharacters(in: .whitespacesAndNewlines) if currentDisplayTitle != previousDisplayTitle { @@ -3872,6 +3870,14 @@ class TabManager: ObservableObject { ] ) } + return applied + } + + private func updatePanelTitle(tabId: UUID, panelId: UUID, title: String, sourceSurface: TerminalSurface) { + guard let tab = workspacesById[tabId], + let terminalPanel = tab.terminalPanel(for: panelId), + terminalPanel.surface === sourceSurface else { return } + _ = updatePanelTitle(tabId: tabId, panelId: panelId, title: title) } func shouldScheduleRawTitleRefresh(forWorkspaceId workspaceId: UUID?) -> Bool { workspaceId == selectedTabId && !PanelTitleUpdateCoalescingSettings.isEnabled(settings: settings) } diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 79acc57638d0..82c281ea4e4e 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -4515,7 +4515,11 @@ class TerminalController { : workspace.panelIdFromSurfaceId(TabID(uuid: panelId)) guard let resolvedPanelId else { return } found = true - applied = workspace.updatePanelTitle(panelId: resolvedPanelId, title: title) + applied = tabManager.updatePanelTitle( + tabId: workspaceId, + panelId: resolvedPanelId, + title: title + ) } guard found else { diff --git a/cmuxTests/SetAutoTitleSocketTests.swift b/cmuxTests/SetAutoTitleSocketTests.swift index bd007214e69f..74a2a6e203c4 100644 --- a/cmuxTests/SetAutoTitleSocketTests.swift +++ b/cmuxTests/SetAutoTitleSocketTests.swift @@ -1,3 +1,4 @@ +import AppKit import Foundation import CmuxSettings import Testing @@ -353,6 +354,38 @@ import Testing } } + @Test func codexNativeTitleSyncRefreshesWindowTitleAndPublishesWorkspaceChange() async throws { + try await withManagerAsync { manager, workspace in + let panelId = try #require(workspace.focusedPanelId) + manager.selectedTabId = workspace.id + let window = NSWindow() + manager.window = window + window.title = "stale window title" + let title = "Codex native window title" + var notifiedWorkspaceIds: [UUID] = [] + let observer = NotificationCenter.default.addObserver( + forName: .workspaceTitleDidChange, + object: manager, + queue: nil + ) { notification in + if let workspaceId = notification.userInfo?[GhosttyNotificationKey.tabId] as? UUID { + notifiedWorkspaceIds.append(workspaceId) + } + } + defer { NotificationCenter.default.removeObserver(observer) } + + let envelope = try await callAsync(method: "surface.sync_codex_native_title", params: [ + "workspace_id": workspace.id.uuidString, + "panel_id": panelId.uuidString, + "title": title + ]) + let result = try #require(envelope["result"] as? [String: Any]) + #expect(result["applied"] as? Bool == true) + #expect(window.title == title) + #expect(notifiedWorkspaceIds == [workspace.id]) + } + } + /// Repairs the visible tab header when the raw title was already stored. @Test func codexNativeTitleSyncReconcilesVisibleTabHeader() async throws { try await withManagerAsync { _, workspace in From dd4754253738c2fbf5fdf66cea62dafa6ef40ede Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 8 Sep 2026 09:26:16 -0700 Subject: [PATCH 12/14] fix: reject stale Codex native title hooks --- CLI/CMUXCLI+CodexFireAndForgetHooks.swift | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CLI/CMUXCLI+CodexFireAndForgetHooks.swift b/CLI/CMUXCLI+CodexFireAndForgetHooks.swift index 61fe4b57fb45..59c9617784b9 100644 --- a/CLI/CMUXCLI+CodexFireAndForgetHooks.swift +++ b/CLI/CMUXCLI+CodexFireAndForgetHooks.swift @@ -93,6 +93,15 @@ extension CMUXCLI { telemetry.breadcrumb("codex-hook.native-title-sync.invalid-target") return } + let sessionStore = ClaudeHookSessionStore(processEnv: environment) + guard (try? sessionStore.isCurrent( + sessionId: sessionId, + workspaceId: workspaceId, + surfaceId: surfaceId + )) ?? false else { + telemetry.breadcrumb("codex-hook.native-title-sync.stale") + return + } let titleStore = CodexNativeTitleStore( codexHome: normalizedHookValue(environment["CODEX_HOME"]) ) From b12f3a94e0e0efa45fef4ef0d395b495cfe43ce8 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 8 Sep 2026 09:38:22 -0700 Subject: [PATCH 13/14] fix: gate Codex title sync by surface owner --- CLI/CMUXCLI+CodexFireAndForgetHooks.swift | 14 ++++----- CLI/CodexTurnLedger.swift | 14 +++++++++ CLI/CodexTurnLedgerPersistence.swift | 2 ++ cmuxTests/SetAutoTitleSocketTests.swift | 36 +++++++++++++++++++++++ 4 files changed, 57 insertions(+), 9 deletions(-) diff --git a/CLI/CMUXCLI+CodexFireAndForgetHooks.swift b/CLI/CMUXCLI+CodexFireAndForgetHooks.swift index 59c9617784b9..9d9948d2e699 100644 --- a/CLI/CMUXCLI+CodexFireAndForgetHooks.swift +++ b/CLI/CMUXCLI+CodexFireAndForgetHooks.swift @@ -93,15 +93,6 @@ extension CMUXCLI { telemetry.breadcrumb("codex-hook.native-title-sync.invalid-target") return } - let sessionStore = ClaudeHookSessionStore(processEnv: environment) - guard (try? sessionStore.isCurrent( - sessionId: sessionId, - workspaceId: workspaceId, - surfaceId: surfaceId - )) ?? false else { - telemetry.breadcrumb("codex-hook.native-title-sync.stale") - return - } let titleStore = CodexNativeTitleStore( codexHome: normalizedHookValue(environment["CODEX_HOME"]) ) @@ -109,6 +100,11 @@ extension CMUXCLI { telemetry.breadcrumb("codex-hook.native-title-sync.no-title") return } + let turnLedger = CodexTurnLedger(environment: environment) + guard (try? turnLedger.isCurrent(sessionID: sessionId, surfaceID: surfaceId)) ?? true else { + telemetry.breadcrumb("codex-hook.native-title-sync.stale") + return + } do { _ = try client.sendV2(method: "surface.sync_codex_native_title", params: [ "workspace_id": workspaceId, diff --git a/CLI/CodexTurnLedger.swift b/CLI/CodexTurnLedger.swift index c64c3433357f..d52791c52d5b 100644 --- a/CLI/CodexTurnLedger.swift +++ b/CLI/CodexTurnLedger.swift @@ -56,6 +56,20 @@ final class CodexTurnLedger { encoder.outputFormatting = [.prettyPrinted, .sortedKeys] } deinit {} + + func isCurrent(sessionID: String, surfaceID: String) throws -> Bool { + guard let normalizedSessionID = Self.normalized(sessionID), + let normalizedSurfaceID = Self.normalized(surfaceID) else { + return false + } + return try withLockedState(persist: false) { state in + guard let ownerSessionID = state.surfaceOwners[normalizedSurfaceID] else { + return true + } + return ownerSessionID == normalizedSessionID + } + } + func sessionStart( sessionID: String, workspaceID: String?, diff --git a/CLI/CodexTurnLedgerPersistence.swift b/CLI/CodexTurnLedgerPersistence.swift index bd21ef5ccc5c..d428373d86d4 100644 --- a/CLI/CodexTurnLedgerPersistence.swift +++ b/CLI/CodexTurnLedgerPersistence.swift @@ -109,6 +109,7 @@ extension CodexTurnLedger { /// Serializes only the cross-process read/modify/atomic-replace transaction; /// the lock is released before any socket or UI work begins. func withLockedState( + persist: Bool = true, _ body: (inout CodexTurnLedgerFile) throws -> T ) throws -> T { let lockPath = path + ".lock" @@ -130,6 +131,7 @@ extension CodexTurnLedger { var state = try load() let result = try body(&state) + guard persist else { return result } let data = try encoder.encode(state) let temporary = parent.appendingPathComponent( ".\(parent.lastPathComponent).codex-ledger.\(UUID().uuidString).tmp" diff --git a/cmuxTests/SetAutoTitleSocketTests.swift b/cmuxTests/SetAutoTitleSocketTests.swift index 74a2a6e203c4..76e2d79dd2ae 100644 --- a/cmuxTests/SetAutoTitleSocketTests.swift +++ b/cmuxTests/SetAutoTitleSocketTests.swift @@ -386,6 +386,42 @@ import Testing } } + @Test func codexNativeTitleSyncUsesCodexSurfaceOwnership() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-codex-title-ledger-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let ledgerPath = root.appendingPathComponent("codex-turn-ledger.json").path + let environment = [CodexHookInvocation.ledgerPathEnvironmentKey: ledgerPath] + let oldLedger = CodexTurnLedger(environment: environment) + let oldInvocation = CodexHookInvocation( + environment: [CodexHookInvocation.tokenEnvironmentKey: "old"], + fallbackObservedPID: nil + ) + _ = try oldLedger.sessionStart( + sessionID: "old-session", + workspaceID: "workspace", + surfaceID: "surface", + invocation: oldInvocation + ) + + let newLedger = CodexTurnLedger(environment: environment) + let newInvocation = CodexHookInvocation( + environment: [CodexHookInvocation.tokenEnvironmentKey: "new"], + fallbackObservedPID: nil + ) + _ = try newLedger.sessionStart( + sessionID: "new-session", + workspaceID: "workspace", + surfaceID: "surface", + invocation: newInvocation + ) + + #expect(try newLedger.isCurrent(sessionID: "new-session", surfaceID: "surface")) + #expect(try !newLedger.isCurrent(sessionID: "old-session", surfaceID: "surface")) + } + /// Repairs the visible tab header when the raw title was already stored. @Test func codexNativeTitleSyncReconcilesVisibleTabHeader() async throws { try await withManagerAsync { _, workspace in From 23bee2f7e53c3147516170f8046f0df1806210f7 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 8 Sep 2026 09:58:12 -0700 Subject: [PATCH 14/14] test: keep CLI ledger coverage out of app target --- cmuxTests/SetAutoTitleSocketTests.swift | 36 ------------------------- 1 file changed, 36 deletions(-) diff --git a/cmuxTests/SetAutoTitleSocketTests.swift b/cmuxTests/SetAutoTitleSocketTests.swift index 76e2d79dd2ae..74a2a6e203c4 100644 --- a/cmuxTests/SetAutoTitleSocketTests.swift +++ b/cmuxTests/SetAutoTitleSocketTests.swift @@ -386,42 +386,6 @@ import Testing } } - @Test func codexNativeTitleSyncUsesCodexSurfaceOwnership() throws { - let root = FileManager.default.temporaryDirectory - .appendingPathComponent("cmux-codex-title-ledger-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: root) } - - let ledgerPath = root.appendingPathComponent("codex-turn-ledger.json").path - let environment = [CodexHookInvocation.ledgerPathEnvironmentKey: ledgerPath] - let oldLedger = CodexTurnLedger(environment: environment) - let oldInvocation = CodexHookInvocation( - environment: [CodexHookInvocation.tokenEnvironmentKey: "old"], - fallbackObservedPID: nil - ) - _ = try oldLedger.sessionStart( - sessionID: "old-session", - workspaceID: "workspace", - surfaceID: "surface", - invocation: oldInvocation - ) - - let newLedger = CodexTurnLedger(environment: environment) - let newInvocation = CodexHookInvocation( - environment: [CodexHookInvocation.tokenEnvironmentKey: "new"], - fallbackObservedPID: nil - ) - _ = try newLedger.sessionStart( - sessionID: "new-session", - workspaceID: "workspace", - surfaceID: "surface", - invocation: newInvocation - ) - - #expect(try newLedger.isCurrent(sessionID: "new-session", surfaceID: "surface")) - #expect(try !newLedger.isCurrent(sessionID: "old-session", surfaceID: "surface")) - } - /// Repairs the visible tab header when the raw title was already stored. @Test func codexNativeTitleSyncReconcilesVisibleTabHeader() async throws { try await withManagerAsync { _, workspace in