// provider_sync.js —— 一键把历史会话同步到「当前账号」的 provider,使其在 Codex 侧栏重新显示。 // 复刻 CodexPlusPlus 的 provider-sync:改 rollout 首行 provider + state DB(provider/has_user_event/cwd)。 // 必须在 Codex 已退出时运行。数据库用纯 JS 的 sql.js(免原生编译,跨平台)。 const fs = require("fs"); const path = require("path"); const os = require("os"); const { execFileSync } = require("child_process"); const initSqlJs = require("sql.js"); const CODEX_HOME = path.join(os.homedir(), ".codex"); // 检测 Codex 是否在运行(尽力而为,跨平台) function codexRunning() { try { if (process.platform === "darwin") { return execFileSync("pgrep", ["-f", "Codex.app/Contents/MacOS/Codex"], { encoding: "utf-8" }).trim().length > 0; } if (process.platform === "win32") { return /Codex\.exe/i.test(execFileSync("tasklist", [], { encoding: "utf-8" })); } } catch { /* pgrep 无匹配时退出码非 0,视为未运行 */ } return false; } // 目标 provider = config.toml 根级 model_provider,缺省 openai function targetProvider() { try { const head = fs.readFileSync(path.join(CODEX_HOME, "config.toml"), "utf-8").split("\n[")[0]; const m = head.match(/^\s*model_provider\s*=\s*["']([^"']+)/m); if (m) return m[1]; } catch {} return "openai"; } function findRollouts() { const out = []; for (const root of [path.join(CODEX_HOME, "sessions"), path.join(CODEX_HOME, "archived_sessions")]) { const walk = (dir) => { let ents; try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; } for (const e of ents) { const full = path.join(dir, e.name); if (e.isDirectory()) walk(full); else if (e.isFile() && e.name.startsWith("rollout-") && e.name.endsWith(".jsonl")) out.push(full); } }; walk(root); } return out; } function ts() { const d = new Date(); const p = (n) => String(n).padStart(2, "0"); return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`; } async function syncProviderToCurrent(log = () => {}, onProgress = () => {}) { if (codexRunning()) throw new Error("检测到 Codex 正在运行,请先完全退出 Codex 再同步。"); const target = targetProvider(); log(`目标 provider:${target}`); onProgress({ phase: "备份中", done: 0, total: 0 }); // 1) 备份 const backup = path.join(CODEX_HOME, "backups_state", "provider-sync", ts()); fs.mkdirSync(backup, { recursive: true }); for (const name of ["config.toml", ".codex-global-state.json", ".codex-global-state.json.bak"]) { const src = path.join(CODEX_HOME, name); if (fs.existsSync(src)) fs.copyFileSync(src, path.join(backup, name)); } for (const db of fs.readdirSync(CODEX_HOME).filter((f) => /^state_.*\.sqlite/.test(f))) { fs.copyFileSync(path.join(CODEX_HOME, db), path.join(backup, db)); } log(`已备份到 ${backup}`); // 2) 遍历 rollout:改首行 provider,收集 thread_id / cwd / has_user_event const userEventIds = new Set(); const cwdById = {}; let changedFiles = 0; const rolloutFiles = findRollouts(); const rtotal = rolloutFiles.length; let ri = 0; for (const file of rolloutFiles) { ri++; if (ri % 5 === 0 || ri === rtotal) onProgress({ phase: "改写会话文件", done: ri, total: rtotal }); let lines; try { lines = fs.readFileSync(file, "utf-8").split(/(?<=\n)/); // 保留换行 } catch { continue; } if (!lines.length) continue; const blob = lines.join(""); const hasUE = blob.includes('"user_message"') || blob.includes('"user_input"'); let tid = null; let rewrote = false; for (let i = 0; i < lines.length; i++) { const line = lines[i]; if (!line.trim()) continue; let rec; try { rec = JSON.parse(line); } catch { continue; } if (rec.type === "session_meta" && rec.payload && typeof rec.payload === "object") { tid = rec.payload.id || rec.payload.session_id || null; const cwd = rec.payload.cwd; if (tid && cwd) cwdById[tid] = cwd; if (rec.payload.model_provider !== target) { rec.payload.model_provider = target; lines[i] = JSON.stringify(rec) + "\n"; rewrote = true; } break; } } if (rewrote) { fs.writeFileSync(file, lines.join(""), "utf-8"); changedFiles++; } if (tid && hasUE) userEventIds.add(tid); } log(`改写 rollout 文件:${changedFiles} 个;含用户消息的会话:${userEventIds.size} 条`); // 3) 更新所有 state_*.sqlite onProgress({ phase: "更新数据库", done: 0, total: 0 }); const SQL = await initSqlJs({ // wasm 恒在【包内】node_modules(热更包不含 node_modules),故用 BUNDLE_ROOT 而非 __dirname locateFile: (f) => path.join(global.__BUNDLE_ROOT__ || path.join(__dirname, "..", ".."), "node_modules", "sql.js", "dist", f), }); let provRows = 0; let ueRows = 0; let cwdRows = 0; for (const name of fs.readdirSync(CODEX_HOME).filter((f) => /^state_.*\.sqlite$/.test(f))) { const dbPath = path.join(CODEX_HOME, name); let db; try { db = new SQL.Database(fs.readFileSync(dbPath)); const tbl = db.exec("SELECT name FROM sqlite_master WHERE type='table' AND name='threads'"); if (!tbl.length) { db.close(); continue; } const cols = new Set(db.exec("PRAGMA table_info(threads)")[0].values.map((r) => r[1])); if (!cols.has("model_provider")) { db.close(); continue; } db.run("UPDATE threads SET model_provider=? WHERE COALESCE(model_provider,'')<>?", [target, target]); provRows += db.getRowsModified(); if (cols.has("has_user_event")) { for (const tid of userEventIds) { db.run("UPDATE threads SET has_user_event=1 WHERE id=? AND COALESCE(has_user_event,0)<>1", [tid]); ueRows += db.getRowsModified(); } } if (cols.has("cwd")) { for (const tid of Object.keys(cwdById)) { db.run("UPDATE threads SET cwd=? WHERE id=? AND COALESCE(cwd,'')<>?", [cwdById[tid], tid, cwdById[tid]]); cwdRows += db.getRowsModified(); } } fs.writeFileSync(dbPath, Buffer.from(db.export())); db.close(); } catch (e) { if (db) try { db.close(); } catch {} log(` 数据库跳过 ${name}:${e.message || e}`); } } log(`数据库更新:provider ${provRows} 行、has_user_event ${ueRows} 行、cwd ${cwdRows} 行`); onProgress({ phase: "完成", done: 0, total: 0 }); return { target, totalConversations: rtotal, changedFiles, userEvents: userEventIds.size, provRows, ueRows, cwdRows, backup, }; } module.exports = { syncProviderToCurrent, codexRunning, targetProvider };