Skip to content

Commit 72854ba

Browse files
awsldevymkiux
andauthored
feat(session): native derived sessions + claude proxy (#136)
* feat(session): support native derived session import * fix(session): localize native import actions * fix(session): harden native derived conversion * fix(session): localize native import errors * style(session): increase session item height * test(session): update session item height expectations * fix(session): harden derived session detection and dedup * refactor(session): remove derived conversion UI, replace openStandalone with copy link * style(web-ui): refine bridge pool panel ui * feat(cli): add claude proxy launch and configurable autoFlag * chore: bump version to v0.0.31 --------- Co-authored-by: ymkiux <ymkiux@users.noreply.github.com>
1 parent d3614cc commit 72854ba

24 files changed

Lines changed: 833 additions & 201 deletions

‎cli.js‎

Lines changed: 329 additions & 32 deletions
Large diffs are not rendered by default.

‎cli/session-convert-args.js‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ function resolveOutputPath(outputPath, defaultFileName) {
2525
}
2626

2727
function parseArgs(args = []) {
28-
const options = { from: '', to: '', sessionId: '', filePath: '', output: '', maxMessages: undefined };
28+
const options = { from: '', to: '', sessionId: '', filePath: '', output: '', outputDir: 'native', maxMessages: undefined };
2929
const errors = [];
3030
for (let i = 0; i < args.length; i += 1) {
3131
const arg = String(args[i] || '');
@@ -41,6 +41,8 @@ function parseArgs(args = []) {
4141
if (arg.startsWith('--file=')) { options.filePath = arg.slice(7); continue; }
4242
if (arg === '--output') { options.output = next; i += 1; continue; }
4343
if (arg.startsWith('--output=')) { options.output = arg.slice(9); continue; }
44+
if (arg === '--output-dir') { options.outputDir = next; i += 1; continue; }
45+
if (arg.startsWith('--output-dir=')) { options.outputDir = arg.slice(13); continue; }
4446
if (arg === '--max-messages') { options.maxMessages = next; i += 1; continue; }
4547
if (arg.startsWith('--max-messages=')) { options.maxMessages = arg.slice(15); continue; }
4648
errors.push(`未知参数: ${arg}`);
@@ -50,6 +52,8 @@ function parseArgs(args = []) {
5052
if (options.from !== 'codex' && options.from !== 'claude') errors.push('参数 --from 仅支持 codex 或 claude');
5153
if (options.to !== 'codex' && options.to !== 'claude') errors.push('参数 --to 仅支持 codex 或 claude');
5254
if (options.from && options.to && options.from === options.to) errors.push('--from 与 --to 不能相同');
55+
options.outputDir = String(options.outputDir || 'native').trim().toLowerCase();
56+
if (options.outputDir !== 'native' && options.outputDir !== 'derived') errors.push('参数 --output-dir 仅支持 native 或 derived');
5357
if (!options.from) errors.push('缺少 --from');
5458
if (!options.to) errors.push('缺少 --to');
5559
if (!options.sessionId && !options.filePath) errors.push('必须指定 --session-id 或 --file');

‎cli/session-convert.js‎

Lines changed: 111 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,80 @@ const { readSessionMessages, buildTargetRecords } = require('./session-convert-i
66

77
function printUsage() {
88
console.log('\n用法:');
9-
console.log(' codexmate convert-session --from <codex|claude> --to <codex|claude> (--session-id <ID>|--file <PATH>) [--output <PATH>] [--max-messages <N|all|Infinity>]');
9+
console.log(' codexmate convert-session --from <codex|claude> --to <codex|claude> (--session-id <ID>|--file <PATH>) [--output <PATH>] [--output-dir <native|derived>] [--max-messages <N|all|Infinity>]');
10+
}
11+
12+
13+
function resolveExistingDir(candidates, fallback) {
14+
for (const candidate of candidates) {
15+
if (!candidate) continue;
16+
try {
17+
if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) return candidate;
18+
} catch (_) {}
19+
}
20+
return fallback;
21+
}
22+
23+
function resolveCodexSessionsDir() {
24+
const home = process.env.HOME || process.env.USERPROFILE || '';
25+
const candidates = [];
26+
if (process.env.CODEX_HOME) candidates.push(path.join(process.env.CODEX_HOME, 'sessions'));
27+
if (process.env.XDG_CONFIG_HOME) candidates.push(path.join(process.env.XDG_CONFIG_HOME, 'codex', 'sessions'));
28+
if (home) {
29+
candidates.push(path.join(home, '.config', 'codex', 'sessions'));
30+
candidates.push(path.join(home, '.codex', 'sessions'));
31+
}
32+
return resolveExistingDir(candidates, candidates[candidates.length - 1] || path.resolve('.codex/sessions'));
33+
}
34+
35+
function resolveClaudeProjectsDir() {
36+
const home = process.env.HOME || process.env.USERPROFILE || '';
37+
const candidates = [];
38+
const claudeHome = process.env.CLAUDE_HOME || process.env.CLAUDE_CONFIG_DIR || '';
39+
if (claudeHome) candidates.push(path.join(claudeHome, 'projects'));
40+
if (process.env.XDG_CONFIG_HOME) candidates.push(path.join(process.env.XDG_CONFIG_HOME, 'claude', 'projects'));
41+
if (home) {
42+
candidates.push(path.join(home, '.config', 'claude', 'projects'));
43+
candidates.push(path.join(home, '.claude', 'projects'));
44+
}
45+
return resolveExistingDir(candidates, candidates[candidates.length - 1] || path.resolve('.claude/projects'));
46+
}
47+
48+
function sanitizeClaudeProjectName(cwd) {
49+
const value = typeof cwd === 'string' && cwd.trim() ? path.resolve(cwd.trim()) : 'codexmate-derived';
50+
return value.replace(/[^a-zA-Z0-9._-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '') || 'codexmate-derived';
51+
}
52+
53+
function buildSourceKey(from, sessionId, filePath) {
54+
const seed = `${from}|${sessionId || ''}|${filePath || ''}`;
55+
let hash = 0;
56+
for (let i = 0; i < seed.length; i += 1) hash = ((hash << 5) - hash + seed.charCodeAt(i)) | 0;
57+
return String(Math.abs(hash)).padStart(8, '0').slice(0, 8);
58+
}
59+
60+
function resolveDefaultOutputPath(opt, sessionId, cwd) {
61+
const safeSessionId = String(sessionId).replace(/[^a-zA-Z0-9_-]/g, '_') || 'session';
62+
if (opt.output) return resolveOutputPath(opt.output, `${opt.to}-session-${safeSessionId}.jsonl`);
63+
if (opt.outputDir === 'derived') {
64+
const home = process.env.HOME || process.env.USERPROFILE || '';
65+
const root = path.join(home || process.cwd(), '.codexmate', 'sessions', 'derived', opt.to, opt.from, buildSourceKey(opt.from, sessionId, opt.filePath));
66+
return path.join(root, `${safeSessionId}.jsonl`);
67+
}
68+
if (opt.to === 'codex') return path.join(resolveCodexSessionsDir(), `${safeSessionId}.jsonl`);
69+
return path.join(resolveClaudeProjectsDir(), sanitizeClaudeProjectName(cwd), `${safeSessionId}.jsonl`);
70+
}
71+
72+
function getDerivedSessionMetaPath(filePath) {
73+
if (!filePath) return '';
74+
return filePath.toLowerCase().endsWith('.jsonl')
75+
? filePath.slice(0, -6) + '.meta.json'
76+
: `${filePath}.meta.json`;
77+
}
78+
79+
function writeJsonAtomic(filePath, value) {
80+
const tmpPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
81+
fs.writeFileSync(tmpPath, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf-8', flag: 'wx' });
82+
fs.renameSync(tmpPath, filePath);
1083
}
1184

1285
async function cmdConvertSession(args = [], deps = {}) {
@@ -28,12 +101,46 @@ async function cmdConvertSession(args = [], deps = {}) {
28101
}
29102
const extracted = await readSessionMessages(filePath, opt.from, opt.maxMessages);
30103
const sessionId = extracted.sessionId || opt.sessionId || path.basename(filePath, '.jsonl');
31-
const safeSessionId = String(sessionId).replace(/[^a-zA-Z0-9_-]/g, '_');
32104
const records = buildTargetRecords(opt.to, { sessionId, cwd: extracted.cwd || '', messages: extracted.messages });
33105
const jsonl = `${records.map(r => JSON.stringify(r)).join('\n')}\n`;
34-
const outputPath = resolveOutputPath(opt.output, `${opt.to}-session-${safeSessionId}.jsonl`);
106+
const outputPath = resolveDefaultOutputPath(opt, sessionId, extracted.cwd || '');
107+
const metaPath = getDerivedSessionMetaPath(outputPath);
35108
ensureDir(path.dirname(outputPath));
36-
fs.writeFileSync(outputPath, jsonl, 'utf-8');
109+
try {
110+
if (fs.existsSync(metaPath)) {
111+
const error = new Error(`target session metadata already exists: ${metaPath}`);
112+
error.code = 'EEXIST';
113+
throw error;
114+
}
115+
fs.writeFileSync(outputPath, jsonl, { encoding: 'utf-8', flag: 'wx' });
116+
writeJsonAtomic(metaPath, {
117+
version: 1,
118+
createdAt: new Date().toISOString(),
119+
source: {
120+
type: opt.from,
121+
sessionId,
122+
filePath
123+
},
124+
target: {
125+
type: opt.to,
126+
sessionId,
127+
filePath: outputPath
128+
},
129+
options: {
130+
maxMessages: opt.maxMessages,
131+
outputDir: opt.outputDir
132+
}
133+
});
134+
} catch (error) {
135+
if (error && error.code === 'EEXIST') {
136+
console.error('转换失败: target session already exists:', outputPath);
137+
process.exit(1);
138+
}
139+
try {
140+
if (fs.existsSync(outputPath) && !fs.existsSync(metaPath)) fs.unlinkSync(outputPath);
141+
} catch (_) {}
142+
throw error;
143+
}
37144
console.log('\n✓ 会话已转换:', outputPath);
38145
if (extracted.truncated) console.log('! 已截断: 可使用 --max-messages=all');
39146
console.log();

‎package-lock.json‎

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "codexmate",
3-
"version": "0.0.30",
3+
"version": "0.0.31",
44
"description": "Codex/Claude Code/OpenClaw 配置、会话与任务编排 CLI + Web 工具",
55
"main": "cli.js",
66
"bin": {

‎tests/e2e/test-session-convert-derived.js‎

Lines changed: 41 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,12 @@ async function convertAndAssertListed(api, tmpHome, source, target, params = {},
6060
const outPath = res.session.filePath;
6161
assert(fs.existsSync(outPath), `derived ${target} session file missing`);
6262
assert(fs.existsSync(derivedMetaPath(outPath)), `derived ${target} meta missing`);
63-
if (target === 'codex') {
63+
if (params.outputDir === 'derived') {
64+
assert(
65+
outPath.startsWith(path.join(tmpHome, '.codexmate', 'sessions', 'derived', target) + path.sep),
66+
`derived ${target} session path should stay inside ~/.codexmate when outputDir=derived`
67+
);
68+
} else if (target === 'codex') {
6469
assert(isCodexSessionPath(tmpHome, outPath), 'derived codex session path should stay inside ~/.codex or ~/.config/codex');
6570
} else if (target === 'claude') {
6671
assert(isClaudeProjectPath(tmpHome, outPath), 'derived claude session path should stay inside ~/.claude/projects or ~/.config/claude/projects');
@@ -97,32 +102,53 @@ module.exports = async function testSessionConvertDerived(ctx) {
97102
});
98103

99104
const detailClaude = await api('session-detail', { source: 'claude', filePath: derivedClaudePath, maxMessages: 50 });
105+
assert(detailClaude.derived === true, 'native-written converted claude session should stay marked derived');
106+
assert(detailClaude.nativeAvailable === true, 'native-written converted claude session should report nativeAvailable');
107+
assert(detailClaude.nativePath === derivedClaudePath, 'native-written converted claude nativePath should equal filePath');
108+
assert(detailClaude.nativeImportAvailable === false, 'native-written converted claude should not show import action');
100109
assert(Array.isArray(detailClaude.messages), 'session-detail(derived claude) missing messages');
101110
assert(detailClaude.messages.length === 2, 'session-detail(derived claude) should keep exact short length');
102111
assert(detailClaude.messages[0].text === 'hello', 'session-detail(derived claude) user text mismatch');
103112
assert(detailClaude.messages[1].text === 'world', 'session-detail(derived claude) assistant text mismatch');
104113

105-
const { outPath: derivedCodexPath } = await convertAndAssertListed(api, tmpHome, 'claude', 'codex', {
106-
filePath: derivedClaudePath,
107-
maxMessages: 'all'
108-
});
109-
110-
const detailCodex = await api('session-detail', { source: 'codex', filePath: derivedCodexPath, maxMessages: 50 });
111-
assert(Array.isArray(detailCodex.messages), 'session-detail(derived codex) missing messages');
112-
assert(detailCodex.messages.length === 2, 'session-detail(derived codex) should keep exact short length');
113-
assert(detailCodex.messages[0].text === 'hello', 'session-detail(derived codex) user text mismatch');
114-
assert(detailCodex.messages[1].text === 'world', 'session-detail(derived codex) assistant text mismatch');
115-
116-
const { outPath: derivedClaudePath2 } = await convertAndAssertListed(api, tmpHome, 'codex', 'claude', {
114+
const duplicateNative = await api('convert-session', {
115+
source: 'codex',
116+
target: 'claude',
117117
sessionId,
118118
maxMessages: 'all'
119119
});
120-
assert(derivedClaudePath2 && derivedClaudePath2 !== derivedClaudePath, 'second derived session should create a distinct file');
121-
assert(fs.existsSync(derivedClaudePath2), 'second derived claude session file missing');
120+
assert(duplicateNative.error, 'second native conversion should abort on target sessionId conflict');
122121

123122
const afterHash = sha256File(sessionPath);
124123
assert(afterHash === beforeHash, 'source codex session should remain unchanged after conversions');
125124

125+
{
126+
const { res: legacyRes, outPath: legacyDerivedCodexPath } = await convertAndAssertListed(api, tmpHome, 'claude', 'codex', {
127+
filePath: derivedClaudePath,
128+
maxMessages: 'all',
129+
outputDir: 'derived'
130+
}, { assertListed: false });
131+
assert(legacyRes.session.nativeAvailable === false, 'legacy derived codex conversion should report native unavailable');
132+
assert(legacyRes.session.nativeImportAvailable === true, 'legacy derived codex conversion should allow native import');
133+
const detailCodex = await api('session-detail', { source: 'codex', filePath: legacyDerivedCodexPath, maxMessages: 50 });
134+
assert(detailCodex.derived === true, 'legacy derived codex session should stay marked derived');
135+
assert(detailCodex.nativeAvailable === false, 'legacy derived codex detail should report native unavailable');
136+
assert(detailCodex.nativeImportAvailable === true, 'legacy derived codex detail should allow import');
137+
assert(Array.isArray(detailCodex.messages), 'session-detail(derived codex) missing messages');
138+
assert(detailCodex.messages.length === 2, 'session-detail(derived codex) should keep exact short length');
139+
assert(detailCodex.messages[0].text === 'hello', 'session-detail(derived codex) user text mismatch');
140+
assert(detailCodex.messages[1].text === 'world', 'session-detail(derived codex) assistant text mismatch');
141+
const imported = await api('import-derived-session', { source: 'codex', filePath: legacyDerivedCodexPath });
142+
assert(!imported.error, `import-derived-session failed: ${imported.error || ''}`);
143+
assert(imported.nativeAvailable === true, 'import-derived-session should report native available');
144+
assert(imported.filePath && imported.filePath !== legacyDerivedCodexPath, 'import-derived-session should copy to native path');
145+
assert(fs.existsSync(imported.filePath), 'imported native codex session file missing');
146+
const importedDetail = await api('session-detail', { source: 'codex', filePath: imported.filePath, maxMessages: 50 });
147+
assert(importedDetail.nativeAvailable === true, 'imported native codex detail should report native available');
148+
const conflict = await api('import-derived-session', { source: 'codex', filePath: legacyDerivedCodexPath });
149+
assert(conflict.conflict === true, 'second import without overwrite should report conflict');
150+
}
151+
126152
if (daudeSessionPath) {
127153
const beforeDaudeHash = sha256File(daudeSessionPath);
128154
const { outPath: daudeDerivedClaudePath } = await convertAndAssertListed(api, tmpHome, 'codex', 'claude', {

‎tests/unit/compact-layout-ui.test.mjs‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,9 +62,9 @@ test('styles keep desktop layout wide and session history readable on large scre
6262
assert.match(styles, /\.session-layout\s*\{[\s\S]*grid-template-columns:\s*minmax\(260px,\s*360px\)\s*minmax\(0,\s*1fr\);/);
6363
assert.match(styles, /\.session-preview-scroll\s*\{[\s\S]*padding-right:\s*52px;/);
6464
assert.match(styles, /\.session-timeline\s*\{[\s\S]*right:\s*4px;[\s\S]*width:\s*44px;/);
65-
assert.match(styles, /\.session-item\s*\{[\s\S]*min-height:\s*108px;[\s\S]*contain-intrinsic-size:\s*108px;/);
66-
assert.match(styles, /\.session-item-cwd\s*\{[\s\S]*flex:\s*1 0 100%;[\s\S]*white-space:\s*normal;[\s\S]*overflow:\s*visible;[\s\S]*overflow-wrap:\s*anywhere;/);
67-
assert.doesNotMatch(styles, /@media \(max-width: 540px\)\s*\{[\s\S]*\.session-item\s*\{[\s\S]*height:\s*75px;/);
65+
assert.match(styles, /\.session-item\s*\{[\s\S]*min-height:\s*84px;/);
66+
assert.match(styles, /\.session-item\s*\{[\s\S]*contain-intrinsic-size:\s*84px;/);
67+
assert.match(styles, /@media\s*\(max-width:\s*720px\)\s*\{[\s\S]*\.session-item\s*\{[\s\S]*min-height:\s*79px;[\s\S]*height:\s*79px;[\s\S]*contain-intrinsic-size:\s*79px;/);
6868

6969
const html = readBundledWebUiHtml();
7070
assert.match(html, /class="brand-logo"\s+src="\/res\/logo-pack\.webp"/);

‎tests/unit/session-actions-standalone.test.mjs‎

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -34,31 +34,26 @@ test('buildSessionStandaloneUrl returns empty when neither origin nor apiBase is
3434
assert.strictEqual(url, '');
3535
});
3636

37-
test('openSessionStandalone shows an error instead of opening an undefined standalone url', () => {
37+
test('copySessionLink shows an error when url cannot be built', async () => {
3838
const methods = createSessionActionMethods({ apiBase: '' });
3939
const context = {
4040
...methods,
4141
shownMessages: [],
4242
showMessage(message, type) {
4343
this.shownMessages.push({ message, type });
44-
}
44+
},
45+
fallbackCopyText() { return true; }
4546
};
46-
let openedUrl = '';
47-
const fakeWindow = {
47+
48+
await withWindow({
4849
location: {
4950
origin: 'null'
50-
},
51-
open(url) {
52-
openedUrl = url;
5351
}
54-
};
55-
56-
withWindow(fakeWindow, () => methods.openSessionStandalone.call(context, {
52+
}, () => methods.copySessionLink.call(context, {
5753
source: 'codex',
5854
sessionId: 'session-1'
5955
}));
6056

61-
assert.strictEqual(openedUrl, '');
6257
assert.deepStrictEqual(context.shownMessages, [{
6358
message: '无法生成链接',
6459
type: 'error'

‎tests/unit/session-convert.test.mjs‎

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,12 @@ test('convert-session converts codex jsonl to claude jsonl', async () => {
3434
);
3535

3636
const files = listFiles(outDir);
37-
assert.deepStrictEqual(files, ['claude-session-sess-1.jsonl']);
38-
const content = fs.readFileSync(path.join(outDir, files[0]), 'utf-8').trim();
37+
assert.deepStrictEqual(files, ['claude-session-sess-1.jsonl', 'claude-session-sess-1.meta.json']);
38+
const meta = JSON.parse(fs.readFileSync(path.join(outDir, 'claude-session-sess-1.meta.json'), 'utf-8'));
39+
assert.strictEqual(meta.source.type, 'codex');
40+
assert.strictEqual(meta.target.type, 'claude');
41+
assert.strictEqual(meta.target.sessionId, 'sess-1');
42+
const content = fs.readFileSync(path.join(outDir, 'claude-session-sess-1.jsonl'), 'utf-8').trim();
3943
const records = content.split('\n').map((line) => JSON.parse(line));
4044
assert.strictEqual(records.length, 2);
4145
assert.strictEqual(records[0].type, 'user');
@@ -63,8 +67,12 @@ test('convert-session converts claude jsonl to codex jsonl', async () => {
6367
);
6468

6569
const files = listFiles(outDir);
66-
assert.deepStrictEqual(files, ['codex-session-sess-2.jsonl']);
67-
const content = fs.readFileSync(path.join(outDir, files[0]), 'utf-8').trim();
70+
assert.deepStrictEqual(files, ['codex-session-sess-2.jsonl', 'codex-session-sess-2.meta.json']);
71+
const meta = JSON.parse(fs.readFileSync(path.join(outDir, 'codex-session-sess-2.meta.json'), 'utf-8'));
72+
assert.strictEqual(meta.source.type, 'claude');
73+
assert.strictEqual(meta.target.type, 'codex');
74+
assert.strictEqual(meta.target.sessionId, 'sess-2');
75+
const content = fs.readFileSync(path.join(outDir, 'codex-session-sess-2.jsonl'), 'utf-8').trim();
6876
const records = content.split('\n').map((line) => JSON.parse(line));
6977
assert.strictEqual(records[0].type, 'session_meta');
7078
assert.strictEqual(records[0].payload.id, 'sess-2');

‎tests/unit/session-header-actions-layout.test.mjs‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@ test('sessions header actions keep buttons inline (contract)', () => {
1818
'panel-sessions should mark header actions with sessions-header-actions'
1919
);
2020

21+
assert(
22+
!html.includes(':disabled="true"'),
23+
'session panel actions must not contain permanently disabled buttons'
24+
);
25+
2126
const css = readText('web-ui/styles/controls-forms.css');
2227
assert(
2328
/\.selector-header\s*\{[\s\S]*?flex-wrap:\s*nowrap\s*;[\s\S]*?\}/m.test(css),

0 commit comments

Comments
 (0)