Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
64 changes: 50 additions & 14 deletions bin/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,36 @@ const DEFAULT_HOST = 'localhost';
const args = process.argv.slice(2);
const command = args[0] || 'help';

const TOOL_LABELS = {
claude: { label: 'claude', ansi: '\x1b[34mclaude\x1b[0m' },
'claude-ext': { label: 'claude-ext', ansi: '\x1b[34mclaude-ext\x1b[0m' },
codex: { label: 'codex', ansi: '\x1b[36mcodex\x1b[0m' },
qwen: { label: 'qwen', ansi: '\x1b[33mqwen\x1b[0m' },
cursor: { label: 'cursor', ansi: '\x1b[35mcursor\x1b[0m' },
opencode: { label: 'opencode', ansi: '\x1b[95mopencode\x1b[0m' },
kiro: { label: 'kiro', ansi: '\x1b[91mkiro\x1b[0m' },
};

function getToolDisplay(tool) {
return TOOL_LABELS[tool] || { label: tool || 'unknown', ansi: tool || 'unknown' };
}

function getResumeCommand(tool, sessionId) {
if (tool === 'codex') return `codex resume ${sessionId}`;
if (tool === 'qwen') return `qwen -r ${sessionId}`;
if (tool === 'cursor') return 'cursor';
return `claude --resume ${sessionId}`;
}

const STATS_TOOL_ROWS = [
{ label: 'Claude sessions', match: (s) => s.tool === 'claude' || s.tool === 'claude-ext' },
{ label: 'Codex sessions', match: (s) => s.tool === 'codex' },
{ label: 'Qwen sessions', match: (s) => s.tool === 'qwen' },
{ label: 'Cursor sessions', match: (s) => s.tool === 'cursor' },
{ label: 'OpenCode sessions', match: (s) => s.tool === 'opencode' },
{ label: 'Kiro sessions', match: (s) => s.tool === 'kiro' },
];

