From 24a5e6caefe0cc9416b5446430397b5d342aad9f Mon Sep 17 00:00:00 2001 From: jackrescuer-gif Date: Sun, 24 May 2026 21:58:46 +0300 Subject: [PATCH] fix: dedupe Claude sessions imported by Codex Desktop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex Desktop >= 0.133.0-alpha.1 ingests Claude Code sessions from ~/.claude/projects/**/*.jsonl into ~/.codex/sessions/ and registers them in session_index.jsonl with a fresh UUIDv7 thread id. The original Claude file stays in place, so the same conversation appeared twice in codbash — once correctly as a Claude session and once incorrectly as a Codex session. Read ~/.codex/external_agent_session_imports.json and skip imported rollouts whose source path still exists on disk. When the source has been deleted, retain the Codex copy so no history is lost. Also watch the ledger file in the rescan-change detector to close a race where Codex writes the rollout/index slightly before the ledger and a poll fires in between. --- src/data.js | 66 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/src/data.js b/src/data.js index f84e23e..cc9f366 100644 --- a/src/data.js +++ b/src/data.js @@ -834,6 +834,48 @@ function parseCodexSessionIndex(codexDir) { return titles; } +// Codex Desktop (>= 0.133.0-alpha.1) ingests Claude Code sessions from +// ~/.claude/projects/**/*.jsonl, rewrites them as Codex rollouts under +// ~/.codex/sessions/YYYY/MM/DD/, and registers them in session_index.jsonl. +// The imported rollout gets a fresh UUIDv7 (originator: "Codex Desktop", +// turn_id: "external-import-turn-1"), while the original Claude file with its +// own UUIDv4 session id stays in place. Codbash loads both — the user sees the +// same conversation twice, once as a Claude session and once as a fake Codex +// session. +// +// The ledger ~/.codex/external_agent_session_imports.json maps each import's +// new thread id back to the source Claude path. We use it to skip imports +// whose original file still exists on disk. If the source has been deleted, +// we keep the Codex copy so no history is lost. +function parseCodexExternalImports(codexDir) { + const importedThreadIds = new Set(); + const ledgerFile = path.join(codexDir, 'external_agent_session_imports.json'); + if (!fs.existsSync(ledgerFile)) return importedThreadIds; + let raw; + try { + raw = fs.readFileSync(ledgerFile, 'utf8'); + } catch { + return importedThreadIds; + } + let data; + try { + data = JSON.parse(raw); + } catch { + return importedThreadIds; + } + const records = Array.isArray(data && data.records) ? data.records : []; + for (const rec of records) { + if (!rec || typeof rec.imported_thread_id !== 'string') continue; + const src = typeof rec.source_path === 'string' ? rec.source_path : ''; + // Only skip the Codex copy when the original is still on disk — otherwise + // the imported rollout is the only surviving copy of the conversation. + if (src && fs.existsSync(src)) { + importedThreadIds.add(rec.imported_thread_id); + } + } + return importedThreadIds; +} + function scanOpenCodeSessions() { const sessions = []; if (!fs.existsSync(OPENCODE_DB)) return sessions; @@ -2303,6 +2345,7 @@ function parseCodexSessionFile(sessionFile) { function scanCodexSessions() { const sessions = []; const codexTitles = parseCodexSessionIndex(CODEX_DIR); + const importedFromClaude = parseCodexExternalImports(CODEX_DIR); const codexHistory = path.join(CODEX_DIR, 'history.jsonl'); if (fs.existsSync(codexHistory)) { const lines = readLines(codexHistory); @@ -2312,6 +2355,7 @@ function scanCodexSessions() { // Codex uses session_id, ts (seconds), text const sid = d.session_id || d.sessionId || d.id; if (!sid) continue; + if (importedFromClaude.has(sid)) continue; // skip — original Claude file loaded separately const ts = d.ts ? d.ts * 1000 : (d.timestamp || Date.now()); if (!sessions.find(s => s.id === sid)) { sessions.push({ @@ -2354,6 +2398,7 @@ function scanCodexSessions() { const uuidMatch = basename.match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/); if (!uuidMatch) continue; const sid = uuidMatch[1]; + if (importedFromClaude.has(sid)) continue; // skip — original Claude file loaded separately const summary = parseCodexSessionFile(f); if (!summary) continue; @@ -2550,6 +2595,8 @@ let _codexHistoryMtime = 0; let _codexHistorySize = 0; let _codexIndexMtime = 0; let _codexIndexSize = 0; +let _codexImportsLedgerMtime = 0; +let _codexImportsLedgerSize = 0; let _codexSessionsDirMtimes = {}; // { dayDirPath: mtimeMs } — shallow leaf dirs under ~/.codex/sessions // Stashed result of the most recent _codexDayDirMtimes() walk during a rescan // check. Reused by _updateScanMarkers() to avoid a second filesystem walk @@ -2622,6 +2669,17 @@ function _sessionsNeedRescan() { const st = fs.statSync(codexIndex); if (st.mtimeMs !== _codexIndexMtime || st.size !== _codexIndexSize) return true; } + // Codex Desktop's external-import ledger drives Claude-vs-Codex attribution. + // Watching it guarantees a rescan after a two-phase import (rollout/index + // written first, ledger appended a tick later) instead of waiting for an + // unrelated mtime change. + const codexLedger = path.join(CODEX_DIR, 'external_agent_session_imports.json'); + if (fs.existsSync(codexLedger)) { + const st = fs.statSync(codexLedger); + if (st.mtimeMs !== _codexImportsLedgerMtime || st.size !== _codexImportsLedgerSize) return true; + } else if (_codexImportsLedgerMtime !== 0 || _codexImportsLedgerSize !== 0) { + return true; + } const dayMtimes = _codexDayDirMtimes(); _codexDayDirMtimesPending = dayMtimes; // reuse in _updateScanMarkers const prevKeys = Object.keys(_codexSessionsDirMtimes); @@ -2672,6 +2730,14 @@ function _updateScanMarkers() { } else { _codexIndexMtime = 0; _codexIndexSize = 0; } + const codexLedger = path.join(CODEX_DIR, 'external_agent_session_imports.json'); + if (fs.existsSync(codexLedger)) { + const st = fs.statSync(codexLedger); + _codexImportsLedgerMtime = st.mtimeMs; + _codexImportsLedgerSize = st.size; + } else { + _codexImportsLedgerMtime = 0; _codexImportsLedgerSize = 0; + } // Reuse the walk performed by _sessionsNeedRescan() when present; // otherwise (first call / direct invocation) walk now. _codexSessionsDirMtimes = _codexDayDirMtimesPending || _codexDayDirMtimes();