@@ -197,6 +197,11 @@ const CLAUDE_MD_FILE_NAME = 'CLAUDE.md';
197197const CLAUDE_PROJECTS_DIR = path.join(os.homedir(), '.claude', 'projects');
198198const CODEBUDDY_DIR = path.join(os.homedir(), '.codebuddy');
199199const CODEBUDDY_PROJECTS_DIR = path.join(CODEBUDDY_DIR, 'projects');
200+ const CODEXMATE_DIR = path.join(os.homedir(), '.codexmate');
201+ const CODEXMATE_SESSIONS_DIR = path.join(CODEXMATE_DIR, 'sessions');
202+ const CODEXMATE_DERIVED_SESSIONS_DIR = path.join(CODEXMATE_SESSIONS_DIR, 'derived');
203+ const CODEXMATE_DERIVED_CODEX_DIR = path.join(CODEXMATE_DERIVED_SESSIONS_DIR, 'codex');
204+ const CODEXMATE_DERIVED_CLAUDE_DIR = path.join(CODEXMATE_DERIVED_SESSIONS_DIR, 'claude');
200205const GEMINI_DIR = path.join(os.homedir(), '.gemini');
201206const GEMINI_TMP_DIR = path.join(GEMINI_DIR, 'tmp');
202207const RECENT_CONFIGS_FILE = path.join(CONFIG_DIR, 'recent-configs.json');
@@ -1309,6 +1314,58 @@ function getCodeBuddyProjectsDir() {
13091314 return resolveExistingDir(candidates, CODEBUDDY_PROJECTS_DIR);
13101315}
13111316
1317+ function getCodexmateDerivedSessionsRoot(target) {
1318+ if (target === 'claude') {
1319+ return CODEXMATE_DERIVED_CLAUDE_DIR;
1320+ }
1321+ return CODEXMATE_DERIVED_CODEX_DIR;
1322+ }
1323+
1324+ function normalizeSessionDerivedTarget(value) {
1325+ const normalized = typeof value === 'string' ? value.trim().toLowerCase() : '';
1326+ if (normalized === 'codex' || normalized === 'claude') {
1327+ return normalized;
1328+ }
1329+ return '';
1330+ }
1331+
1332+ function normalizeSessionDerivedSource(value) {
1333+ return normalizeSessionDerivedTarget(value);
1334+ }
1335+
1336+ function buildSessionDerivedSourceKey(source, sessionId, filePath) {
1337+ const baseSource = normalizeSessionDerivedSource(source);
1338+ const id = typeof sessionId === 'string' ? sessionId.trim() : '';
1339+ const pathValue = typeof filePath === 'string' ? filePath.trim() : '';
1340+ const seed = `${baseSource}|${id}|${pathValue}`;
1341+ return crypto.createHash('sha1').update(seed).digest('hex').slice(0, 16);
1342+ }
1343+
1344+ function formatCompactTimestamp(value = Date.now()) {
1345+ const stamp = new Date(value);
1346+ const year = String(stamp.getFullYear());
1347+ const month = String(stamp.getMonth() + 1).padStart(2, '0');
1348+ const day = String(stamp.getDate()).padStart(2, '0');
1349+ const hour = String(stamp.getHours()).padStart(2, '0');
1350+ const minute = String(stamp.getMinutes()).padStart(2, '0');
1351+ const second = String(stamp.getSeconds()).padStart(2, '0');
1352+ return `${year}${month}${day}-${hour}${minute}${second}`;
1353+ }
1354+
1355+ function buildDerivedSessionId(baseId) {
1356+ const safeBase = typeof baseId === 'string' && baseId.trim() ? baseId.trim() : 'session';
1357+ const normalized = safeBase.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64) || 'session';
1358+ const suffix = crypto.randomBytes(3).toString('hex');
1359+ return `${normalized}-${formatCompactTimestamp()}-${suffix}`;
1360+ }
1361+
1362+ function buildDerivedSessionOutputDir(target, source, sourceKey) {
1363+ const targetRoot = getCodexmateDerivedSessionsRoot(target);
1364+ const safeSource = normalizeSessionDerivedSource(source) || 'codex';
1365+ const safeKey = typeof sourceKey === 'string' && sourceKey.trim() ? sourceKey.trim() : 'unknown';
1366+ return path.join(targetRoot, safeSource, safeKey);
1367+ }
1368+
13121369function readModelsCacheEntry(cacheKey) {
13131370 if (!cacheKey) return null;
13141371 const entry = g_modelsCache.get(cacheKey);
@@ -3280,6 +3337,57 @@ function collectRecentJsonlFiles(rootDir, options = {}) {
32803337 return filesMeta.slice(0, returnCount).map(item => item.filePath);
32813338}
32823339
3340+ function collectRecentJsonlFilesFromRoots(rootDirs, options = {}) {
3341+ const roots = Array.isArray(rootDirs)
3342+ ? rootDirs.filter((dirPath) => typeof dirPath === 'string' && dirPath.trim() && fs.existsSync(dirPath.trim()))
3343+ : [];
3344+ if (roots.length === 0) {
3345+ return [];
3346+ }
3347+
3348+ const returnCount = Math.max(1, Number(options.returnCount) || 1);
3349+ const maxFilesScanned = Math.max(returnCount, Number(options.maxFilesScanned) || 2000);
3350+ const ignoreSubPath = typeof options.ignoreSubPath === 'string' ? options.ignoreSubPath : '';
3351+ const stack = roots.map((dirPath) => dirPath.trim());
3352+ const filesMeta = [];
3353+ let scanned = 0;
3354+
3355+ while (stack.length > 0 && scanned < maxFilesScanned) {
3356+ const dir = stack.pop();
3357+ let entries = [];
3358+ try {
3359+ entries = fs.readdirSync(dir, { withFileTypes: true });
3360+ } catch (_) {
3361+ continue;
3362+ }
3363+
3364+ for (const entry of entries) {
3365+ const fullPath = path.join(dir, entry.name);
3366+ if (entry.isDirectory()) {
3367+ stack.push(fullPath);
3368+ continue;
3369+ }
3370+ if (!entry.isFile() || !entry.name.endsWith('.jsonl')) {
3371+ continue;
3372+ }
3373+ if (ignoreSubPath && fullPath.includes(ignoreSubPath)) {
3374+ continue;
3375+ }
3376+ scanned += 1;
3377+ try {
3378+ const stat = fs.statSync(fullPath);
3379+ filesMeta.push({ filePath: fullPath, mtimeMs: stat.mtimeMs || 0 });
3380+ } catch (_) {}
3381+ if (scanned >= maxFilesScanned) {
3382+ break;
3383+ }
3384+ }
3385+ }
3386+
3387+ filesMeta.sort((a, b) => b.mtimeMs - a.mtimeMs);
3388+ return filesMeta.slice(0, returnCount).map(item => item.filePath);
3389+ }
3390+
32833391function getSessionListCache(cacheKey, forceRefresh = false) {
32843392 if (forceRefresh) {
32853393 g_sessionListCache.delete(cacheKey);
@@ -4395,7 +4503,7 @@ function listCodexSessions(limit, options = {}) {
43954503 const titleReadBytes = Number.isFinite(Number(options.titleReadBytes))
43964504 ? Math.max(1024, Math.floor(Number(options.titleReadBytes)))
43974505 : SESSION_TITLE_READ_BYTES;
4398- const files = collectRecentJsonlFiles( codexSessionsDir, {
4506+ const files = collectRecentJsonlFilesFromRoots([ codexSessionsDir, getCodexmateDerivedSessionsRoot('codex')] , {
43994507 returnCount: scanCount,
44004508 maxFilesScanned
44014509 });
@@ -4420,7 +4528,10 @@ function listCodexSessions(limit, options = {}) {
44204528
44214529function listClaudeSessions(limit, options = {}) {
44224530 const claudeProjectsDir = getClaudeProjectsDir();
4423- if (!fs.existsSync(claudeProjectsDir)) {
4531+ const derivedClaudeRoot = getCodexmateDerivedSessionsRoot('claude');
4532+ const hasProjectsDir = fs.existsSync(claudeProjectsDir);
4533+ const hasDerivedDir = fs.existsSync(derivedClaudeRoot);
4534+ if (!hasProjectsDir && !hasDerivedDir) {
44244535 return [];
44254536 }
44264537
@@ -4448,12 +4559,14 @@ function listClaudeSessions(limit, options = {}) {
44484559
44494560 const sessions = [];
44504561 let projectDirs = [];
4451- try {
4452- projectDirs = fs.readdirSync(claudeProjectsDir, { withFileTypes: true })
4453- .filter(entry => entry.isDirectory())
4454- .map(entry => path.join(claudeProjectsDir, entry.name));
4455- } catch (e) {
4456- projectDirs = [];
4562+ if (hasProjectsDir) {
4563+ try {
4564+ projectDirs = fs.readdirSync(claudeProjectsDir, { withFileTypes: true })
4565+ .filter(entry => entry.isDirectory())
4566+ .map(entry => path.join(claudeProjectsDir, entry.name));
4567+ } catch (e) {
4568+ projectDirs = [];
4569+ }
44574570 }
44584571
44594572 for (const projectDir of projectDirs) {
@@ -4619,6 +4732,25 @@ function listClaudeSessions(limit, options = {}) {
46194732 }
46204733 }
46214734
4735+ if (fs.existsSync(derivedClaudeRoot)) {
4736+ const seen = new Set(sessions.map((item) => (item && item.filePath ? item.filePath : '')).filter(Boolean));
4737+ const derivedFiles = collectRecentJsonlFiles(derivedClaudeRoot, {
4738+ returnCount: scanCount,
4739+ maxFilesScanned
4740+ });
4741+ for (const filePath of derivedFiles) {
4742+ if (seen.has(filePath)) continue;
4743+ const summary = parseClaudeSessionSummary(filePath, {
4744+ summaryReadBytes,
4745+ titleReadBytes
4746+ });
4747+ if (summary) {
4748+ sessions.push(summary);
4749+ }
4750+ seen.add(filePath);
4751+ }
4752+ }
4753+
46224754 return mergeAndLimitSessions(sessions, limit);
46234755}
46244756
@@ -4963,19 +5095,25 @@ function resolveSessionFilePath(source, filePath, sessionId) {
49635095 const normalizedSource = source === 'claude' || source === 'gemini' || source === 'codebuddy'
49645096 ? source
49655097 : 'codex';
4966- const root = normalizedSource === 'claude'
4967- ? getClaudeProjectsDir()
5098+ const homeDir = process && process.env && process.env.HOME ? process.env.HOME : '';
5099+ const derivedCodexDir = homeDir ? `${homeDir}/.codexmate/sessions/derived/codex` : '';
5100+ const derivedClaudeDir = homeDir ? `${homeDir}/.codexmate/sessions/derived/claude` : '';
5101+ const roots = normalizedSource === 'claude'
5102+ ? [getClaudeProjectsDir(), derivedClaudeDir]
49685103 : (normalizedSource === 'gemini'
4969- ? getGeminiTmpDir()
4970- : (normalizedSource === 'codebuddy' ? getCodeBuddyProjectsDir() : getCodexSessionsDir()));
4971- if (!root || !fs.existsSync(root)) {
5104+ ? [getGeminiTmpDir()]
5105+ : (normalizedSource === 'codebuddy'
5106+ ? [getCodeBuddyProjectsDir()]
5107+ : [getCodexSessionsDir(), derivedCodexDir]));
5108+ const availableRoots = roots.filter((dirPath) => dirPath && fs.existsSync(dirPath));
5109+ if (availableRoots.length === 0) {
49725110 return '';
49735111 }
49745112
49755113 if (typeof filePath === 'string' && filePath.trim()) {
49765114 const expandedPath = expandHomePath(filePath.trim());
49775115 const targetPath = expandedPath ? path.resolve(expandedPath) : '';
4978- if (targetPath && fs.existsSync(targetPath) && isPathInside(targetPath, root )) {
5116+ if (targetPath && fs.existsSync(targetPath) && availableRoots.some((rootPath) => isPathInside(targetPath, rootPath) )) {
49795117 return targetPath;
49805118 }
49815119 }
@@ -4985,7 +5123,7 @@ function resolveSessionFilePath(source, filePath, sessionId) {
49855123 const lookupStore = g_sessionFileLookupCache[normalizedSource];
49865124 if (lookupStore instanceof Map && lookupStore.has(targetId)) {
49875125 const cachedPath = lookupStore.get(targetId);
4988- if (cachedPath && fs.existsSync(cachedPath) && isPathInside(cachedPath, root )) {
5126+ if (cachedPath && fs.existsSync(cachedPath) && availableRoots.some((rootPath) => isPathInside(cachedPath, rootPath) )) {
49895127 return cachedPath;
49905128 }
49915129 lookupStore.delete(targetId);
@@ -5020,7 +5158,11 @@ function resolveSessionFilePath(source, filePath, sessionId) {
50205158 }
50215159 matchedFile = filesMeta.find(item => path.basename(item, '.json').toLowerCase() === targetId) || '';
50225160 } else {
5023- const files = collectJsonlFiles(root, 5000);
5161+ const files = [];
5162+ for (const rootPath of availableRoots) {
5163+ files.push(...collectJsonlFiles(rootPath, 5000));
5164+ if (files.length >= 5000) break;
5165+ }
50245166 matchedFile = files.find(item => path.basename(item, '.jsonl').toLowerCase() === targetId) || '';
50255167 }
50265168 if (matchedFile && fs.existsSync(matchedFile)) {
@@ -6664,6 +6806,134 @@ async function exportSessionData(params = {}) {
66646806 };
66656807}
66666808
6809+ async function convertSessionToDerived(params = {}) {
6810+ const source = normalizeSessionDerivedSource(params.source);
6811+ const target = normalizeSessionDerivedTarget(params.target || params.to);
6812+ if (!source || !target) {
6813+ return { error: 'Invalid source/target' };
6814+ }
6815+ if (source === target) {
6816+ return { error: 'source and target must be different' };
6817+ }
6818+
6819+ const maxMessages = resolveMaxMessagesValue(params.maxMessages, MAX_EXPORT_MESSAGES);
6820+ const filePath = resolveSessionFilePath(source, getSessionFileArg(params), params.sessionId);
6821+ if (!filePath) {
6822+ return { error: 'Session file not found' };
6823+ }
6824+
6825+ let extracted;
6826+ try {
6827+ extracted = await extractMessagesFromFile(filePath, source, { maxMessages });
6828+ } catch (_) {
6829+ extracted = null;
6830+ }
6831+ if (!extracted) {
6832+ return { error: 'Failed to parse session file' };
6833+ }
6834+
6835+ const baseSessionId = extracted.sessionId || params.sessionId || path.basename(filePath, '.jsonl');
6836+ const derivedSessionId = buildDerivedSessionId(baseSessionId);
6837+ const sourceKey = buildSessionDerivedSourceKey(source, baseSessionId, filePath);
6838+ const outputDir = buildDerivedSessionOutputDir(target, source, sourceKey);
6839+ ensureDir(outputDir);
6840+ const outputPath = path.join(outputDir, `${derivedSessionId}.jsonl`);
6841+ const metaPath = path.join(outputDir, `${derivedSessionId}.meta.json`);
6842+
6843+ const cwd = typeof extracted.cwd === 'string' ? extracted.cwd : '';
6844+ const messages = removeLeadingSystemMessage(Array.isArray(extracted.messages) ? extracted.messages : []);
6845+ const now = Date.now();
6846+ const baseTime = new Date(now).toISOString();
6847+ const lines = [];
6848+
6849+ if (target === 'codex') {
6850+ lines.push(JSON.stringify({ type: 'session_meta', timestamp: baseTime, payload: { id: derivedSessionId, cwd } }));
6851+ for (let i = 0; i < messages.length; i += 1) {
6852+ const message = messages[i];
6853+ if (!message) continue;
6854+ const role = normalizeRole(message.role);
6855+ if (role !== 'user' && role !== 'assistant' && role !== 'system') continue;
6856+ const text = typeof message.text === 'string' ? message.text : '';
6857+ if (!text) continue;
6858+ lines.push(JSON.stringify({
6859+ type: 'response_item',
6860+ timestamp: toIsoTime(message.timestamp, '') || new Date(now + i).toISOString(),
6861+ payload: { type: 'message', role, content: text }
6862+ }));
6863+ }
6864+ } else {
6865+ for (let i = 0; i < messages.length; i += 1) {
6866+ const message = messages[i];
6867+ if (!message) continue;
6868+ const role = normalizeRole(message.role);
6869+ if (role !== 'user' && role !== 'assistant' && role !== 'system') continue;
6870+ const text = typeof message.text === 'string' ? message.text : '';
6871+ if (!text) continue;
6872+ lines.push(JSON.stringify({
6873+ type: role,
6874+ timestamp: toIsoTime(message.timestamp, '') || new Date(now + i).toISOString(),
6875+ sessionId: derivedSessionId,
6876+ cwd,
6877+ message: { content: text }
6878+ }));
6879+ }
6880+ }
6881+
6882+ fs.writeFileSync(outputPath, `${lines.join('\n')}\n`, 'utf-8');
6883+ writeJsonAtomic(metaPath, {
6884+ version: 1,
6885+ createdAt: baseTime,
6886+ source: {
6887+ type: source,
6888+ sessionId: baseSessionId,
6889+ filePath
6890+ },
6891+ target: {
6892+ type: target,
6893+ sessionId: derivedSessionId,
6894+ filePath: outputPath
6895+ },
6896+ options: {
6897+ maxMessages: maxMessages === Infinity ? 'all' : maxMessages
6898+ }
6899+ });
6900+
6901+ invalidateSessionListCache();
6902+
6903+ const summary = target === 'codex'
6904+ ? parseCodexSessionSummary(outputPath, { summaryReadBytes: SESSION_BROWSE_SUMMARY_READ_BYTES, titleReadBytes: SESSION_BROWSE_SUMMARY_READ_BYTES })
6905+ : parseClaudeSessionSummary(outputPath, { summaryReadBytes: SESSION_BROWSE_SUMMARY_READ_BYTES, titleReadBytes: SESSION_BROWSE_SUMMARY_READ_BYTES });
6906+ const maxMessagesLabel = maxMessages === Infinity ? 'all' : maxMessages;
6907+
6908+ return {
6909+ derived: true,
6910+ source,
6911+ target,
6912+ truncated: !!extracted.truncated,
6913+ maxMessages: maxMessagesLabel,
6914+ session: summary || {
6915+ source: target,
6916+ sourceLabel: target === 'codex' ? 'Codex' : 'Claude Code',
6917+ sessionId: derivedSessionId,
6918+ title: derivedSessionId,
6919+ cwd,
6920+ createdAt: baseTime,
6921+ updatedAt: baseTime,
6922+ messageCount: messages.length,
6923+ totalTokens: 0,
6924+ contextWindow: 0,
6925+ inputTokens: 0,
6926+ cachedInputTokens: 0,
6927+ outputTokens: 0,
6928+ reasoningOutputTokens: 0,
6929+ __messageCountExact: true,
6930+ filePath: outputPath,
6931+ keywords: [],
6932+ capabilities: {}
6933+ }
6934+ };
6935+ }
6936+
66676937function buildExportPayload(includeKeys) {
66686938 const { config } = readConfigOrVirtualDefault();
66696939 const providers = config.model_providers || {};
@@ -9832,6 +10102,9 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
983210102 case 'export-session':
983310103 result = await exportSessionData(params);
983410104 break;
10105+ case 'convert-session':
10106+ result = await convertSessionToDerived(params || {});
10107+ break;
983510108 case 'delete-session':
983610109 result = await deleteSessionData(params || {});
983710110 break;
0 commit comments