2026-07-15 18:01:56 +08:00
// 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" );
2026-08-16 12:45:32 +08:00
const { spawnSync } = require ( "child_process" );
2026-07-15 18:01:56 +08:00
const initSqlJs = require ( "sql.js" );
2026-08-16 12:45:32 +08:00
const { cleanThreadTitle , cleanUserVisibleText } = require ( "./title_clean" );
const codexDesktop = require ( "./codex_desktop" );
2026-07-15 18:01:56 +08:00
2026-08-16 12:45:32 +08:00
let CODEX_HOME = path . join ( os . homedir (), ".codex" );
let SESSION_INDEX = path . join ( CODEX_HOME , "session_index.jsonl" );
let GLOBAL_STATE = path . join ( CODEX_HOME , ".codex-global-state.json" );
// Codex 桌面端侧栏现在优先读这个本地目录库,而不是直接扫 state_*.sqlite。
// 只改 threads / rollout / session_index 而不同步这里,重启后侧栏仍会空。
let LOCAL_CATALOG_DB = path . join ( CODEX_HOME , "sqlite" , "codex-dev.db" );
function setCodexDataDir ( dir ) {
if ( typeof dir !== "string" || ! path . isAbsolute ( dir )) throw new Error ( "Codex 数据目录必须是绝对路径" );
CODEX_HOME = path . resolve ( dir );
SESSION_INDEX = path . join ( CODEX_HOME , "session_index.jsonl" );
GLOBAL_STATE = path . join ( CODEX_HOME , ".codex-global-state.json" );
LOCAL_CATALOG_DB = path . join ( CODEX_HOME , "sqlite" , "codex-dev.db" );
}
2026-07-15 18:01:56 +08:00
// 检测 Codex 是否在运行(尽力而为,跨平台)
function codexRunning () {
2026-08-16 12:45:32 +08:00
return codexDesktop . codexRunning ();
2026-07-15 18:01:56 +08:00
}
// 目标 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 ;
}
2026-07-15 18:45:40 +08:00
function isArchivedRollout ( file ) {
return file . startsWith ( path . join ( CODEX_HOME , "archived_sessions" ) + path . sep );
}
function loadIndexTitles () {
const map = {};
try {
for ( const line of fs . readFileSync ( SESSION_INDEX , "utf-8" ). split ( "\n" )) {
if ( ! line . trim ()) continue ;
try {
const d = JSON . parse ( line );
2026-08-16 12:45:32 +08:00
if ( d . id && d . thread_name ) map [ d . id ] = cleanThreadTitle ( d . thread_name , "" , 80 );
2026-07-15 18:45:40 +08:00
} catch {}
}
} catch {}
return map ;
}
function parseTimeMs ( tsText , fallbackMs = Date . now ()) {
const n = Date . parse ( tsText || "" );
return Number . isFinite ( n ) ? n : fallbackMs ;
}
function firstText ( value ) {
if ( typeof value === "string" ) return value ;
if ( Array . isArray ( value )) {
return value
. map (( x ) => ( typeof x === "string" ? x : x && typeof x . text === "string" ? x . text : "" ))
. filter ( Boolean )
. join ( "\n" );
}
return "" ;
}
2026-08-16 12:45:32 +08:00
// rollout payload 里的字段不一定是字符串:subagent 会话的 source 就是对象
// {subagent:{thread_spawn:{...}}}。Codex 自身在 state 库里存的是 JSON 字符串;
// 直接把对象绑给 sql.js 会抛 "unknown type" 并让整批 upsert 回滚(Windows 无
// sqlite3 CLI 时走 sql.js 路径,正是这个原因导致恢复后 state 库 0 线程)。
function payloadText ( value , fallback ) {
if ( typeof value === "string" ) return value ;
if ( value === null || value === undefined ) return fallback ;
if ( typeof value === "number" || typeof value === "boolean" ) return String ( value );
try {
const text = JSON . stringify ( value );
if ( typeof text === "string" ) return text ;
} catch {}
return fallback ;
}
2026-07-15 18:45:40 +08:00
function sql ( v ) {
if ( v === null || v === undefined ) return "NULL" ;
if ( typeof v === "number" ) return Number . isFinite ( v ) ? String ( v ) : "NULL" ;
2026-08-16 12:45:32 +08:00
if ( typeof v !== "string" ) {
// 对象(如 subagent 的 source)序列化成 JSON 文本,与 Codex 自身存储一致,
// 避免落入 String(v) 产生无意义的 '[object Object]' 脏行。
try { v = JSON . stringify ( v ); } catch { v = String ( v ); }
}
2026-07-15 18:45:40 +08:00
return "'" + String ( v ). replace ( /'/g , "''" ) + "'" ;
}
2026-08-16 12:45:32 +08:00
const THREAD_COLUMNS = [
"id" , "rollout_path" , "created_at" , "updated_at" , "source" , "model_provider" , "cwd" , "title" ,
"sandbox_policy" , "approval_mode" , "tokens_used" , "has_user_event" , "archived" , "archived_at" ,
"git_sha" , "git_branch" , "git_origin_url" , "cli_version" , "first_user_message" , "memory_mode" ,
"model" , "reasoning_effort" , "created_at_ms" , "updated_at_ms" , "thread_source" , "preview" ,
"recency_at" , "recency_at_ms" , "history_mode" ,
];
// Codex 当前 state_5.sqlite 的完整基础 schema。首次安装工具箱且目标目录还没有任何
// state_*.sqlite 时,不能只创建一个 threads 简表:Codex 启动时还会依赖这些索引、触发器
// 和关联表。migration 记录使用当前 Codex 版本的基线,后续版本可继续追加自己的迁移。
const STATE_DB_SCHEMA_SQL = `
CREATE TABLE IF NOT EXISTS _sqlx_migrations (
version BIGINT PRIMARY KEY,
description TEXT NOT NULL,
installed_on TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
success BOOLEAN NOT NULL,
checksum BLOB NOT NULL,
execution_time BIGINT NOT NULL
);
CREATE TABLE IF NOT EXISTS thread_sections (
id TEXT PRIMARY KEY,
name TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS backfill_state (
id INTEGER PRIMARY KEY CHECK (id = 1),
status TEXT NOT NULL,
last_watermark TEXT,
last_success_at INTEGER,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS external_agent_config_imports (
import_id TEXT PRIMARY KEY,
completed_at_ms INTEGER NOT NULL,
successes TEXT NOT NULL,
failures TEXT NOT NULL,
provider_id TEXT
);
CREATE TABLE IF NOT EXISTS remote_control_enrollments (
websocket_url TEXT NOT NULL,
account_id TEXT NOT NULL,
app_server_client_name TEXT NOT NULL,
server_id TEXT NOT NULL,
environment_id TEXT NOT NULL,
server_name TEXT NOT NULL,
updated_at INTEGER NOT NULL,
remote_control_enabled INTEGER,
PRIMARY KEY (websocket_url, account_id, app_server_client_name)
);
CREATE TABLE IF NOT EXISTS threads (
id TEXT PRIMARY KEY,
rollout_path TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
source TEXT NOT NULL,
model_provider TEXT NOT NULL,
cwd TEXT NOT NULL,
title TEXT NOT NULL,
sandbox_policy TEXT NOT NULL,
approval_mode TEXT NOT NULL,
tokens_used INTEGER NOT NULL DEFAULT 0,
has_user_event INTEGER NOT NULL DEFAULT 0,
archived INTEGER NOT NULL DEFAULT 0,
archived_at INTEGER,
git_sha TEXT,
git_branch TEXT,
git_origin_url TEXT,
cli_version TEXT NOT NULL DEFAULT '',
first_user_message TEXT NOT NULL DEFAULT '',
agent_nickname TEXT,
agent_role TEXT,
memory_mode TEXT NOT NULL DEFAULT 'enabled',
model TEXT,
reasoning_effort TEXT,
agent_path TEXT,
created_at_ms INTEGER,
updated_at_ms INTEGER,
thread_source TEXT,
preview TEXT NOT NULL DEFAULT '',
recency_at INTEGER NOT NULL DEFAULT 0,
recency_at_ms INTEGER NOT NULL DEFAULT 0,
history_mode TEXT NOT NULL DEFAULT 'legacy',
name TEXT,
is_pinned INTEGER NOT NULL DEFAULT 0,
thread_section_id TEXT REFERENCES thread_sections(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS thread_dynamic_tools (
thread_id TEXT NOT NULL,
position INTEGER NOT NULL,
name TEXT NOT NULL,
description TEXT NOT NULL,
input_schema TEXT NOT NULL,
defer_loading INTEGER NOT NULL DEFAULT 0,
namespace TEXT,
PRIMARY KEY(thread_id, position),
FOREIGN KEY(thread_id) REFERENCES threads(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS thread_spawn_edges (
parent_thread_id TEXT NOT NULL,
child_thread_id TEXT NOT NULL PRIMARY KEY,
status TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_thread_dynamic_tools_thread ON thread_dynamic_tools(thread_id);
CREATE INDEX IF NOT EXISTS idx_thread_spawn_edges_parent_status ON thread_spawn_edges(parent_thread_id, status);
CREATE INDEX IF NOT EXISTS idx_threads_archived ON threads(archived);
CREATE INDEX IF NOT EXISTS idx_threads_archived_cwd_created_at_ms ON threads(archived, cwd, created_at_ms DESC, id DESC);
CREATE INDEX IF NOT EXISTS idx_threads_archived_cwd_recency_at_ms ON threads(archived, cwd, recency_at_ms DESC, id DESC);
CREATE INDEX IF NOT EXISTS idx_threads_archived_cwd_updated_at_ms ON threads(archived, cwd, updated_at_ms DESC, id DESC);
CREATE INDEX IF NOT EXISTS idx_threads_created_at ON threads(created_at DESC, id DESC);
CREATE INDEX IF NOT EXISTS idx_threads_created_at_ms ON threads(created_at_ms DESC, id DESC);
CREATE INDEX IF NOT EXISTS idx_threads_pinned_recency_at_ms ON threads(archived, recency_at_ms DESC, id DESC) WHERE is_pinned = 1 AND preview <> '';
CREATE INDEX IF NOT EXISTS idx_threads_provider ON threads(model_provider);
CREATE INDEX IF NOT EXISTS idx_threads_recency_at_ms ON threads(recency_at_ms DESC, id DESC);
CREATE INDEX IF NOT EXISTS idx_threads_section_recency_at_ms ON threads(archived, thread_section_id, recency_at_ms DESC, id DESC) WHERE thread_section_id IS NOT NULL AND preview <> '';
CREATE INDEX IF NOT EXISTS idx_threads_source ON threads(source);
CREATE INDEX IF NOT EXISTS idx_threads_updated_at ON threads(updated_at DESC, id DESC);
CREATE INDEX IF NOT EXISTS idx_threads_updated_at_ms ON threads(updated_at_ms DESC, id DESC);
CREATE INDEX IF NOT EXISTS idx_threads_visible_created_at_ms ON threads(archived, created_at_ms DESC) WHERE preview <> '';
CREATE INDEX IF NOT EXISTS idx_threads_visible_recency_at_ms ON threads(archived, recency_at_ms DESC, id DESC) WHERE preview <> '';
CREATE INDEX IF NOT EXISTS idx_threads_visible_updated_at_ms ON threads(archived, updated_at_ms DESC) WHERE preview <> '';
CREATE TRIGGER IF NOT EXISTS threads_created_at_ms_after_insert
AFTER INSERT ON threads WHEN NEW.created_at_ms IS NULL
BEGIN UPDATE threads SET created_at_ms = NEW.created_at * 1000 WHERE id = NEW.id; END;
CREATE TRIGGER IF NOT EXISTS threads_created_at_ms_after_update
AFTER UPDATE OF created_at ON threads
WHEN NEW.created_at != OLD.created_at AND NEW.created_at_ms IS OLD.created_at_ms
BEGIN UPDATE threads SET created_at_ms = NEW.created_at * 1000 WHERE id = NEW.id; END;
CREATE TRIGGER IF NOT EXISTS threads_recency_at_after_insert
AFTER INSERT ON threads WHEN NEW.recency_at_ms = 0
BEGIN UPDATE threads SET recency_at = NEW.updated_at, recency_at_ms = COALESCE(NEW.updated_at_ms, NEW.updated_at * 1000) WHERE id = NEW.id; END;
CREATE TRIGGER IF NOT EXISTS threads_updated_at_ms_after_insert
AFTER INSERT ON threads WHEN NEW.updated_at_ms IS NULL
BEGIN UPDATE threads SET updated_at_ms = NEW.updated_at * 1000 WHERE id = NEW.id; END;
CREATE TRIGGER IF NOT EXISTS threads_updated_at_ms_after_update
AFTER UPDATE OF updated_at ON threads
WHEN NEW.updated_at != OLD.updated_at AND NEW.updated_at_ms IS OLD.updated_at_ms
BEGIN UPDATE threads SET updated_at_ms = NEW.updated_at * 1000 WHERE id = NEW.id; END;
` ;
const STATE_DB_MIGRATIONS = [
[ 1 , "threads" , "627EF19164C9BB298A0CD99945981C9B7BDA3D9E6CF12EB35145E3B1D3BF7CF8740F0DBAA0B475185FC2993397078049" ],
[ 2 , "logs" , "521B72CBC04C7D03B1E4AEF8DC0FDEE672F1F7F5881385A55A0E937E4EBE87A3F67BCA4D3F6B59AFBC9A7CA723C82856" ],
[ 3 , "logs thread id" , "E58A2862BDB66D3274E60144A26DC9D68BBEFFF834CD29B82B740F38FCF95862934F412665D985715167839E8F0EB377" ],
[ 4 , "thread dynamic tools" , "8ADC251366E4C7E5EA78B620AC71F4C4BDFB8F048BF9F2FD7204226CFBA54C8E83FC9D83FC1EC334BC683BB7F6D0D8A8" ],
[ 5 , "threads cli version" , "1AC64F85E083E5B49B974BF498FC39C630F0032057FB4BF4CB31708619FAC5A80189F261CF3E38DAFD92DC406E73C220" ],
[ 6 , "memories" , "A203FC4B597E1420BFFDC4E00E214798B21CF7FC921D8BD6F089C75248FB6A4B4942C97A1F8E0271ECA8CF3B3E5E7A80" ],
[ 7 , "threads first user message" , "7E35A464DF6D4C82694DE50865BA3FE9104F9E1385A33BE7D633AB4B8D6CF60B7F369FE083D31251FD77B75FA2D61F82" ],
[ 8 , "backfill state" , "AA3F287F7A448554E7AF01F259F0871CE90ACE1A7D4B8EBBD74C87AE57C3E21119F7EEC8B9C2E9F4A9E76E63A0F0B60A" ],
[ 9 , "stage1 outputs rollout slug" , "8B469360777E6DD6C21D3F0EB93481251846B0A50E0A44EB0DBEE4A0A32B7206768A5D83A5FCEC30C6FD2E43D755FF1B" ],
[ 10 , "logs process id" , "8ADAF1365F8E5DEEBA68BDEEE9FD83B094A87BDAB364D95AB5C714F7B8BD95DC3B38576871470312154C9D41E8F10F93" ],
[ 11 , "logs partition prune indexes" , "31ECECBD07F04452D0DC5127D824ED183056B9900A31AA739387A4AEF232016D975110829814841FB6BF3FD23634B799" ],
[ 12 , "logs estimated bytes" , "0012A213ED25F2CF806C2530C487EBB00B4F102F5960D1A852154A0F77D25C9E9F7DF29550FAEB30EBAFF15A26C7D604" ],
[ 13 , "threads agent nickname" , "05526A0900A8430523A9A05932EA61302FCE20782B245498565C1B9A2DFD10CF5C49932089B496AE23AF44F6C5F76AF4" ],
[ 14 , "agent jobs" , "4D2FBB2442C5C5C7C47B4254B5B8DC2C52129F65C4A952A79F1EC5C40FB07483326429CE5ACDBD372BC9262352CB6277" ],
[ 15 , "agent jobs max runtime seconds" , "0EECE6DB59C948FAA6608526AF440A9F53F5A982D27F3E866CD9F1401C2E59BC61F8381B5A1C9744ACB232F50DB493E5" ],
[ 16 , "memory usage" , "4768F743E981293735C6E07B0282EC8ACE98AF720059D68555B17979D28C94B0C893A97A950D5E32758BC6D92D8FE6FF" ],
[ 17 , "phase2 selection flag" , "4CBDB2EDFEBF6040858FFC193CFFA36E6B3FBF508F8418AD073081BBFCF59D53D423B6EB4C0DED206AB4D4C1A0EFED94" ],
[ 18 , "phase2 selection snapshot" , "A4363003BC44F0A4AE1EED53B536B7175F97863EA6881A059DC6642E70E2921576204697CEC2345F560E65FF7FBF4455" ],
[ 19 , "thread dynamic tools defer loading" , "3884C02F080F0184E92B620F870DFBB840A8EC5DFCE2FE6455DD8197FCD27D7DD86E63AD6EC7C4091A1161D9ED199F8D" ],
[ 20 , "threads model reasoning effort" , "07673F4859F740134569AB9606760D8173ED6DF92DA47C86FBD0CEEB333E18AB7AC91D525AB8C11503C42164FD4167AE" ],
[ 21 , "thread spawn edges" , "EA1AEBE4F5A1D56C8EFFCC15F705C272275DD8ADFEB01BC97D3F7B283474A3B8C2B0C8C69DE55786E8BDA17CAE6F4DE7" ],
[ 22 , "threads agent path" , "160FE01B757DD06F9573B9030952D021F2B148732BEA5FC5F5AE09E9227F3F83CA676695EDAF8502669FCB546119DC94" ],
[ 23 , "drop logs" , "E6C748A9A18286E4C773FFE0DAD659F16FB7E5B7B032235815991C405F2D32C068F3EDDE920D0FCF589B687BFFCFEE2E" ],
[ 24 , "remote control enrollments" , "E92AA526B1F36E26F6221C04A671F07B27DE2E7A5112D93A8C3AF816A87846E7804D5FBAD689E61CF4EC91FD79D3BD8A" ],
[ 25 , "thread timestamps millis" , "D6FB51D79941B93C3278FE2A8A0C6FA882407B7B716E32D72329F0B7B60C181A42F49E56CD552315EFB0402A111EDE12" ],
[ 26 , "thread dynamic tools namespace" , "6C62A325D3B9CA8E424EACB9E3890D8D9DE57C3725E2D1FA30C3743AE779FCA2969387174359DFB26E4920791AE7E831" ],
[ 27 , "threads cwd sort indexes" , "D84DCA6A039E004C0C7D06C5A16C2CCC363137537F3DD9898F50BB013CE9067140761B87D58CA742D769D35976863FC3" ],
[ 28 , "device key bindings" , "EF44ADAA291E40DFE6B74E3CD259711ADAE13F01C5594B177D08E2D2041A67ED3813FEECF049662AAED0F636BD3F4AC3" ],
[ 29 , "thread goals" , "E9EC76F97DBF41CE4949DE324A2A26538C2025C0AC4F272B77A852970FB14B7EC970D6978A765F91AE171DCE9C0E4149" ],
[ 30 , "threads thread source" , "80C1CDACD4520B42B18B960164912685591A7EE041645F35609A2EE2792EB474B7B0B758366556982D8CDE2ACB583672" ],
[ 31 , "drop device key bindings" , "943675A9B3DC92D0B2731AE7D2FE324FF1A40B6366B9D5D8B4CB47CA885C08BEB6EBB1D776A5A47C878B4094DDE70EA0" ],
[ 32 , "threads preview" , "3C01D615C5E3ABA0FD858B7168E73881598D7D33A0ECC41527BDF6595A199D1AEC2FF9F3B3000A57241C500D81B34259" ],
[ 33 , "thread goal stopped statuses" , "4904AAE354E3D8CE32AC21D2AE579FD708446AE1DFB2649FC9DD22DC9279B91A2BBB02E6F01B3E0ABD1088177BF2F030" ],
[ 34 , "drop thread goals" , "D6FEA739C76EBDEF35A23F06E54EFB5904D7987FB6B663DB1EBC98EB9B192305F454932BFBAA2231AEDCA6209903A5F3" ],
[ 35 , "drop memory tables" , "D7B56ACB07C96858BDC7CA4C53DB51EB71C6A19EF2C524FF68C2660A6FE0D23211A6D39CB905F01C42FFB7C8EE913BBC" ],
[ 36 , "threads visible sort indexes" , "6381F7BB4C36BE41C6106B8523EA6C68F5A00273A5522155B7B84BAD26D152436F3375DF7097CE3ABF2421AEC32641ED" ],
[ 37 , "remote control enrollments enabled" , "F6A5AB0DB36BB9E45AB5285B56090FB453DF0A4D4D8D62EACE234F0B557F4203C6C455FC67BD7D6E72623C3979EC9405" ],
[ 38 , "external agent config imports" , "E7778522CBE529F5E2BA179B502AEE8E5C21272AD820E672AB0A565F7EDC9BD0F29171CCF9815C0ACC0F89EAE0A2B1FC" ],
[ 39 , "threads recency at" , "C217B3D14C08C23603F485AF11452EE2B68F65FA8DBAF215F33F53B608C092BE8738DE3F63D6A38F120EDEA5AF247C17" ],
[ 40 , "threads history mode" , "A7A99674F90A43184E43D66595C4F1DA50AE8715A5BFCB1579A2B7B10668335D7F7AB6B6F2D7A7A379272773D803A914" ],
[ 41 , "threads name" , "0DC734DBE4CBCC5AD5A8BBC2FDC1CF770DDEA3CB53EB36C55A4DA6FA2E8BE4CC8E68672F43AC953E727E415E45C50A78" ],
[ 42 , "drop agent jobs" , "CFDC4FD47328D1B67C0C7555125DE4FB4ADD5B3B57FD31AC1987E4D8B0C99B1F5418DB9EE7EC2158AED8A2E3DFCFD636" ],
[ 43 , "threads is pinned" , "605128E722BD2521C7E979E9961C16706435E442B3E3A8EFA36F6B16C42E3833090906D9A62FDE097A109B78E29DA830" ],
[ 44 , "external agent config imports provider id" , "5D29223BD1CAFE456A4AF992A545E8EC420B71331EB12A7427E26512B8A44117D9445B83562F095B3A5A0F11C4F091D2" ],
[ 45 , "threads section" , "A7FADC8CAEA8B6ABEB108F588A91B8E5D1C9E2645CE990F2251B9F0234E6E53D78FC854F1C88C7217CC143016539D691" ],
];
function threadColumns ( cols ) {
return THREAD_COLUMNS . filter (( c ) => cols . has ( c ));
}
function threadValue ( record , target , column ) {
const created = Math . floor ( record . created_at_ms / 1000 );
const updated = Math . floor ( record . updated_at_ms / 1000 );
const values = {
id : record . id ,
rollout_path : record . rollout_path ,
created_at : created ,
updated_at : updated ,
source : record . source ,
model_provider : target ,
cwd : record . cwd ,
title : record . title ,
sandbox_policy : record . sandbox_policy ,
approval_mode : record . approval_mode ,
tokens_used : record . tokens_used ,
has_user_event : record . has_user_event ,
archived : record . archived ,
archived_at : record . archived ? updated : null ,
git_sha : record . git_sha ,
git_branch : record . git_branch ,
git_origin_url : record . git_origin_url ,
cli_version : record . cli_version ,
first_user_message : record . first_user_message ,
memory_mode : "enabled" ,
model : record . model ,
reasoning_effort : record . reasoning_effort ,
created_at_ms : Math . round ( record . created_at_ms ),
updated_at_ms : Math . round ( record . updated_at_ms ),
thread_source : record . thread_source ,
preview : record . preview ,
recency_at : updated ,
recency_at_ms : Math . round ( record . updated_at_ms ),
history_mode : record . history_mode ,
};
return values [ column ];
}
function threadUpdateSet ( columns , target , parameterized = false ) {
return columns . filter (( c ) => c !== "id" ). map (( c ) => {
if ( c === "model_provider" ) return ` ${ c } = ${ parameterized ? "?" : sql ( target ) } ` ;
if ( c === "has_user_event" ) return ` ${ c } =CASE WHEN excluded. ${ c } =1 THEN 1 ELSE threads. ${ c } END` ;
if ([ "cwd" , "first_user_message" , "preview" , "thread_source" ]. includes ( c )) {
return ` ${ c } =CASE WHEN COALESCE(excluded. ${ c } ,'')<>'' THEN excluded. ${ c } ELSE threads. ${ c } END` ;
}
if ( c === "title" ) {
return ` ${ c } =CASE WHEN COALESCE(threads. ${ c } ,'')='' OR threads. ${ c } ='(无标题对话)' THEN excluded. ${ c } ELSE threads. ${ c } END` ;
}
if ([ "updated_at" , "updated_at_ms" , "recency_at" , "recency_at_ms" ]. includes ( c )) {
return ` ${ c } =MAX(threads. ${ c } , excluded. ${ c } )` ;
}
return ` ${ c } =excluded. ${ c } ` ;
});
}
2026-07-15 18:45:40 +08:00
function sqlite3Available () {
const r = spawnSync ( "sqlite3" , [ "--version" ], { encoding : "utf-8" });
return ! r . error && r . status === 0 ;
}
function runSqlite3 ( dbPath , input ) {
const r = spawnSync ( "sqlite3" , [ dbPath ], {
input ,
encoding : "utf-8" ,
maxBuffer : 20 * 1024 * 1024 ,
});
if ( r . error || r . status !== 0 ) {
throw new Error (( r . stderr || r . error && r . error . message || "sqlite3 执行失败" ). trim ());
}
return r . stdout || "" ;
}
function sqliteCols ( dbPath ) {
const out = runSqlite3 ( dbPath , "PRAGMA table_info(threads);\n" );
return new Set ( out . split ( "\n" ). map (( line ) => line . split ( "|" )[ 1 ]). filter ( Boolean ));
}
function readRolloutRecord ( file , titles ) {
let content ;
try {
content = fs . readFileSync ( file , "utf-8" );
} catch {
return null ;
}
const stat = fs . statSync ( file );
const rec = {
id : "" ,
rollout_path : file ,
cwd : "" ,
source : "vscode" ,
model_provider : "" ,
thread_source : "user" ,
title : "" ,
first_user_message : "" ,
preview : "" ,
has_user_event : content . includes ( '"user_message"' ) || content . includes ( '"user_input"' ) ? 1 : 0 ,
archived : isArchivedRollout ( file ) ? 1 : 0 ,
archived_at : null ,
created_at_ms : stat . birthtimeMs || stat . mtimeMs || Date . now (),
updated_at_ms : stat . mtimeMs || Date . now (),
sandbox_policy : JSON . stringify ({ type : "disabled" }),
approval_mode : "never" ,
tokens_used : 0 ,
cli_version : "" ,
git_sha : null ,
git_branch : null ,
git_origin_url : null ,
model : null ,
reasoning_effort : null ,
history_mode : "legacy" ,
};
for ( const line of content . split ( "\n" )) {
if ( ! line . trim ()) continue ;
let d ;
try {
d = JSON . parse ( line );
} catch {
continue ;
}
const p = d . payload || {};
if ( d . type === "session_meta" && ! rec . id ) {
rec . id = p . id || p . session_id || rec . id ;
2026-08-16 12:45:32 +08:00
rec . cwd = payloadText ( p . cwd , rec . cwd );
rec . source = payloadText ( p . source , rec . source );
rec . model_provider = payloadText ( p . model_provider , rec . model_provider );
rec . thread_source = payloadText ( p . thread_source , rec . thread_source );
rec . cli_version = payloadText ( p . cli_version , rec . cli_version );
rec . history_mode = payloadText ( p . history_mode , rec . history_mode );
2026-07-15 18:45:40 +08:00
rec . created_at_ms = parseTimeMs ( p . timestamp || d . timestamp , rec . created_at_ms );
if ( p . git && typeof p . git === "object" ) {
rec . git_sha = p . git . commit_hash || null ;
rec . git_branch = p . git . branch || null ;
rec . git_origin_url = p . git . repository_url || null ;
}
} else if ( d . type === "event_msg" && ( p . type === "user_message" || p . type === "user_input" )) {
rec . has_user_event = 1 ;
if ( ! rec . first_user_message ) rec . first_user_message = firstText ( p . message || p . input ). trim ();
}
}
rec . updated_at_ms = Math . max ( rec . updated_at_ms , rec . created_at_ms );
2026-08-16 12:45:32 +08:00
rec . title = cleanThreadTitle ( titles [ rec . id ] || "" , rec . first_user_message , 80 ) || "(无标题对话)" ;
rec . preview = cleanUserVisibleText ( rec . first_user_message ). replace ( /\s+/g , " " ). trim (). slice ( 0 , 200 );
2026-07-15 18:45:40 +08:00
return rec . id ? rec : null ;
}
function buildThreadUpsertSql ( records , target , cols ) {
2026-08-16 12:45:32 +08:00
const desired = threadColumns ( cols );
const updateSet = threadUpdateSet ( desired , target );
2026-07-15 18:45:40 +08:00
const lines = [ "BEGIN;" ];
for ( const r of records ) {
lines . push (
2026-08-16 12:45:32 +08:00
`INSERT INTO threads ( ${ desired . join ( "," ) } ) VALUES ( ${ desired . map (( c ) => sql ( threadValue ( r , target , c ))). join ( "," ) } ) ` +
2026-07-15 18:45:40 +08:00
`ON CONFLICT(id) DO UPDATE SET ${ updateSet . join ( "," ) } ;`
);
}
2026-07-16 19:16:45 +08:00
// 【增强】确保数据库中所有会话的 provider 都更新为目标(兜底逻辑)
2026-07-15 18:45:40 +08:00
lines . push ( `UPDATE threads SET model_provider= ${ sql ( target ) } WHERE COALESCE(model_provider,'')<> ${ sql ( target ) } ;` );
lines . push ( "COMMIT;" );
return lines . join ( "\n" ) + "\n" ;
}
function updateProjectState ( records , log = () => {}) {
if ( ! fs . existsSync ( GLOBAL_STATE )) return { addedProjects : 0 , removedProjectless : 0 };
let state ;
try {
state = JSON . parse ( fs . readFileSync ( GLOBAL_STATE , "utf-8" ));
} catch ( e ) {
log ( `全局项目索引跳过: ${ e . message || e } ` );
return { addedProjects : 0 , removedProjectless : 0 };
}
const projects = [... new Set ( records . map (( r ) => r . cwd ). filter ( Boolean ))];
const idsWithProject = new Set ( records . filter (( r ) => r . cwd ). map (( r ) => r . id ));
const before = Array . isArray ( state [ "project-order" ]) ? state [ "project-order" ] : [];
const seen = new Set ( before );
state [ "project-order" ] = before . concat ( projects . filter (( p ) => ! seen . has ( p )));
const oldProjectless = Array . isArray ( state [ "projectless-thread-ids" ]) ? state [ "projectless-thread-ids" ] : [];
state [ "projectless-thread-ids" ] = oldProjectless . filter (( id ) => ! idsWithProject . has ( id ));
const addedProjects = state [ "project-order" ]. length - before . length ;
const removedProjectless = oldProjectless . length - state [ "projectless-thread-ids" ]. length ;
if ( addedProjects || removedProjectless ) {
fs . writeFileSync ( GLOBAL_STATE , JSON . stringify ( state , null , 2 ) + "\n" , "utf-8" );
log ( `项目索引更新:新增项目 ${ addedProjects } 个,移出无项目会话 ${ removedProjectless } 条` );
}
return { addedProjects , removedProjectless };
}
2026-07-15 18:01:56 +08:00
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 ()) } ` ;
}
2026-07-16 19:16:45 +08:00
// 【增强】重建 session_index.jsonl:确保所有会话都在索引中(切账号后必要)
function rebuildSessionIndex ( records , log = () => {}) {
if ( ! records || ! records . length ) {
log ( "索引重建:无会话记录,跳过" );
return { rebuilt : 0 };
}
// 备份旧索引
if ( fs . existsSync ( SESSION_INDEX )) {
const backup = SESSION_INDEX + ".bak-" + ts ();
try {
fs . copyFileSync ( SESSION_INDEX , backup );
log ( `已备份旧索引 → ${ path . basename ( backup ) } ` );
} catch ( e ) {
log ( `索引备份失败: ${ e . message || e } ` );
}
}
// 读取现有索引,保留用户重命名的标题
const existingTitles = {};
try {
const lines = fs . readFileSync ( SESSION_INDEX , "utf-8" ). split ( "\n" );
for ( const line of lines ) {
if ( ! line . trim ()) continue ;
try {
const d = JSON . parse ( line );
if ( d . id && d . thread_name ) existingTitles [ d . id ] = d . thread_name ;
} catch {}
}
} catch {}
// 重建索引:每个会话一行 JSON
const lines = [];
for ( const r of records ) {
if ( ! r . id ) continue ;
const indexEntry = {
id : r . id ,
2026-08-16 12:45:32 +08:00
thread_name : cleanThreadTitle ( existingTitles [ r . id ] || "" , r . first_user_message || r . title , 80 ) || r . title || "(无标题对话)" ,
2026-07-16 19:16:45 +08:00
project_path : r . cwd || null ,
created_at : new Date ( r . created_at_ms ). toISOString (),
source : r . source || "vscode" ,
archived : r . archived ? true : false ,
};
lines . push ( JSON . stringify ( indexEntry ));
}
// 写入新索引
try {
fs . writeFileSync ( SESSION_INDEX , lines . join ( "\n" ) + "\n" , "utf-8" );
log ( `索引重建完成: ${ lines . length } 条会话` );
return { rebuilt : lines . length };
} catch ( e ) {
log ( `索引重建失败: ${ e . message || e } ` );
return { rebuilt : 0 , error : e . message || String ( e ) };
}
}
2026-08-16 12:45:32 +08:00
function secTs ( ms ) {
const n = Number ( ms );
if ( ! Number . isFinite ( n ) || n <= 0 ) return Date . now () / 1000 ;
// 兼容秒 / 毫秒两种时间戳
return n > 1e12 ? n / 1000 : n ;
}
// 重建 Codex 桌面端侧栏目录(~/.codex/sqlite/codex-dev.db 的 local_thread_catalog)。
// 新版 ChatGPT/Codex 启动后侧栏主要读这里;只同步 state_5.sqlite 不会让历史会话重新出现。
function rebuildLocalThreadCatalog ( records , target , log = () => {}) {
if ( ! records || ! records . length ) {
log ( "侧栏目录重建:无会话记录,跳过" );
return { rebuilt : 0 , skipped : true };
}
if ( ! fs . existsSync ( LOCAL_CATALOG_DB )) {
log ( `侧栏目录重建跳过:未找到 ${ LOCAL_CATALOG_DB } ` );
return { rebuilt : 0 , skipped : true , missing : true };
}
// 只回填未归档、有标题/预览/用户消息的主会话。
// 注意:新版 Codex 从某版本起大量线程 has_user_event 默认写成 0,不能再只靠这个字段过滤。
const visible = [];
const seen = new Set ();
for ( const r of records ) {
if ( ! r || ! r . id || seen . has ( r . id ) || r . archived ) continue ;
const hasText = !! ( r . first_user_message || r . preview || ( r . title && r . title !== "(无标题对话)" ));
const hasUser = !! r . has_user_event || hasText ;
if ( ! hasUser ) continue ;
seen . add ( r . id );
visible . push ( r );
}
if ( ! visible . length ) {
log ( "侧栏目录重建:没有可展示会话,跳过" );
return { rebuilt : 0 , skipped : true };
}
// 备份目录库
try {
const bak = LOCAL_CATALOG_DB + ".bak-" + ts ();
fs . copyFileSync ( LOCAL_CATALOG_DB , bak );
log ( `已备份侧栏目录库 → ${ path . basename ( bak ) } ` );
} catch ( e ) {
log ( `侧栏目录库备份失败: ${ e . message || e } ` );
}
const nowSec = Date . now () / 1000 ;
let maxUpdated = 0 ;
const valuesSql = [];
visible . forEach (( r , idx ) => {
const created = secTs ( r . created_at_ms );
const updated = Math . max ( secTs ( r . updated_at_ms ), created );
if ( updated > maxUpdated ) maxUpdated = updated ;
const title = cleanThreadTitle ( r . title || "" , r . first_user_message || r . preview || "" , 80 ) || "(无标题对话)" ;
const sourceKind = String ( r . source || "vscode" ). slice ( 0 , 64 ) || "vscode" ;
const provider = target || r . model_provider || "" ;
const cwd = r . cwd || "" ;
const gitBranch = r . git_branch || null ;
// observation_sequence 用递增序号,避免全 0 被后续 reconcile 误判
const obs = idx + 1 ;
valuesSql . push (
`( ${ sql ( "local" ) } , ${ sql ( r . id ) } , ${ sql ( title ) } , ${ sql ( created ) } , ${ sql ( updated ) } , ${ sql ( cwd ) } ,` +
` ${ sql ( sourceKind ) } ,NULL, ${ sql ( provider ) } , ${ sql ( gitBranch ) } , ${ sql ( obs ) } ,0)`
);
});
const sqlText = [
"BEGIN;" ,
"DELETE FROM local_thread_catalog WHERE host_id='local';" ,
"INSERT INTO local_thread_catalog (" +
"host_id, thread_id, display_title, source_created_at, source_updated_at, cwd, " +
"source_kind, source_detail, model_provider, git_branch, observation_sequence, missing_candidate" +
") VALUES " +
valuesSql . join ( ",\n" ) +
";" ,
// 标记冷构建完成,避免启动后又被半残状态卡住
"INSERT OR IGNORE INTO local_thread_catalog_sync_state (host_id) VALUES ('local');" ,
"UPDATE local_thread_catalog_sync_state SET " +
`watermark_updated_at= ${ sql ( maxUpdated || nowSec ) } , ` +
"initial_build_complete=1, " +
`observation_sequence= ${ sql ( visible . length ) } , ` +
`last_full_reconciled_at= ${ sql ( Math . floor ( nowSec )) } ` +
"WHERE host_id='local';" ,
"INSERT OR IGNORE INTO local_thread_catalog_hosts (host_id, host_kind) VALUES ('local','local');" ,
"INSERT OR IGNORE INTO local_thread_catalog_metadata (id, catalog_revision) VALUES (1, 0);" ,
"UPDATE local_thread_catalog_metadata SET catalog_revision=COALESCE(catalog_revision,0)+1 WHERE id=1;" ,
"COMMIT;" ,
"PRAGMA wal_checkpoint(TRUNCATE);" ,
]. join ( "\n" ) + "\n" ;
try {
if ( sqlite3Available ()) {
runSqlite3 ( LOCAL_CATALOG_DB , sqlText );
} else {
// 无 sqlite3 CLI 时走 sql.js(同步路径里已有依赖)
throw new Error ( "sqlite3 CLI 不可用,改走 sql.js" );
}
log ( `侧栏目录重建完成: ${ visible . length } 条(provider= ${ target } ) `);
return { rebuilt : visible . length };
} catch ( e ) {
// sql.js 兜底
try {
// 这里用同步 require 的 initSqlJs 需要 await,调用方是 async,所以抛回给上层处理
return { rebuilt : 0 , error : e . message || String ( e ), needSqlJs : true , sqlText , visibleCount : visible . length };
} catch ( e2 ) {
log ( `侧栏目录重建失败: ${ e . message || e } ` );
return { rebuilt : 0 , error : e . message || String ( e ) };
}
}
}
// Codex 桌面端对 codex-dev.db 有一套版本化迁移(PRAGMA user_version,当前迁移链到 v30,
// 依据 ChatGPT.app 0.147.x 的 app.asar 反编译核对)。工具箱自建该库时必须把 user_version
// 对齐到链尾:否则 Codex 会从 v0 重跑全部迁移,其中 v27/v28/v29 会把
// local_thread_catalog_sync_state.last_full_reconciled_at 置 NULL,首次启动即触发
// full reconcile——若 state 库线程没写成功,侧栏会话会"闪现后全部消失"。
// Codex 升级新增 v31+ 迁移时会自然在此基础上继续,不受影响。
const CATALOG_DB_USER_VERSION = 30 ;
function createLocalCatalogSchema ( db ) {
db . run ( `CREATE TABLE IF NOT EXISTS local_thread_catalog (
host_id TEXT NOT NULL,
thread_id TEXT NOT NULL,
display_title TEXT NOT NULL,
source_created_at REAL NOT NULL,
source_updated_at REAL NOT NULL,
cwd TEXT NOT NULL,
source_kind TEXT NOT NULL,
source_detail TEXT,
model_provider TEXT NOT NULL,
git_branch TEXT,
observation_sequence INTEGER NOT NULL,
missing_candidate INTEGER NOT NULL DEFAULT 0 CHECK (missing_candidate IN (0, 1)),
thread_source TEXT,
source_recency_at REAL NOT NULL DEFAULT 0,
PRIMARY KEY (host_id, thread_id)
)` );
db . run ( "CREATE INDEX IF NOT EXISTS local_thread_catalog_created_idx ON local_thread_catalog (host_id, source_created_at DESC, source_updated_at DESC, thread_id) WHERE missing_candidate=0" );
db . run ( "CREATE INDEX IF NOT EXISTS local_thread_catalog_cwd_created_idx ON local_thread_catalog (host_id, cwd, source_created_at DESC, source_updated_at DESC, thread_id) WHERE missing_candidate=0" );
db . run ( "CREATE INDEX IF NOT EXISTS local_thread_catalog_updated_idx ON local_thread_catalog (host_id, source_recency_at DESC, source_created_at DESC, thread_id) WHERE missing_candidate=0" );
db . run ( "CREATE INDEX IF NOT EXISTS local_thread_catalog_cwd_updated_idx ON local_thread_catalog (host_id, cwd, source_recency_at DESC, source_created_at DESC, thread_id) WHERE missing_candidate=0" );
db . run ( `CREATE TABLE IF NOT EXISTS local_thread_catalog_sync_state (
host_id TEXT PRIMARY KEY,
watermark_updated_at REAL,
initial_build_complete INTEGER NOT NULL DEFAULT 0,
observation_sequence INTEGER NOT NULL DEFAULT 0,
last_full_reconciled_at INTEGER
)` );
db . run ( "CREATE TABLE IF NOT EXISTS local_thread_catalog_hosts (host_id TEXT PRIMARY KEY, host_kind TEXT NOT NULL CHECK (host_kind IN ('local','ssh','wsl','remote-control')))" );
db . run ( "CREATE TABLE IF NOT EXISTS local_thread_catalog_metadata (id INTEGER PRIMARY KEY CHECK (id=1), catalog_revision INTEGER NOT NULL DEFAULT 0)" );
}
async function rebuildLocalThreadCatalogAsync ( records , target , log = () => {}) {
if ( ! records || ! records . length ) {
log ( "侧栏目录重建:无会话记录,跳过" );
return { rebuilt : 0 , skipped : true };
}
const visible = [];
const seen = new Set ();
for ( const r of records ) {
if ( ! r || ! r . id || seen . has ( r . id ) || r . archived ) continue ;
const hasText = !! ( r . first_user_message || r . preview || ( r . title && r . title !== "(无标题对话)" ));
if ( ! ( r . has_user_event || hasText )) continue ;
seen . add ( r . id );
visible . push ( r );
}
if ( ! visible . length ) {
log ( "侧栏目录重建:没有可展示会话,跳过" );
return { rebuilt : 0 , skipped : true };
}
let db ;
const existed = fs . existsSync ( LOCAL_CATALOG_DB );
try {
const SQL = await initSqlJs ({
locateFile : ( f ) =>
path . join ( global . __BUNDLE_ROOT__ || path . join ( __dirname , ".." , ".." ), "node_modules" , "sql.js" , "dist" , f ),
});
fs . mkdirSync ( path . dirname ( LOCAL_CATALOG_DB ), { recursive : true });
db = existed ? new SQL . Database ( fs . readFileSync ( LOCAL_CATALOG_DB )) : new SQL . Database ();
createLocalCatalogSchema ( db );
if ( ! existed ) db . run ( `PRAGMA user_version = ${ CATALOG_DB_USER_VERSION } ` );
if ( existed ) {
try {
const bak = LOCAL_CATALOG_DB + ".bak-" + ts ();
fs . copyFileSync ( LOCAL_CATALOG_DB , bak );
log ( `已备份侧栏目录库 → ${ path . basename ( bak ) } ` );
} catch ( e ) {
log ( `侧栏目录库备份失败: ${ e . message || e } ` );
}
}
const cols = sqlJsTableColumns ( db , "local_thread_catalog" );
const insertCols = [
"host_id" , "thread_id" , "display_title" , "source_created_at" , "source_updated_at" , "cwd" ,
"source_kind" , "source_detail" , "model_provider" , "git_branch" , "observation_sequence" ,
"missing_candidate" , "thread_source" , "source_recency_at" ,
]. filter (( c ) => cols . has ( c ));
const nowSec = Date . now () / 1000 ;
let maxUpdated = 0 ;
const valuesFor = ( r , idx ) => {
const created = secTs ( r . created_at_ms );
const updated = Math . max ( secTs ( r . updated_at_ms ), created );
maxUpdated = Math . max ( maxUpdated , updated );
const title = cleanThreadTitle ( r . title || "" , r . first_user_message || r . preview || "" , 80 ) || "(无标题对话)" ;
const values = {
host_id : "local" ,
thread_id : r . id ,
display_title : title ,
source_created_at : created ,
source_updated_at : updated ,
cwd : r . cwd || "" ,
source_kind : String ( r . source || "vscode" ). slice ( 0 , 64 ) || "vscode" ,
source_detail : null ,
model_provider : target || r . model_provider || "" ,
git_branch : r . git_branch || null ,
observation_sequence : idx + 1 ,
missing_candidate : 0 ,
thread_source : r . thread_source || "user" ,
source_recency_at : updated ,
};
return insertCols . map (( c ) => values [ c ]);
};
const insert = db . prepare (
`INSERT INTO local_thread_catalog ( ${ insertCols . join ( "," ) } ) VALUES ( ${ insertCols . map (() => "?" ). join ( "," ) } )`
);
db . run ( "BEGIN" );
db . run ( "DELETE FROM local_thread_catalog WHERE host_id='local'" );
visible . forEach (( r , idx ) => insert . run ( valuesFor ( r , idx )));
insert . free ();
db . run ( "INSERT OR IGNORE INTO local_thread_catalog_sync_state (host_id) VALUES ('local')" );
db . run (
"UPDATE local_thread_catalog_sync_state SET watermark_updated_at=?, initial_build_complete=1, observation_sequence=?, last_full_reconciled_at=? WHERE host_id='local'" ,
[ maxUpdated || nowSec , visible . length , Math . floor ( nowSec )]
);
db . run ( "INSERT OR IGNORE INTO local_thread_catalog_hosts (host_id, host_kind) VALUES ('local','local')" );
db . run ( "INSERT OR IGNORE INTO local_thread_catalog_metadata (id, catalog_revision) VALUES (1,0)" );
db . run ( "UPDATE local_thread_catalog_metadata SET catalog_revision=COALESCE(catalog_revision,0)+1 WHERE id=1" );
db . run ( "COMMIT" );
const exported = Buffer . from ( db . export ());
db . close ();
db = null ;
fs . writeFileSync ( LOCAL_CATALOG_DB , exported );
try { fs . rmSync ( LOCAL_CATALOG_DB + "-wal" , { force : true }); } catch {}
try { fs . rmSync ( LOCAL_CATALOG_DB + "-shm" , { force : true }); } catch {}
log ( `侧栏目录重建完成(sql.js): ${ visible . length } 条(provider= ${ target } ) `);
return { rebuilt : visible . length , created : ! existed };
} catch ( e ) {
if ( db ) try { db . run ( "ROLLBACK" ); } catch {}
if ( db ) try { db . close (); } catch {}
log ( `侧栏目录重建失败: ${ e . message || e } ` );
return { rebuilt : 0 , error : e . message || String ( e ) };
}
}
// 兜底:把 rollout 已确认有用户消息、但 state DB 仍为 0 的 has_user_event 强制拉起来。
// 新版 Codex 从 7/17 起大量线程默认写成 0,仅靠 upsert 的 CASE 也可能被后续状态覆盖。
function forceHasUserEventSql ( userEventIds ) {
if ( ! userEventIds || ! userEventIds . size ) return "" ;
const ids = [... userEventIds ]. map (( id ) => sql ( id ));
// 分批,避免单条 SQL 过长
const chunks = [];
for ( let i = 0 ; i < ids . length ; i += 200 ) chunks . push ( ids . slice ( i , i + 200 ));
return chunks
. map (( part ) => `UPDATE threads SET has_user_event=1 WHERE id IN ( ${ part . join ( "," ) } ) AND COALESCE(has_user_event,0)<>1;` )
. join ( "\n" );
}
function sqlJsTableColumns ( db , table ) {
const result = db . exec ( `PRAGMA table_info(" ${ String ( table ). replace ( /"/g , '""' ) } ")` );
if ( ! result . length ) return new Set ();
return new Set ( result [ 0 ]. values . map (( row ) => String ( row [ 1 ] || "" )). filter ( Boolean ));
}
function sqlJsCount ( db , table ) {
const result = db . exec ( `SELECT COUNT(*) FROM " ${ String ( table ). replace ( /"/g , '""' ) } "` );
return result . length && result [ 0 ]. values . length ? Number ( result [ 0 ]. values [ 0 ][ 0 ] || 0 ) : 0 ;
}
function sqlJsLocateFile ( file ) {
return path . join (
global . __BUNDLE_ROOT__ || path . join ( __dirname , ".." , ".." ),
"node_modules" , "sql.js" , "dist" , file
);
}
async function loadSqlJs () {
return initSqlJs ({ locateFile : sqlJsLocateFile });
}
// 创建一个可被当前 Codex 直接打开的完整 state_5.sqlite,而不是只造 threads 表。
// 通过临时文件安装,避免 Windows 下进程中断时留下半个数据库。
function createStateDbSqlJs ( SQL , dbPath ) {
const db = new SQL . Database ();
try {
db . exec ( STATE_DB_SCHEMA_SQL );
for ( const [ version , description , checksum ] of STATE_DB_MIGRATIONS ) {
db . run (
`INSERT INTO _sqlx_migrations (version, description, success, checksum, execution_time) ` +
`VALUES ( ${ version } , ${ sql ( description ) } , 1, X' ${ checksum } ', 0)`
);
}
const tmp = ` ${ dbPath } .td-create- ${ process . pid } - ${ Date . now () } .tmp` ;
fs . mkdirSync ( path . dirname ( dbPath ), { recursive : true });
try {
fs . writeFileSync ( tmp , Buffer . from ( db . export ()));
fs . renameSync ( tmp , dbPath );
} finally {
try { fs . rmSync ( tmp , { force : true }); } catch {}
}
} finally {
db . close ();
}
}
// sql.js 只接受 null/string/number/bigint 绑定值,其余类型直接抛错。
function bindValue ( v ) {
if ( v === null || v === undefined ) return null ;
if ( typeof v === "string" || typeof v === "number" ) return v ;
try {
const text = JSON . stringify ( v );
if ( typeof text === "string" ) return text ;
} catch {}
return String ( v );
}
// Windows 通常没有 sqlite3.exe。这里必须和 sqlite3 分支一样做 UPSERT,不能只 UPDATE 已有行,
// 否则新机器上的 state_*.sqlite 没有 threads 时,rollout 虽然存在,Codex 仍然看不到会话。
// 单条记录失败只跳过该条(记日志),不能让一条异常记录回滚整批——否则 state 库一条都进不去,
// 侧栏 catalog 却重建成功,Codex 启动 full reconcile 后会话会"闪现后全部消失"。
function upsertStateDbSqlJs ( db , records , target , userEventIds , cwdById , log = () => {}) {
const tables = db . exec ( "SELECT name FROM sqlite_master WHERE type='table' AND name='threads'" );
if ( ! tables . length ) return { insertedRows : 0 , provRows : 0 , ueRows : 0 , cwdRows : 0 , failedRows : 0 , skipped : true };
const cols = sqlJsTableColumns ( db , "threads" );
if ( ! cols . has ( "id" ) || ! cols . has ( "model_provider" )) {
return { insertedRows : 0 , provRows : 0 , ueRows : 0 , cwdRows : 0 , failedRows : 0 , skipped : true };
}
const desired = threadColumns ( cols );
const updateSet = threadUpdateSet ( desired , target , true );
const statement = db . prepare (
`INSERT INTO threads ( ${ desired . join ( "," ) } ) VALUES ( ${ desired . map (() => "?" ). join ( "," ) } ) ` +
`ON CONFLICT(id) DO UPDATE SET ${ updateSet . join ( "," ) } `
);
const before = sqlJsCount ( db , "threads" );
let provRows = 0 ;
let ueRows = 0 ;
let cwdRows = 0 ;
let failedRows = 0 ;
try {
db . run ( "BEGIN" );
for ( const record of records ) {
try {
statement . run ( desired . map (( column ) => bindValue ( threadValue ( record , target , column ))). concat ([ target ]));
} catch ( e ) {
failedRows ++ ;
if ( failedRows <= 3 ) log ( ` 线程回填跳过 ${ record . id || "(无id)" } : ${ e . message || e } ` );
}
}
statement . free ();
db . run ( `UPDATE threads SET model_provider=? WHERE COALESCE(model_provider,'')<>?` , [ target , target ]);
provRows = db . getRowsModified ();
if ( cols . has ( "has_user_event" )) {
for ( const id of userEventIds || []) {
db . run ( "UPDATE threads SET has_user_event=1 WHERE id=? AND COALESCE(has_user_event,0)<>1" , [ id ]);
ueRows += db . getRowsModified ();
}
}
if ( cols . has ( "cwd" )) {
for ( const [ id , cwd ] of Object . entries ( cwdById || {})) {
db . run ( "UPDATE threads SET cwd=? WHERE id=? AND COALESCE(cwd,'')<>?" , [ cwd , id , cwd ]);
cwdRows += db . getRowsModified ();
}
}
db . run ( "COMMIT" );
} catch ( e ) {
try { statement . free (); } catch {}
try { db . run ( "ROLLBACK" ); } catch {}
throw e ;
}
return {
insertedRows : Math . max ( 0 , sqlJsCount ( db , "threads" ) - before ),
provRows ,
ueRows ,
cwdRows ,
failedRows ,
};
}
2026-07-15 18:01:56 +08:00
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 ));
}
2026-07-15 18:45:40 +08:00
for ( const db of fs . readdirSync ( CODEX_HOME ). filter (( f ) => /^ state_ . * \ . sqlite ( ?:- ( ?: wal | shm )) ? $ / . test ( f ))) {
2026-07-15 18:01:56 +08:00
fs . copyFileSync ( path . join ( CODEX_HOME , db ), path . join ( backup , db ));
}
2026-08-16 12:45:32 +08:00
// 同时备份侧栏目录库
if ( fs . existsSync ( LOCAL_CATALOG_DB )) {
try {
fs . copyFileSync ( LOCAL_CATALOG_DB , path . join ( backup , "codex-dev.db" ));
} catch {}
}
2026-07-15 18:01:56 +08:00
log ( `已备份到 ${ backup } ` );
2026-07-15 18:45:40 +08:00
// 2) 遍历 rollout:改首行 provider,收集完整线程元信息
2026-07-15 18:01:56 +08:00
const userEventIds = new Set ();
const cwdById = {};
2026-07-15 18:45:40 +08:00
const titles = loadIndexTitles ();
const records = [];
2026-07-15 18:01:56 +08:00
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 );
2026-07-15 18:45:40 +08:00
const rec = readRolloutRecord ( file , titles );
if ( rec ) {
rec . model_provider = target ;
records . push ( rec );
}
2026-07-15 18:01:56 +08:00
}
2026-07-15 18:45:40 +08:00
log ( `改写 rollout 文件: ${ changedFiles } 个;含用户消息的会话: ${ userEventIds . size } 条;可回填线程: ${ records . length } 条` );
// 2.5) Codex 项目分组来自全局 state,历史 cwd 需要合并到 project-order。
const projectState = updateProjectState ( records , log );
2026-07-15 18:01:56 +08:00
2026-08-16 12:45:32 +08:00
// 3) 首次安装场景:目标目录可能连 state_*.sqlite 都没有。先创建完整的
// state_5.sqlite,再走下面统一的 sqlite3 / sql.js UPSERT 流程。
let SQL = null ;
let stateDbCreated = 0 ;
let stateDbCreateError = "" ;
let stateDbNames = fs . readdirSync ( CODEX_HOME ). filter (( f ) => /^ state_ . * \ . sqlite$ / . test ( f ));
if ( ! stateDbNames . length ) {
const stateDbName = "state_5.sqlite" ;
try {
SQL = await loadSqlJs ();
createStateDbSqlJs ( SQL , path . join ( CODEX_HOME , stateDbName ));
stateDbNames = [ stateDbName ];
stateDbCreated = 1 ;
log ( `未找到 state_*.sqlite,已创建完整状态库: ${ stateDbName } ` );
} catch ( e ) {
stateDbCreateError = e . message || String ( e );
log ( `创建 state_5.sqlite 失败: ${ stateDbCreateError } ` );
}
}
// 4) 更新所有 state_*.sqlite
2026-07-15 18:01:56 +08:00
onProgress ({ phase : "更新数据库" , done : 0 , total : 0 });
2026-07-15 18:45:40 +08:00
if ( sqlite3Available ()) {
let provRows = 0 ;
let ueRows = 0 ;
let cwdRows = 0 ;
let insertedRows = 0 ;
2026-08-16 12:45:32 +08:00
const forceUeSql = forceHasUserEventSql ( userEventIds );
for ( const name of stateDbNames ) {
2026-07-15 18:45:40 +08:00
const dbPath = path . join ( CODEX_HOME , name );
try {
const cols = sqliteCols ( dbPath );
if ( ! cols . has ( "model_provider" ) || ! cols . has ( "has_user_event" )) continue ;
const beforeProvider = Number ( runSqlite3 ( dbPath , `SELECT COUNT(*) FROM threads WHERE COALESCE(model_provider,'')<> ${ sql ( target ) } ;\n` ). trim () || 0 );
const beforeUserEvent = Number ( runSqlite3 ( dbPath , "SELECT COUNT(*) FROM threads WHERE COALESCE(has_user_event,0)<>1;\n" ). trim () || 0 );
const beforeRows = Number ( runSqlite3 ( dbPath , "SELECT COUNT(*) FROM threads;\n" ). trim () || 0 );
runSqlite3 ( dbPath , buildThreadUpsertSql ( records , target , cols ));
2026-08-16 12:45:32 +08:00
// 兜底:强制把 rollout 已确认有用户消息的线程 has_user_event 拉起来
if ( forceUeSql ) runSqlite3 ( dbPath , "BEGIN;\n" + forceUeSql + "\nCOMMIT;\n" );
2026-07-15 18:45:40 +08:00
runSqlite3 ( dbPath , "PRAGMA wal_checkpoint(TRUNCATE);\n" );
const afterUserEvent = Number ( runSqlite3 ( dbPath , "SELECT COUNT(*) FROM threads WHERE COALESCE(has_user_event,0)<>1;\n" ). trim () || 0 );
const afterRows = Number ( runSqlite3 ( dbPath , "SELECT COUNT(*) FROM threads;\n" ). trim () || 0 );
provRows += beforeProvider ;
ueRows += Math . max ( 0 , beforeUserEvent - afterUserEvent );
insertedRows += Math . max ( 0 , afterRows - beforeRows );
cwdRows += records . filter (( r ) => r . cwd ). length ;
} catch ( e ) {
log ( ` sqlite3 数据库跳过 ${ name } : ${ e . message || e } ` );
}
}
log ( `数据库更新:provider ${ provRows } 行、has_user_event ${ ueRows } 行、新增线程 ${ insertedRows } 行、项目索引 + ${ projectState . addedProjects } ` );
2026-07-16 19:16:45 +08:00
// 【增强】4) 重建 session_index.jsonl,确保所有会话都在索引中
onProgress ({ phase : "重建索引" , done : 0 , total : 0 });
const indexResult = rebuildSessionIndex ( records , log );
2026-08-16 12:45:32 +08:00
// 【关键修复】5) 重建 Codex 桌面侧栏 local_thread_catalog
// 新版 ChatGPT/Codex 侧栏读的是 ~/.codex/sqlite/codex-dev.db,不是 state_5.sqlite。
onProgress ({ phase : "重建侧栏目录" , done : 0 , total : 0 });
const catalogResult = await rebuildLocalThreadCatalogAsync ( records , target , log );
2026-07-15 18:45:40 +08:00
onProgress ({ phase : "完成" , done : 0 , total : 0 });
return {
target ,
totalConversations : rtotal ,
changedFiles ,
userEvents : userEventIds . size ,
provRows ,
ueRows ,
cwdRows ,
insertedRows ,
projectRows : projectState . addedProjects ,
projectlessRemoved : projectState . removedProjectless ,
2026-07-16 19:16:45 +08:00
indexRebuilt : indexResult . rebuilt || 0 ,
2026-08-16 12:45:32 +08:00
catalogRebuilt : catalogResult . rebuilt || 0 ,
stateDbCreated ,
stateDbCreateError ,
2026-07-15 18:45:40 +08:00
backup ,
};
}
2026-08-16 12:45:32 +08:00
if ( ! SQL ) SQL = await loadSqlJs ();
2026-07-15 18:01:56 +08:00
let provRows = 0 ;
let ueRows = 0 ;
let cwdRows = 0 ;
2026-08-16 12:45:32 +08:00
let insertedRows = 0 ;
let failedRows = 0 ;
for ( const name of stateDbNames ) {
2026-07-15 18:01:56 +08:00
const dbPath = path . join ( CODEX_HOME , name );
let db ;
try {
db = new SQL . Database ( fs . readFileSync ( dbPath ));
2026-08-16 12:45:32 +08:00
const result = upsertStateDbSqlJs ( db , records , target , userEventIds , cwdById , log );
if ( result . skipped ) {
2026-07-15 18:01:56 +08:00
db . close ();
2026-08-16 12:45:32 +08:00
db = null ;
2026-07-15 18:01:56 +08:00
continue ;
}
2026-08-16 12:45:32 +08:00
provRows += result . provRows ;
ueRows += result . ueRows ;
cwdRows += result . cwdRows ;
insertedRows += result . insertedRows ;
failedRows += result . failedRows || 0 ;
2026-07-15 18:01:56 +08:00
fs . writeFileSync ( dbPath , Buffer . from ( db . export ()));
db . close ();
2026-08-16 12:45:32 +08:00
db = null ;
try { fs . rmSync ( dbPath + "-wal" , { force : true }); } catch {}
try { fs . rmSync ( dbPath + "-shm" , { force : true }); } catch {}
2026-07-15 18:01:56 +08:00
} catch ( e ) {
if ( db ) try { db . close (); } catch {}
log ( ` 数据库跳过 ${ name } : ${ e . message || e } ` );
}
}
2026-08-16 12:45:32 +08:00
log ( `数据库更新:provider ${ provRows } 行、has_user_event ${ ueRows } 行、cwd ${ cwdRows } 行、新增线程 ${ insertedRows } 行 ${ failedRows ? `、失败跳过 ${ failedRows } 行` : "" } ` );
2026-07-16 19:16:45 +08:00
// 【增强】4) 重建 session_index.jsonl,确保所有会话都在索引中
onProgress ({ phase : "重建索引" , done : 0 , total : 0 });
const indexResult = rebuildSessionIndex ( records , log );
2026-08-16 12:45:32 +08:00
// 【关键修复】5) 重建 Codex 桌面侧栏 local_thread_catalog
onProgress ({ phase : "重建侧栏目录" , done : 0 , total : 0 });
const catalogResult = await rebuildLocalThreadCatalogAsync ( records , target , log );
2026-07-15 18:01:56 +08:00
onProgress ({ phase : "完成" , done : 0 , total : 0 });
return {
target ,
totalConversations : rtotal ,
changedFiles ,
userEvents : userEventIds . size ,
provRows ,
ueRows ,
cwdRows ,
2026-08-16 12:45:32 +08:00
insertedRows ,
2026-07-15 18:45:40 +08:00
projectRows : projectState . addedProjects ,
projectlessRemoved : projectState . removedProjectless ,
2026-07-16 19:16:45 +08:00
indexRebuilt : indexResult . rebuilt || 0 ,
2026-08-16 12:45:32 +08:00
catalogRebuilt : catalogResult . rebuilt || 0 ,
stateDbCreated ,
stateDbCreateError ,
2026-07-15 18:01:56 +08:00
backup ,
};
}
2026-08-16 12:45:32 +08:00
module . exports = { syncProviderToCurrent , codexRunning , targetProvider , setCodexDataDir };