Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
231 changes: 215 additions & 16 deletions cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -4556,10 +4556,10 @@ function listCodexSessions(limit, options = {}) {
titleReadBytes
});
if (summary) {
sessions.push({
sessions.push(attachSessionNativeStatus({
...summary,
derived: isDerivedSessionFile(filePath)
});
}));
}

if (sessions.length >= targetCount) {
Expand Down Expand Up @@ -4721,7 +4721,7 @@ function listClaudeSessions(limit, options = {}) {
const keywords = normalizeKeywords(entry.keywords);
const capabilities = normalizeCapabilities(entry.capabilities);

sessions.push({
sessions.push(attachSessionNativeStatus({
source: 'claude',
sourceLabel: 'Claude Code',
provider,
Expand All @@ -4744,7 +4744,7 @@ function listClaudeSessions(limit, options = {}) {
derived: isDerivedSessionFile(filePath),
keywords,
capabilities
});
}));

if (sessions.length >= targetCount) {
break;
Expand Down Expand Up @@ -6429,6 +6429,66 @@ function isDerivedSessionFile(filePath) {
return /-\d{8}-\d{6}-[0-9a-f]{6}$/i.test(base);
}

function readDerivedSessionMeta(filePath) {
const metaPath = getDerivedSessionMetaPath(filePath);
if (!metaPath) return null;
return readJsonFile(metaPath, null);
}

function resolveNativeSessionFilePath(source, sessionId, cwd = '') {
const normalizedSource = normalizeSessionDerivedTarget(source);
const id = typeof sessionId === 'string' ? sessionId.trim() : '';
if (!normalizedSource || !id) return '';
if (normalizedSource === 'codex') {
return path.join(getCodexSessionsDir(), `${id}.jsonl`);
}
const projectDir = resolveClaudeProjectDirForCwd(cwd || '') || path.join(getClaudeProjectsDir(), 'codexmate-derived');
return path.join(projectDir, `${id}.jsonl`);
}

function buildSessionNativeStatus(source, sessionId, cwd, filePath, derived) {
const normalizedSource = normalizeSessionDerivedTarget(source);
const id = typeof sessionId === 'string' ? sessionId.trim() : '';
const currentPath = typeof filePath === 'string' && filePath.trim()
? path.resolve(expandHomePath(filePath.trim()))
: '';
const nativePath = resolveNativeSessionFilePath(normalizedSource, id, cwd || '');
const resolvedNativePath = nativePath ? path.resolve(expandHomePath(nativePath)) : '';
const inNativePath = !!(currentPath && resolvedNativePath && currentPath === resolvedNativePath);
const nativeExists = !!(resolvedNativePath && fs.existsSync(resolvedNativePath));
const isDerived = derived === true || isDerivedSessionFile(currentPath);
return {
derived: isDerived,
nativeAvailable: inNativePath || nativeExists || (!isDerived && !!currentPath && fs.existsSync(currentPath)),
nativePath: resolvedNativePath || (currentPath && !isDerived ? currentPath : ''),
derivedPath: isDerived && currentPath && !inNativePath ? currentPath : '',
nativeImportAvailable: isDerived && !!resolvedNativePath && !inNativePath
Comment thread
coderabbitai[bot] marked this conversation as resolved.
};
Comment on lines +6625 to +6647

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Tighten native-status gating before exposing import/native fields.

buildSessionNativeStatus() currently reports nativeAvailable: true for unsupported sources because (!isDerived && currentExists) still fires when source is not codex/claude. It also keeps nativeImportAvailable: true after a native copy already exists, so clients can keep surfacing an import action that should disappear after import.

Suggested fix
 function buildSessionNativeStatus(source, sessionId, cwd, filePath, derived) {
     const normalizedSource = normalizeSessionDerivedTarget(source);
+    const currentPath = typeof filePath === 'string' && filePath.trim()
+        ? path.resolve(expandHomePath(filePath.trim()))
+        : '';
+    const isDerived = derived === true || isDerivedSessionFile(currentPath);
+    if (!normalizedSource) {
+        return {
+            derived: isDerived,
+            nativeAvailable: false,
+            nativePath: '',
+            derivedPath: isDerived ? currentPath : '',
+            nativeImportAvailable: false
+        };
+    }
     const id = typeof sessionId === 'string' ? sessionId.trim() : '';
-    const currentPath = typeof filePath === 'string' && filePath.trim()
-        ? path.resolve(expandHomePath(filePath.trim()))
-        : '';
     const nativePath = resolveNativeSessionFilePath(normalizedSource, id, cwd || '');
     const resolvedNativePath = nativePath ? path.resolve(expandHomePath(nativePath)) : '';
     const inNativePath = !!(currentPath && resolvedNativePath && currentPath === resolvedNativePath);
     const nativeExists = !!(resolvedNativePath && fs.existsSync(resolvedNativePath));
     const currentExists = !!(currentPath && fs.existsSync(currentPath));
-    const isDerived = derived === true || isDerivedSessionFile(currentPath);
     const nativeAvailable = inNativePath || nativeExists || (!isDerived && currentExists);
     const effectiveNativePath = !isDerived && currentPath
         ? currentPath
         : (resolvedNativePath || '');
     return {
         derived: isDerived,
         nativeAvailable,
         nativePath: effectiveNativePath,
         derivedPath: isDerived && currentPath && !inNativePath ? currentPath : '',
-        nativeImportAvailable: isDerived && !!resolvedNativePath && !inNativePath
+        nativeImportAvailable: isDerived && !!resolvedNativePath && !inNativePath && !nativeExists
     };
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cli.js` around lines 6625 - 6647, buildSessionNativeStatus currently treats
any non-derived current session file as "nativeAvailable" and allows
"nativeImportAvailable" even after a native copy exists; restrict those
behaviors to supported derived sources and hide import when a native already
exists. Inside buildSessionNativeStatus, compute a flag for supported sources
(e.g., const supportedDerived = normalizedSource === 'codex' || normalizedSource
=== 'claude'), then change nativeAvailable to: inNativePath || nativeExists ||
(supportedDerived && !isDerived && currentExists); and change
nativeImportAvailable to: isDerived && !!resolvedNativePath && !inNativePath &&
!nativeExists so import is not offered when a native already exists. Ensure you
reference the existing symbols normalizedSource, isDerived, currentExists,
nativeExists, inNativePath, resolvedNativePath, and derived when applying these
checks.

}

function attachSessionNativeStatus(session) {
if (!session || typeof session !== 'object') return session;
return {
...session,
...buildSessionNativeStatus(
session.source,
session.sessionId,
session.cwd,
session.filePath,
session.derived === true
)
};
}

function buildConvertedFromLabel(meta) {
const sourceType = meta && meta.source && typeof meta.source.type === 'string'
? meta.source.type.trim().toLowerCase()
: '';
if (sourceType === 'codex') return 'Codex';
if (sourceType === 'claude') return 'Claude Code';
return '';
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

function resolveStateMaxMessages(state) {
if (!state || typeof state !== 'object') {
return MAX_EXPORT_MESSAGES;
Expand Down Expand Up @@ -6794,7 +6854,18 @@ async function readSessionDetail(params = {}) {
: (hasExactTotalMessages ? extracted.totalMessages > indexedMessages.length : false),
messageLimit,
messages: indexedMessages,
filePath
filePath,
...(typeof buildSessionNativeStatus === 'function'
? buildSessionNativeStatus(source, sessionId, extracted.cwd || '', filePath, (typeof isDerivedSessionFile === 'function' ? isDerivedSessionFile(filePath) : false))
: { derived: (typeof isDerivedSessionFile === 'function' ? isDerivedSessionFile(filePath) : false) }),
convertedFrom: (() => {
if (typeof buildConvertedFromLabel !== 'function' || typeof readDerivedSessionMeta !== 'function') return '';
const label = buildConvertedFromLabel(readDerivedSessionMeta(filePath));
return label ? label.toLowerCase().replace(' code', '') : '';
})(),
convertedFromLabel: (typeof buildConvertedFromLabel === 'function' && typeof readDerivedSessionMeta === 'function')
? buildConvertedFromLabel(readDerivedSessionMeta(filePath))
: ''
};
}

Expand Down Expand Up @@ -7025,18 +7096,35 @@ async function convertSessionToDerived(params = {}) {
}

const baseSessionId = extracted.sessionId || params.sessionId || path.basename(filePath, '.jsonl');
const derivedSessionId = buildDerivedSessionId(baseSessionId);
const sourceKey = buildSessionDerivedSourceKey(source, baseSessionId, filePath);
const outputDir = target === 'codex'
? getCodexSessionsDir()
: (target === 'claude'
? (resolveClaudeProjectDirForCwd(extracted.cwd || '') || path.join(getClaudeProjectsDir(), 'codexmate-derived'))
: buildDerivedSessionOutputDir(target, source, sourceKey));
const outputDirModeRaw = typeof params.outputDir === 'string'
? params.outputDir
: (typeof params.output_dir === 'string' ? params.output_dir : 'native');
const outputDirMode = outputDirModeRaw.trim().toLowerCase() === 'derived' ? 'derived' : 'native';
const cwd = typeof extracted.cwd === 'string' ? extracted.cwd : '';
const outputDir = outputDirMode === 'derived'
? buildDerivedSessionOutputDir(target, source, sourceKey)
: (target === 'codex'
? getCodexSessionsDir()
: (resolveClaudeProjectDirForCwd(cwd || '') || path.join(getClaudeProjectsDir(), 'codexmate-derived')));
ensureDir(outputDir);
const outputPath = path.join(outputDir, `${derivedSessionId}.jsonl`);
const metaPath = path.join(outputDir, `${derivedSessionId}.meta.json`);

const cwd = typeof extracted.cwd === 'string' ? extracted.cwd : '';
let derivedSessionId = '';
let outputPath = '';
let metaPath = '';
for (let attempt = 0; attempt < 8; attempt += 1) {
derivedSessionId = buildDerivedSessionId(baseSessionId);
outputPath = path.join(outputDir, `${derivedSessionId}.jsonl`);
metaPath = path.join(outputDir, `${derivedSessionId}.meta.json`);
if (!fs.existsSync(outputPath) && !fs.existsSync(metaPath)) {
break;
}
derivedSessionId = '';
}
if (!derivedSessionId) {
return { error: 'Converted sessionId conflicts with an existing native session; please retry or choose derived output.' };
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const resolvedCwd = cwd ? path.resolve(expandHomePath(cwd)) : '';
const messages = removeLeadingSystemMessage(Array.isArray(extracted.messages) ? extracted.messages : []);
const now = Date.now();
Expand Down Expand Up @@ -7095,7 +7183,8 @@ async function convertSessionToDerived(params = {}) {
filePath: outputPath
},
options: {
maxMessages: maxMessages === Infinity ? 'all' : maxMessages
maxMessages: maxMessages === Infinity ? 'all' : maxMessages,
outputDir: outputDirMode
}
});

Expand Down Expand Up @@ -7131,7 +7220,12 @@ async function convertSessionToDerived(params = {}) {
target,
truncated: !!extracted.truncated,
maxMessages: maxMessagesLabel,
session: summary ? { ...summary, derived: true } : {
session: attachSessionNativeStatus(summary ? {
...summary,
derived: true,
convertedFrom: source,
convertedFromLabel: source === 'codex' ? 'Codex' : 'Claude Code'
} : {
source: target,
sourceLabel: target === 'codex' ? 'Codex' : 'Claude Code',
sessionId: derivedSessionId,
Expand All @@ -7149,9 +7243,111 @@ async function convertSessionToDerived(params = {}) {
__messageCountExact: true,
filePath: outputPath,
derived: true,
convertedFrom: source,
convertedFromLabel: source === 'codex' ? 'Codex' : 'Claude Code',
keywords: [],
capabilities: {}
})
};
}


async function importDerivedSessionToNative(params = {}) {
const source = normalizeSessionDerivedTarget(params.source);
if (!source) {
return { error: 'Invalid source' };
}
const filePath = resolveSessionFilePath(source, getSessionFileArg(params), params.sessionId);
if (!filePath) {
return { error: 'Session file not found' };
}

const summary = (source === 'claude' ? parseClaudeSessionSummary(filePath) : parseCodexSessionSummary(filePath))
|| buildSessionSummaryFallback(source, filePath, params.sessionId);
const sessionId = summary.sessionId || params.sessionId || path.basename(filePath, '.jsonl');
const nativePath = resolveNativeSessionFilePath(source, sessionId, summary.cwd || '');
if (!nativePath) {
return { error: 'Native session path unavailable' };
}

const resolvedSourcePath = path.resolve(expandHomePath(filePath));
const resolvedNativePath = path.resolve(expandHomePath(nativePath));
const overwrite = params.overwrite === true || params.confirmOverwrite === true || params.force === true;
const nativeStatus = buildSessionNativeStatus(source, sessionId, summary.cwd || '', resolvedSourcePath, true);
if (resolvedSourcePath === resolvedNativePath) {
return {
success: true,
imported: false,
alreadyNative: true,
source,
sessionId,
filePath: resolvedNativePath,
nativePath: resolvedNativePath,
nativeAvailable: true,
session: attachSessionNativeStatus({ ...summary, filePath: resolvedNativePath, derived: true })
};
}
if (fs.existsSync(resolvedNativePath) && !overwrite) {
return {
error: 'Native session already exists',
conflict: true,
source,
sessionId,
filePath: resolvedSourcePath,
nativePath: resolvedNativePath,
nativeAvailable: true,
session: attachSessionNativeStatus({ ...summary, filePath: resolvedSourcePath, derived: true })
};
}

try {
ensureDir(path.dirname(resolvedNativePath));
fs.copyFileSync(resolvedSourcePath, resolvedNativePath);
const sourceMetaPath = getDerivedSessionMetaPath(resolvedSourcePath);
const targetMetaPath = getDerivedSessionMetaPath(resolvedNativePath);
let meta = readDerivedSessionMeta(resolvedSourcePath) || {};
if (!meta || typeof meta !== 'object' || Array.isArray(meta)) meta = {};
meta.version = meta.version || 1;
meta.importedAt = new Date().toISOString();
meta.target = {
...(meta.target && typeof meta.target === 'object' ? meta.target : {}),
type: source,
sessionId,
filePath: resolvedNativePath
};
if (sourceMetaPath && fs.existsSync(sourceMetaPath)) {
writeJsonAtomic(targetMetaPath, meta);
} else {
writeJsonAtomic(targetMetaPath, meta);
}
if (source === 'claude') {
const indexPath = path.join(path.dirname(resolvedNativePath), 'sessions-index.json');
ensureClaudeSessionsIndex(indexPath, summary.cwd || '');
upsertClaudeSessionIndexEntry(indexPath, resolvedNativePath, {
...summary,
source: 'claude',
sessionId,
claudeIndexEntry: { projectPath: summary.cwd || '' }
});
}
} catch (e) {
return { error: `Import to native failed: ${e.message}` };
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}

invalidateSessionListCache();
const importedSummary = (source === 'claude' ? parseClaudeSessionSummary(resolvedNativePath) : parseCodexSessionSummary(resolvedNativePath))
|| { ...summary, filePath: resolvedNativePath };
return {
success: true,
imported: true,
overwritten: fs.existsSync(resolvedNativePath) && overwrite,
source,
sessionId,
filePath: resolvedNativePath,
nativePath: resolvedNativePath,
nativeAvailable: true,
previousFilePath: resolvedSourcePath,
session: attachSessionNativeStatus({ ...importedSummary, filePath: resolvedNativePath, derived: true })
};
}

Expand Down Expand Up @@ -10341,6 +10537,9 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
case 'convert-session':
result = await convertSessionToDerived(params || {});
break;
case 'import-derived-session':
result = await importDerivedSessionToNative(params || {});
break;
case 'delete-session':
result = await deleteSessionData(params || {});
break;
Expand Down
6 changes: 5 additions & 1 deletion cli/session-convert-args.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ function resolveOutputPath(outputPath, defaultFileName) {
}

function parseArgs(args = []) {
const options = { from: '', to: '', sessionId: '', filePath: '', output: '', maxMessages: undefined };
const options = { from: '', to: '', sessionId: '', filePath: '', output: '', outputDir: 'native', maxMessages: undefined };
const errors = [];
for (let i = 0; i < args.length; i += 1) {
const arg = String(args[i] || '');
Expand All @@ -41,6 +41,8 @@ function parseArgs(args = []) {
if (arg.startsWith('--file=')) { options.filePath = arg.slice(7); continue; }
if (arg === '--output') { options.output = next; i += 1; continue; }
if (arg.startsWith('--output=')) { options.output = arg.slice(9); continue; }
if (arg === '--output-dir') { options.outputDir = next; i += 1; continue; }
if (arg.startsWith('--output-dir=')) { options.outputDir = arg.slice(13); continue; }
if (arg === '--max-messages') { options.maxMessages = next; i += 1; continue; }
if (arg.startsWith('--max-messages=')) { options.maxMessages = arg.slice(15); continue; }
errors.push(`未知参数: ${arg}`);
Expand All @@ -50,6 +52,8 @@ function parseArgs(args = []) {
if (options.from !== 'codex' && options.from !== 'claude') errors.push('参数 --from 仅支持 codex 或 claude');
if (options.to !== 'codex' && options.to !== 'claude') errors.push('参数 --to 仅支持 codex 或 claude');
if (options.from && options.to && options.from === options.to) errors.push('--from 与 --to 不能相同');
options.outputDir = String(options.outputDir || 'native').trim().toLowerCase();
if (options.outputDir !== 'native' && options.outputDir !== 'derived') errors.push('参数 --output-dir 仅支持 native 或 derived');
if (!options.from) errors.push('缺少 --from');
if (!options.to) errors.push('缺少 --to');
if (!options.sessionId && !options.filePath) errors.push('必须指定 --session-id 或 --file');
Expand Down
Loading
Loading