@@ -1071,6 +1071,17 @@ final class AppState {
10711071 return
10721072 }
10731073
1074+ let source = event. rawJSON [ " _source " ] as? String
1075+ let hasTranscriptPath = ( event. rawJSON [ " transcript_path " ] as? String )
1076+ . map { !$0. isEmpty } ?? false
1077+ if Self . isCodexPlaceholderHook (
1078+ source: source,
1079+ cwd: event. rawJSON [ " cwd " ] as? String ,
1080+ hasTranscriptPath: hasTranscriptPath
1081+ ) {
1082+ return
1083+ }
1084+
10741085 let sessionId = event. sessionId ?? " default "
10751086
10761087 // Skip Codex APP internal sessions (title generation, etc.) — they have no transcript
@@ -2436,6 +2447,10 @@ final class AppState {
24362447 if backfillSessionMessages ( sessionId: info. sessionId, from: info) {
24372448 didMutate = true
24382449 }
2450+ if sessions [ info. sessionId] ? . cwd != info. cwd {
2451+ sessions [ info. sessionId] ? . cwd = info. cwd
2452+ didMutate = true
2453+ }
24392454 if let path = info. transcriptPath, sessions [ info. sessionId] ? . transcriptPath != path {
24402455 sessions [ info. sessionId] ? . transcriptPath = path
24412456 didMutate = true
@@ -2489,6 +2504,10 @@ final class AppState {
24892504 if backfillSessionMessages ( sessionId: existingKey, from: info) {
24902505 didMutate = true
24912506 }
2507+ if sessions [ existingKey] ? . cwd != info. cwd {
2508+ sessions [ existingKey] ? . cwd = info. cwd
2509+ didMutate = true
2510+ }
24922511 if let path = info. transcriptPath, sessions [ existingKey] ? . transcriptPath != path {
24932512 sessions [ existingKey] ? . transcriptPath = path
24942513 didMutate = true
@@ -4118,15 +4137,59 @@ final class AppState {
41184137
41194138 /// Find running Codex processes.
41204139 /// Checks both executable path (Desktop app) and command-line args (npm/Homebrew: node script).
4140+ nonisolated static func isCodexExecutablePath( _ path: String ) -> Bool {
4141+ let executableURL = URL ( fileURLWithPath: path) . standardizedFileURL
4142+ let lowerPath = executableURL. path. lowercased ( )
4143+ let resourceSuffix = " /contents/resources/codex "
4144+ guard lowerPath. hasSuffix ( resourceSuffix) else { return false }
4145+
4146+ // Since Codex was folded into ChatGPT Desktop, the same com.openai.codex
4147+ // bundle can now be installed as ChatGPT.app instead of Codex.app. Read
4148+ // the bundle identifier first so future app renames continue to work.
4149+ let appURL = executableURL
4150+ . deletingLastPathComponent ( ) // Resources
4151+ . deletingLastPathComponent ( ) // Contents
4152+ . deletingLastPathComponent ( ) // *.app
4153+ if Bundle ( url: appURL) ? . bundleIdentifier == AppState . codexAppBundleId {
4154+ return true
4155+ }
4156+
4157+ // Keep the legacy path check for synthetic/test bundles without an
4158+ // Info.plist and for older installations whose bundle cannot be read.
4159+ let appName = appURL. deletingPathExtension ( ) . lastPathComponent. lowercased ( )
4160+ return appName == " codex " || appName == " chatgpt "
4161+ }
4162+
4163+ /// Codex Desktop's shared app-server is launched with `/` as its cwd. Its
4164+ /// rollout metadata contains the project cwd, so desktop discovery must
4165+ /// use that value instead of comparing every transcript to `/`.
4166+ nonisolated static func codexDiscoveryUsesTranscriptCwd( processCwd: String ? ) -> Bool {
4167+ guard let processCwd, !processCwd. isEmpty else { return true }
4168+ return processCwd == " / "
4169+ }
4170+
4171+ /// Codex Desktop currently invokes some hooks without a payload, or with
4172+ /// the shared app-server's root cwd. Those events contain no session data
4173+ /// and would overwrite a session discovered from its rollout transcript.
4174+ nonisolated static func isCodexPlaceholderHook(
4175+ source: String ? ,
4176+ cwd: String ? ,
4177+ hasTranscriptPath: Bool
4178+ ) -> Bool {
4179+ guard source? . lowercased ( ) == " codex " , !hasTranscriptPath else { return false }
4180+ return cwd == nil || cwd? . trimmingCharacters ( in: . whitespacesAndNewlines) == " / "
4181+ }
4182+
41214183 private nonisolated static func findCodexPids( candidatePids: [ pid_t ] ? = nil ) -> [ pid_t ] {
41224184 var codexPids : [ pid_t ] = [ ]
41234185
41244186 for pid in candidatePids ?? allProcessIds ( ) {
41254187 guard let path = executablePath ( for: pid) else { continue }
41264188 let pathLower = path. lowercased ( )
41274189
4128- // Match 1: Codex Desktop app (native binary)
4129- if pathLower. contains ( " codex.app/contents/ " ) && pathLower. hasSuffix ( " /codex " ) {
4190+ // Match 1: Codex Desktop app (native binary). The executable may
4191+ // live under Codex.app or ChatGPT.app depending on the release.
4192+ if isCodexExecutablePath ( path) {
41304193 codexPids. append ( pid)
41314194 continue
41324195 }
@@ -4191,50 +4254,66 @@ final class AppState {
41914254 var seenSessionIds : Set < String > = [ ]
41924255
41934256 for pid in codexPids {
4194- guard let cwd = getCwd ( for: pid) , !cwd. isEmpty, !isSubagentWorktree( cwd) else {
4195- // getCwd failed
4257+ let processCwd = getCwd ( for: pid)
4258+ let useTranscriptCwd = codexDiscoveryUsesTranscriptCwd ( processCwd: processCwd)
4259+ if !useTranscriptCwd,
4260+ let processCwd,
4261+ isSubagentWorktree ( processCwd) {
41964262 continue
41974263 }
4198- // pid found
4264+
41994265 let processStart = getProcessStartTime ( pid)
42004266
4201- // Codex stores sessions in date-based dirs: ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl
4202- // Scan recent directories for matching session files
4203- guard let bestFile = findRecentCodexSession ( base: sessionsBase, cwd: cwd, after: processStart, fm: fm) else {
4204- // no session file found
4205- continue
4267+ // Codex stores sessions in date-based dirs: ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl.
4268+ // A terminal process maps to one cwd; the shared Desktop app-server
4269+ // may own several sessions, so inspect all fresh rollouts instead.
4270+ let files : [ String ]
4271+ if useTranscriptCwd {
4272+ files = findRecentCodexSessions ( base: sessionsBase, after: processStart, fm: fm)
4273+ } else if let processCwd,
4274+ let bestFile = findRecentCodexSession (
4275+ base: sessionsBase,
4276+ cwd: processCwd,
4277+ after: processStart,
4278+ fm: fm
4279+ ) {
4280+ files = [ bestFile]
4281+ } else {
4282+ files = [ ]
42064283 }
42074284
4208- // Extract session ID from filename: rollout-{date}-{uuid}.jsonl
4209- let fileName = ( bestFile as NSString ) . lastPathComponent
4210- let sessionId = extractCodexSessionId ( from: fileName)
4211- guard !sessionId. isEmpty, !seenSessionIds. contains ( sessionId) else { continue }
4212- seenSessionIds. insert ( sessionId)
4285+ for file in files {
4286+ let fileName = ( file as NSString ) . lastPathComponent
4287+ let sessionId = extractCodexSessionId ( from: fileName)
4288+ guard !sessionId. isEmpty, !seenSessionIds. contains ( sessionId) else { continue }
4289+ seenSessionIds. insert ( sessionId)
42134290
4214- let modifiedAt = ( try ? fm. attributesOfItem ( atPath: bestFile) ) ? [ . modificationDate] as? Date ?? Date ( )
4291+ let modifiedAt = ( try ? fm. attributesOfItem ( atPath: file) ) ? [ . modificationDate] as? Date ?? Date ( )
4292+ let codexFreshnessLimit : TimeInterval = processStart != nil ? - 300 : - 30
4293+ if modifiedAt. timeIntervalSinceNow < codexFreshnessLimit { continue }
42154294
4216- // Skip stale transcripts: tighter window when processStart is unknown
4217- let codexFreshnessLimit : TimeInterval = processStart != nil ? - 300 : - 30
4218- if modifiedAt. timeIntervalSinceNow < codexFreshnessLimit { continue }
4295+ let sessionCwd = codexSessionCwd ( path: file) ?? processCwd
4296+ guard let sessionCwd, !sessionCwd. isEmpty, !isSubagentWorktree( sessionCwd) else { continue }
42194297
4220- let ( model, messages) = readRecentFromCodexTranscript ( path: bestFile )
4221- let subagentMetadata = codexSubagentMetadata ( inTranscriptPath: bestFile )
4298+ let ( model, messages) = readRecentFromCodexTranscript ( path: file )
4299+ let subagentMetadata = codexSubagentMetadata ( inTranscriptPath: file )
42224300
4223- results. append ( DiscoveredSession (
4224- sessionId: sessionId,
4225- cwd: cwd,
4226- tty: nil ,
4227- model: model,
4228- pid: pid,
4229- modifiedAt: modifiedAt,
4230- recentMessages: messages,
4231- source: " codex " ,
4232- transcriptPath: bestFile,
4233- parentSessionId: subagentMetadata? . parentThreadId,
4234- subagentStatus: codexThreadSpawnStatus ( childThreadId: sessionId) ,
4235- agentType: subagentMetadata? . agentType,
4236- agentNickname: subagentMetadata? . agentNickname
4237- ) )
4301+ results. append ( DiscoveredSession (
4302+ sessionId: sessionId,
4303+ cwd: sessionCwd,
4304+ tty: nil ,
4305+ model: model,
4306+ pid: pid,
4307+ modifiedAt: modifiedAt,
4308+ recentMessages: messages,
4309+ source: " codex " ,
4310+ transcriptPath: file,
4311+ parentSessionId: subagentMetadata? . parentThreadId,
4312+ subagentStatus: codexThreadSpawnStatus ( childThreadId: sessionId) ,
4313+ agentType: subagentMetadata? . agentType,
4314+ agentNickname: subagentMetadata? . agentNickname
4315+ ) )
4316+ }
42384317 }
42394318 return results
42404319 }
@@ -4374,6 +4453,11 @@ final class AppState {
43744453 /// Find the most recent Codex session file matching a CWD
43754454 /// Scans back up to 7 days to cover long-running sessions that span day boundaries
43764455 private nonisolated static func findRecentCodexSession( base: String , cwd: String , after: Date ? , fm: FileManager ) -> String ? {
4456+ findRecentCodexSessions ( base: base, after: after, fm: fm)
4457+ . first ( where: { codexSessionMatchesCwd ( path: $0, cwd: cwd) } )
4458+ }
4459+
4460+ private nonisolated static func findRecentCodexSessions( base: String , after: Date ? , fm: FileManager ) -> [ String ] {
43774461 let cal = Calendar . current
43784462 let now = Date ( )
43794463 var dirs : [ String ] = [ ]
@@ -4387,11 +4471,12 @@ final class AppState {
43874471 dirs. append ( dir)
43884472 }
43894473 }
4390- guard !dirs. isEmpty else { return nil }
4391- return scanCodexDir ( dirs: dirs, cwd : cwd , after: after, fm: fm)
4474+ guard !dirs. isEmpty else { return [ ] }
4475+ return scanCodexDir ( dirs: dirs, after: after, fm: fm)
43924476 }
43934477
4394- private nonisolated static func scanCodexDir( dirs: [ String ] , cwd: String , after: Date ? , fm: FileManager ) -> String ? {
4478+ private nonisolated static func scanCodexDir( dirs: [ String ] , after: Date ? , fm: FileManager ) -> [ String ] {
4479+ var results : [ String ] = [ ]
43954480 for dir in dirs {
43964481 guard let files = try ? fm. contentsOfDirectory ( atPath: dir) else { continue }
43974482 // Sort descending to check newest first
@@ -4405,26 +4490,24 @@ final class AppState {
44054490 modified < start. addingTimeInterval ( - 10 ) {
44064491 continue
44074492 }
4408- if codexSessionMatchesCwd ( path: fullPath, cwd: cwd) {
4409- return fullPath
4410- }
4493+ results. append ( fullPath)
44114494 }
44124495 }
4413- return nil
4496+ return results
44144497 }
44154498
44164499 /// Check if a Codex session file's CWD matches the target
44174500 private nonisolated static func codexSessionMatchesCwd( path: String , cwd: String ) -> Bool {
4418- guard let handle = FileHandle ( forReadingAtPath : path) else { return false }
4419- defer { handle . closeFile ( ) }
4420- let data = handle . readData ( ofLength : 4096 ) // First line is enough
4421- guard let text = String ( data : data , encoding : . utf8 ) ,
4422- let firstLine = text . components ( separatedBy : " \n " ) . first ,
4501+ codexSessionCwd ( path : path) == cwd
4502+ }
4503+
4504+ nonisolated static func codexSessionCwd ( path : String ) -> String ? {
4505+ guard let firstLine = readFirstLine ( path : path ) ,
44234506 let lineData = firstLine. data ( using: . utf8) ,
44244507 let json = try ? JSONSerialization . jsonObject ( with: lineData) as? [ String : Any ] ,
44254508 let payload = json [ " payload " ] as? [ String : Any ] ,
4426- let sessionCwd = payload [ " cwd " ] as? String else { return false }
4427- return sessionCwd == cwd
4509+ let sessionCwd = payload [ " cwd " ] as? String else { return nil }
4510+ return sessionCwd
44284511 }
44294512
44304513 /// Extract session ID from Codex filename: rollout-2026-04-04T20-54-48-{uuid}.jsonl
0 commit comments