Skip to content

Commit ed6f1bb

Browse files
Haoo-7Haoo
andauthored
fix: support Codex Desktop hosted by ChatGPT (#267)
Co-authored-by: Haoo <haoo@HaoodeMacBook-Air-2.local>
1 parent b2db895 commit ed6f1bb

5 files changed

Lines changed: 350 additions & 54 deletions

File tree

Sources/CodeIsland/AppState+TranscriptTailer.swift

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,55 @@ extension AppState {
2323
sessions[sessionId] = session
2424
}
2525

26+
if sessions[sessionId]?.source == "codex",
27+
let turnStatus = Self.latestCodexTurnStatus(path: path),
28+
var session = sessions[sessionId] {
29+
switch turnStatus {
30+
case .processing:
31+
session.status = .processing
32+
session.interrupted = false
33+
session.taskRoundEnded = false
34+
case .idle:
35+
session.status = .idle
36+
session.currentTool = nil
37+
session.toolDescription = nil
38+
}
39+
if let modifiedAt = (try? FileManager.default.attributesOfItem(atPath: path))?[.modificationDate] as? Date {
40+
session.lastActivity = modifiedAt
41+
}
42+
sessions[sessionId] = session
43+
}
44+
2645
transcriptTailer.attach(sessionId: sessionId, filePath: path)
2746
}
2847

48+
private nonisolated static func latestCodexTurnStatus(path: String) -> ConversationTurnStatus? {
49+
guard let handle = FileHandle(forReadingAtPath: path) else { return nil }
50+
defer { handle.closeFile() }
51+
52+
// A long Codex turn can place its task_started event well before the
53+
// final 128 KB after emitting large reasoning/tool rows. Scan in chunks
54+
// so startup state recovery remains bounded in memory without missing
55+
// that event.
56+
handle.seek(toFileOffset: 0)
57+
let chunkSize = 64 * 1024
58+
var pendingFragment = Data()
59+
var latestStatus: ConversationTurnStatus?
60+
61+
while true {
62+
let chunk = handle.readData(ofLength: chunkSize)
63+
if chunk.isEmpty { break }
64+
65+
let result = JSONLTailer.scanLines(pendingFragment + chunk)
66+
pendingFragment = result.trailingFragment
67+
if let turnStatus = result.delta.turnStatus {
68+
latestStatus = turnStatus
69+
}
70+
}
71+
72+
return latestStatus
73+
}
74+
2975
/// Stop watching a session's transcript. Called when the session is removed or
3076
/// when a new transcript path supersedes an older one.
3177
func detachTranscriptTailer(sessionId: String) {
@@ -38,6 +84,28 @@ extension AppState {
3884
guard var session = sessions[delta.sessionId] else { return }
3985
var mutated = false
4086

87+
if delta.hasActivity {
88+
session.lastActivity = Date()
89+
mutated = true
90+
}
91+
92+
if let turnStatus = delta.turnStatus {
93+
switch turnStatus {
94+
case .processing:
95+
session.status = .processing
96+
session.interrupted = false
97+
session.taskRoundEnded = false
98+
case .idle:
99+
session.status = .idle
100+
session.currentTool = nil
101+
session.toolDescription = nil
102+
}
103+
// A status-only event is still activity. This matters for a long Codex
104+
// turn whose transcript has not emitted a message yet.
105+
session.lastActivity = Date()
106+
mutated = true
107+
}
108+
41109
if let prompt = delta.lastUserPrompt, session.lastUserPrompt != prompt {
42110
session.lastUserPrompt = prompt
43111
if session.recentMessages.last(where: { $0.isUser })?.text != prompt {

Sources/CodeIsland/AppState.swift

Lines changed: 133 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)