Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
19cbfb1
test: cover Codex native title tab sync
austinywang Sep 6, 2026
be25860
fix: sync Codex native titles to terminal tabs
austinywang Sep 6, 2026
7dee65c
Merge remote-tracking branch 'origin/main' into issue-11144-codex-tab…
austinywang Sep 6, 2026
9cd72d4
fix: harden Codex native title sync
austinywang Sep 6, 2026
eaf9356
fix: expose Codex title handler to async bridge
austinywang Sep 6, 2026
a848541
fix: capture title sync bridge explicitly
austinywang Sep 6, 2026
e82c6c9
fix: retry Codex title reads during SQLite contention
austinywang Sep 6, 2026
98419dd
Merge remote-tracking branch 'origin/main' into issue-11144-codex-tab…
austinywang Sep 6, 2026
2c6f02f
test: cover Codex visible tab header reconciliation
austinywang Sep 6, 2026
436ec7c
fix: reconcile stale Codex tab headers
austinywang Sep 6, 2026
8dcce72
fix: use SQLite busy timeout for native title lookup
austinywang Sep 6, 2026
c99c2dd
Merge remote-tracking branch 'origin/main' into issue-11144-codex-tab…
austinywang Sep 6, 2026
ff9d783
Merge remote-tracking branch 'origin/main' into issue-11144-codex-tab…
austinywang Sep 6, 2026
75f16f0
Merge remote-tracking branch 'origin/main' into issue-11144-codex-tab…
austinywang Sep 6, 2026
2c18ee6
Merge branch 'main' into issue-11144-codex-tab-title
austinywang Sep 6, 2026
2408b69
Merge branch 'main' into issue-11144-codex-tab-title
austinywang Sep 7, 2026
da820c5
Merge branch 'main' into issue-11144-codex-tab-title
austinywang Sep 8, 2026
dde38c4
Merge branch 'main' into issue-11144-codex-tab-title
austinywang Sep 8, 2026
dca65a1
Merge branch 'main' into issue-11144-codex-tab-title
austinywang Sep 8, 2026
ed6a324
Merge remote-tracking branch 'origin/main' into issue-11144-codex-tab…
austinywang Sep 8, 2026
5101355
fix: compile async title socket test
austinywang Sep 8, 2026
f7ff531
Merge remote-tracking branch 'origin/main' into issue-11144-codex-tab…
austinywang Sep 8, 2026
fb92dc3
fix: refresh window title for native Codex sync
austinywang Sep 8, 2026
dd47542
fix: reject stale Codex native title hooks
austinywang Sep 8, 2026
b12f3a9
fix: gate Codex title sync by surface owner
austinywang Sep 8, 2026
23bee2f
test: keep CLI ledger coverage out of app target
austinywang Sep 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions CLI/CMUXCLI+CodexFireAndForgetHooks.swift
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,90 @@ 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
}
let titleStore = CodexNativeTitleStore(
codexHome: normalizedHookValue(environment["CODEX_HOME"])
)
guard let title = titleStore.title(forSessionId: sessionId) else {
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,
"panel_id": surfaceId,
"title": title
])
telemetry.breadcrumb("codex-hook.native-title-sync.sent")
} catch {
telemetry.breadcrumb("codex-hook.native-title-sync.send-failed")
}
}
Comment thread
cursor[bot] marked this conversation as resolved.

/// 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 >/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
Expand Down
14 changes: 14 additions & 0 deletions CLI/CodexTurnLedger.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?,
Expand Down
2 changes: 2 additions & 0 deletions CLI/CodexTurnLedgerPersistence.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(
persist: Bool = true,
_ body: (inout CodexTurnLedgerFile) throws -> T
) throws -> T {
let lockPath = path + ".lock"
Expand All @@ -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"
Expand Down
21 changes: 21 additions & 0 deletions CLI/cmux.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33858,6 +33858,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
Expand Down Expand Up @@ -36258,6 +36269,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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import Foundation
import SQLite3

/// Reads Codex's authoritative native thread title from its state database.
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 func title(forSessionId sessionId: String) -> String? {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
let normalizedSessionId = sessionId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedSessionId.isEmpty else { return nil }

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
}

/// 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)
.appendingPathComponent("state_5.sqlite", isDirectory: false)
.path
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import Foundation
import SQLite3
import Testing

@testable import CMUXAgentLaunch

struct CodexNativeTitleStoreTests {
private enum FixtureError: Error {
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) }

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) }

let store = CodexNativeTitleStore(databasePath: fixture.database.path)
#expect(store.title(forSessionId: sessionId) == nil)
let missingDatabase = fixture.directory.appendingPathComponent("missing.sqlite")
let missingStore = CodexNativeTitleStore(databasePath: missingDatabase.path)
#expect(missingStore.title(forSessionId: sessionId) == nil)
}

/// 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)
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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,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",
Expand Down
Loading
Loading