switch (command) {
case 'run':
case 'start': {
Expand All @@ -38,7 +68,7 @@ switch (command) {
const limit = parseInt(args[1]) || 20;
console.log(`\n \x1b[36m\x1b[1m${sessions.length} sessions\x1b[0m across ${new Set(sessions.map(s => s.project)).size} projects\n`);
for (const s of sessions.slice(0, limit)) {
const tool = s.tool === 'codex' ? '\x1b[36mcodex\x1b[0m' : '\x1b[34mclaude\x1b[0m';
const tool = getToolDisplay(s.tool).ansi.padEnd(18);
const msg = (s.session_name || s.first_message || '').slice(0, 50).padEnd(50);
const proj = s.project_short || '';
console.log(` ${tool} ${s.id.slice(0, 12)} ${s.last_time} ${msg} \x1b[2m${proj}\x1b[0m`);
Expand All @@ -60,8 +90,9 @@ switch (command) {
console.log(`\n \x1b[36m\x1b[1mSession Stats\x1b[0m\n`);
console.log(` Total sessions: ${sessions.length}`);
console.log(` Total projects: ${Object.keys(projects).length}`);
console.log(` Claude sessions: ${sessions.filter(s => s.tool === 'claude').length}`);
console.log(` Codex sessions: ${sessions.filter(s => s.tool === 'codex').length}`);
for (const row of STATS_TOOL_ROWS) {
console.log(` ${row.label.padEnd(18)} ${sessions.filter(row.match).length}`);
}
console.log(`\n \x1b[1mTop projects:\x1b[0m`);
const sorted = Object.entries(projects).sort((a, b) => b[1].count - a[1].count).slice(0, 10);
for (const [name, info] of sorted) {
Expand Down Expand Up @@ -123,8 +154,12 @@ switch (command) {
console.log(` Started: ${session.first_time}`);
console.log(` Last: ${session.last_time}`);
console.log(` Msgs: ${session.messages} inputs, ${session.detail_messages || 0} total`);
if (cost.cost > 0) {
console.log(` Cost: $${cost.cost.toFixed(2)} (${cost.model || 'unknown'})`);
if (cost.cost > 0 || cost.unavailable) {
if (cost.unavailable) {
console.log(` Cost: unavailable (${cost.model || 'unknown'})`);
} else {
console.log(` Cost: $${cost.cost.toFixed(2)} (${cost.model || 'unknown'})`);
}
console.log(` Tokens: ${(cost.inputTokens/1000).toFixed(0)}K in / ${(cost.outputTokens/1000).toFixed(0)}K out`);
}
console.log('');
Expand All @@ -139,7 +174,7 @@ switch (command) {
console.log('');
}

console.log(` Resume: \x1b[2m${session.tool === 'codex' ? 'codex resume' : 'claude --resume'} ${session.id}\x1b[0m`);
console.log(` Resume: \x1b[2m${getResumeCommand(session.tool, session.id)}\x1b[0m`);
console.log('');
break;
}
Expand All @@ -160,26 +195,26 @@ switch (command) {

Generates a context document for continuing a session in another tool.

Targets: claude, codex, opencode, any (default)
Targets: claude, codex, qwen, opencode, any (default)
Options:
--verbosity=minimal|standard|verbose|full
--out=file.md (save to file instead of stdout)

Examples:
codedash handoff 13ae5748 Print handoff doc
codedash handoff 13ae5748 codex For Codex specifically
codedash handoff 13ae5748 qwen For Qwen specifically
codedash handoff 13ae5748 --verbosity=full Include more context
codedash handoff 13ae5748 --out=handoff.md Save to file

Quick handoff (latest session):
codedash handoff claude codex Latest Claude → Codex
codedash handoff qwen codex Latest Qwen → Codex
`);
break;
}

// Check if sid is a tool name (quick handoff)
let result;
if (['claude', 'codex', 'opencode'].includes(sid)) {
if (['claude', 'codex', 'qwen', 'opencode'].includes(sid)) {
result = quickHandoff(sid, target, { verbosity });
} else {
const allH = loadSessions();
Expand Down Expand Up @@ -210,18 +245,19 @@ switch (command) {

case 'convert': {
const sid = args[1];
const target = args[2]; // 'claude' or 'codex'
const target = args[2]; // 'claude' or 'codex' or 'qwen'
if (!sid || !target) {
console.log(`
\x1b[36m\x1b[1mConvert session between agents\x1b[0m

Usage: codedash convert <session-id> <target-format>

Formats: claude, codex
Formats: claude, codex, qwen

Examples:
codedash convert 019d54ed codex Convert Claude session to Codex
codedash convert 13ae5748 claude Convert Codex session to Claude
codedash convert 13ae5748 qwen Convert Claude/Codex session to Qwen
`);
break;
}
Expand Down Expand Up @@ -332,7 +368,7 @@ switch (command) {
case '--help':
default:
console.log(`
\x1b[36m\x1b[1mcodedash\x1b[0m — Claude & Codex Sessions Dashboard
\x1b[36m\x1b[1mcodedash\x1b[0m — AI Coding Agent Sessions Dashboard

\x1b[1mUsage:\x1b[0m
codedash run [port] [--no-browser] Start the dashboard server
Expand All @@ -341,7 +377,7 @@ switch (command) {
codedash list [limit] List sessions in terminal
codedash stats Show session statistics
codedash handoff <id> [target] Generate handoff document
codedash convert <id> <format> Convert session (claude/codex)
codedash convert <id> <format> Convert session (claude/codex/qwen)
codedash export [file.tar.gz] Export all sessions to archive
codedash import <file.tar.gz> Import sessions from archive
codedash cloud <command> Cloud session sync (setup/push/pull/list/status)
Expand Down
86 changes: 86 additions & 0 deletions src/convert.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,19 @@ const { findSessionFile, extractContent, isSystemMessage } = require('./data');

const CLAUDE_DIR = path.join(os.homedir(), '.claude');
const CODEX_DIR = path.join(os.homedir(), '.codex');
const QWEN_DIR = path.join(os.homedir(), '.qwen');

function extractQwenText(parts) {
if (!Array.isArray(parts)) return '';
return parts
.map(part => {
if (!part || typeof part !== 'object' || part.thought) return '';
return typeof part.text === 'string' ? part.text : '';
})
.filter(Boolean)
.join('\n')
.trim();
}

// ── Read session into canonical format ────────────────────

Expand Down Expand Up @@ -48,6 +61,22 @@ function readSession(sessionId, project) {
model: msg.model || '',
});
}
} else if (found.format === 'qwen') {
if (!sessionMeta.cwd && entry.cwd) sessionMeta.cwd = entry.cwd;
if (!sessionMeta.version && entry.version) sessionMeta.version = entry.version;
if (!sessionMeta.gitBranch && entry.gitBranch) sessionMeta.gitBranch = entry.gitBranch;
if (!sessionMeta.originalSessionId && entry.sessionId) sessionMeta.originalSessionId = entry.sessionId;

if (entry.type !== 'user' && entry.type !== 'assistant') continue;
const content = extractQwenText(((entry.message || {}).parts));
if (!content || isSystemMessage(content)) continue;

messages.push({
role: entry.type === 'assistant' ? 'assistant' : 'user',
content: content,
timestamp: entry.timestamp || '',
model: entry.type === 'assistant' ? (entry.model || '') : '',
});
} else {
// Codex
if (entry.type === 'session_meta' && entry.payload) {
Expand Down Expand Up @@ -238,6 +267,61 @@ function writeCodex(canonical, targetProject) {
};
}

function writeQwen(canonical, targetProject) {
const newSessionId = crypto.randomUUID();
const cwd = targetProject || canonical.meta.cwd || os.homedir();
const projectKey = cwd.replace(/[^a-zA-Z0-9-]/g, '-');
const chatsDir = path.join(QWEN_DIR, 'projects', projectKey, 'chats');

if (!fs.existsSync(chatsDir)) {
fs.mkdirSync(chatsDir, { recursive: true });
}

const outFile = path.join(chatsDir, `${newSessionId}.jsonl`);
const nowIso = new Date().toISOString();
const version = canonical.meta.version || '0.14.0';
const gitBranch = canonical.meta.gitBranch || 'main';
const lines = [];
let prevUuid = null;

for (const msg of canonical.messages) {
const uuid = crypto.randomUUID();
const entry = {
uuid,
parentUuid: prevUuid,
sessionId: newSessionId,
timestamp: msg.timestamp || nowIso,
type: msg.role === 'assistant' ? 'assistant' : 'user',
cwd,
version,
gitBranch,
message: {
role: msg.role === 'assistant' ? 'model' : 'user',
parts: [{ text: msg.content }],
},
};

if (msg.role === 'assistant') {
entry.model = msg.model || canonical.meta.model || 'converted-session';
}

lines.push(JSON.stringify(entry));
prevUuid = uuid;
}

const tmpFile = outFile + '.tmp';
fs.writeFileSync(tmpFile, lines.join('\n') + '\n');
fs.renameSync(tmpFile, outFile);

return {
sessionId: newSessionId,
file: outFile,
format: 'qwen',
messages: canonical.messages.length,
resumeCmd: `qwen -r ${newSessionId}`,
};
}

// ── Main convert function ─────────────────────────────────

function convertSession(sessionId, project, targetFormat) {
Expand All @@ -259,6 +343,8 @@ function convertSession(sessionId, project, targetFormat) {
result = writeClaude(canonical, project);
} else if (targetFormat === 'codex') {
result = writeCodex(canonical, project);
} else if (targetFormat === 'qwen') {
result = writeQwen(canonical, project);
} else {
return { ok: false, error: `Unknown target format: ${targetFormat}` };
}
Expand Down
Loading