diff --git a/bin/cli.js b/bin/cli.js index 91a43d8..32f6e4f 100755 --- a/bin/cli.js +++ b/bin/cli.js @@ -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': { @@ -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`); @@ -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) { @@ -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(''); @@ -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; } @@ -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(); @@ -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 - 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; } @@ -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 @@ -341,7 +377,7 @@ switch (command) { codedash list [limit] List sessions in terminal codedash stats Show session statistics codedash handoff [target] Generate handoff document - codedash convert Convert session (claude/codex) + codedash convert Convert session (claude/codex/qwen) codedash export [file.tar.gz] Export all sessions to archive codedash import Import sessions from archive codedash cloud Cloud session sync (setup/push/pull/list/status) diff --git a/src/convert.js b/src/convert.js index bcf81d4..c71d892 100644 --- a/src/convert.js +++ b/src/convert.js @@ -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 ──────────────────── @@ -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) { @@ -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) { @@ -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}` }; } diff --git a/src/data.js b/src/data.js index 557d875..9aa5a48 100644 --- a/src/data.js +++ b/src/data.js @@ -40,6 +40,7 @@ const IS_WSL = ALL_HOMES.length > 1; const CLAUDE_DIR = path.join(ALL_HOMES[0], '.claude'); const CODEX_DIR = path.join(ALL_HOMES[0], '.codex'); +const QWEN_DIR = path.join(ALL_HOMES[0], '.qwen'); const OPENCODE_DB = path.join(ALL_HOMES[0], '.local', 'share', 'opencode', 'opencode.db'); const KIRO_DB = path.join(ALL_HOMES[0], 'Library', 'Application Support', 'kiro-cli', 'data.sqlite3'); const CURSOR_DIR = path.join(ALL_HOMES[0], '.cursor'); @@ -59,6 +60,7 @@ const PROJECTS_DIR = path.join(CLAUDE_DIR, 'projects'); // On WSL, collect all alternative data dirs const EXTRA_CLAUDE_DIRS = ALL_HOMES.slice(1).map(h => path.join(h, '.claude')).filter(d => fs.existsSync(d)); const EXTRA_CODEX_DIRS = ALL_HOMES.slice(1).map(h => path.join(h, '.codex')).filter(d => fs.existsSync(d)); +const EXTRA_QWEN_DIRS = ALL_HOMES.slice(1).map(h => path.join(h, '.qwen')).filter(d => fs.existsSync(d)); const EXTRA_CURSOR_DIRS = ALL_HOMES.slice(1).map(h => path.join(h, '.cursor')).filter(d => fs.existsSync(d)); // Extra OpenCode/Kiro DBs on Windows side @@ -69,6 +71,7 @@ if (IS_WSL) { console.log(' \x1b[36m[WSL]\x1b[0m Detected Windows homes:', ALL_HOMES.slice(1).join(', ')); if (EXTRA_CLAUDE_DIRS.length) console.log(' \x1b[36m[WSL]\x1b[0m Extra Claude dirs:', EXTRA_CLAUDE_DIRS.join(', ')); if (EXTRA_CODEX_DIRS.length) console.log(' \x1b[36m[WSL]\x1b[0m Extra Codex dirs:', EXTRA_CODEX_DIRS.join(', ')); + if (EXTRA_QWEN_DIRS.length) console.log(' \x1b[36m[WSL]\x1b[0m Extra Qwen dirs:', EXTRA_QWEN_DIRS.join(', ')); if (EXTRA_CURSOR_DIRS.length) console.log(' \x1b[36m[WSL]\x1b[0m Extra Cursor dirs:', EXTRA_CURSOR_DIRS.join(', ')); } @@ -79,6 +82,17 @@ function readLines(filePath) { return fs.readFileSync(filePath, 'utf8').split('\n').map(l => l.replace(/\r$/, '')).filter(Boolean); } +function parseTimestamp(value) { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string') { + const trimmed = value.trim(); + if (!trimmed) return NaN; + if (/^\d+$/.test(trimmed)) return Number(trimmed); + return Date.parse(trimmed); + } + return NaN; +} + // OpenCode built-in tools that should NOT be treated as MCP servers const OPENCODE_BUILTIN_TOOLS = new Set([ 'read', 'write', 'edit', 'bash', 'glob', 'grep', 'task', 'todowrite', @@ -132,6 +146,266 @@ function isRealUserPrompt(entry) { return true; } +function parseMcpToolName(toolName) { + if (!toolName || !toolName.startsWith('mcp__')) return null; + const parts = toolName.split('__'); + if (parts.length < 3) return null; + return { + type: 'mcp', + server: parts[1], + tool: parts.slice(2).join('__'), + }; +} + +function extractQwenText(parts, options) { + options = options || {}; + if (!Array.isArray(parts)) return ''; + + const lines = []; + for (const part of parts) { + if (!part || typeof part !== 'object') continue; + if (part.thought && !options.includeThoughts) continue; + if (typeof part.text === 'string' && part.text.trim()) { + lines.push(part.text.trim()); + continue; + } + if (options.includeToolResults && part.functionResponse) { + const response = part.functionResponse.response; + if (typeof response === 'string' && response.trim()) { + lines.push(response.trim()); + } else if (response && typeof response.output === 'string' && response.output.trim()) { + lines.push(response.output.trim()); + } + } + } + + return lines.join('\n').trim(); +} + +function extractQwenTools(parts) { + if (!Array.isArray(parts)) return []; + + const tools = []; + const seen = new Set(); + + for (const part of parts) { + if (!part || !part.functionCall || !part.functionCall.name) continue; + const tool = parseMcpToolName(part.functionCall.name); + if (!tool) continue; + const key = tool.type + ':' + tool.server + ':' + tool.tool; + if (seen.has(key)) continue; + seen.add(key); + tools.push(tool); + } + + return tools; +} + +function normalizeQwenUsage(rawUsage) { + if (!rawUsage || typeof rawUsage !== 'object') return null; + + const promptTokens = rawUsage.promptTokenCount || rawUsage.inputTokenCount || 0; + const outputTokens = rawUsage.candidatesTokenCount || rawUsage.outputTokenCount || 0; + const cacheReadTokens = rawUsage.cachedContentTokenCount || 0; + + return { + inputTokens: Math.max(0, promptTokens - cacheReadTokens), + outputTokens: outputTokens, + cacheReadTokens: cacheReadTokens, + cacheCreateTokens: 0, + totalTokens: rawUsage.totalTokenCount || (promptTokens + outputTokens), + thoughtsTokens: rawUsage.thoughtsTokenCount || 0, + toolTokens: rawUsage.toolTokenCount || 0, + }; +} + +function listQwenSessionFiles(qwenDir) { + const files = []; + const projectsDir = path.join(qwenDir, 'projects'); + if (!fs.existsSync(projectsDir)) return files; + + const collectSessionFiles = (dir, depth) => { + if (depth < 0 || !fs.existsSync(dir)) return; + let entries = []; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + + for (const entry of entries) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + collectSessionFiles(full, depth - 1); + } else if (entry.name.endsWith('.jsonl')) { + files.push(full); + } + } + }; + + try { + for (const projectKey of fs.readdirSync(projectsDir)) { + const projectDir = path.join(projectsDir, projectKey); + if (!fs.existsSync(projectDir) || !fs.statSync(projectDir).isDirectory()) continue; + + const chatDir = path.join(projectDir, 'chats'); + const sessionDir = path.join(projectDir, 'sessions'); + if (fs.existsSync(chatDir) || fs.existsSync(sessionDir)) { + collectSessionFiles(chatDir, 0); + collectSessionFiles(sessionDir, 0); + } else { + // Newer/older Qwen versions may move session JSONL files around. + collectSessionFiles(projectDir, 2); + } + } + } catch {} + + return files; +} + +function parseQwenSessionFile(sessionFile) { + if (!fs.existsSync(sessionFile)) return null; + + let stat; + let lines; + try { + stat = fs.statSync(sessionFile); + lines = readLines(sessionFile); + } catch { + return null; + } + + let sessionId = path.basename(sessionFile, path.extname(sessionFile)); + let projectPath = ''; + let msgCount = 0; + let userMsgCount = 0; + let firstMsg = ''; + let firstTs = stat.mtimeMs; + let lastTs = stat.mtimeMs; + let model = ''; + const mcpSet = new Set(); + + for (const line of lines) { + try { + const entry = JSON.parse(line); + const ts = parseTimestamp(entry.timestamp || entry.ts); + if (Number.isFinite(ts)) { + if (ts < firstTs) firstTs = ts; + if (ts > lastTs) lastTs = ts; + } + + if (!sessionId && entry.sessionId) sessionId = entry.sessionId; + if (!projectPath && entry.cwd) projectPath = entry.cwd; + if (!model && typeof entry.model === 'string') model = entry.model; + + if (entry.type === 'assistant') { + const tools = extractQwenTools(((entry.message || {}).parts)); + for (const tool of tools) mcpSet.add(tool.server); + } + + if (entry.type !== 'user' && entry.type !== 'assistant') continue; + const text = extractQwenText(((entry.message || {}).parts)); + if (!text || isSystemMessage(text)) continue; + + msgCount++; + if (entry.type === 'user') userMsgCount++; + if (!firstMsg) firstMsg = text.slice(0, 200); + } catch {} + } + + return { + sessionId, + projectPath, + msgCount, + userMsgCount, + firstMsg, + firstTs, + lastTs, + fileSize: stat.size, + model, + mcpServers: Array.from(mcpSet), + }; +} + +function loadQwenDetail(sessionId, filePath, options) { + options = options || {}; + const maxMessages = options.maxMessages || 0; + const messages = []; + + if (!filePath || !fs.existsSync(filePath)) return { messages }; + + let lines; + try { + lines = readLines(filePath); + } catch { + return { messages }; + } + + for (const line of lines) { + if (maxMessages && messages.length >= maxMessages) break; + try { + const entry = JSON.parse(line); + if (entry.type !== 'user' && entry.type !== 'assistant') continue; + + const parts = ((entry.message || {}).parts); + const content = extractQwenText(parts); + if (!content || isSystemMessage(content)) continue; + + const msg = { + role: entry.type, + content: content.slice(0, 2000), + uuid: entry.uuid || '', + timestamp: entry.timestamp || '', + model: entry.type === 'assistant' ? (entry.model || '') : '', + }; + + if (entry.type === 'assistant') { + const tools = extractQwenTools(parts); + const usage = normalizeQwenUsage(entry.usageMetadata); + if (tools.length > 0) msg.tools = tools; + if (usage) msg.tokens = usage; + } + + messages.push(msg); + } catch {} + } + + return { messages }; +} + +function scanQwenSessions(qwenDir) { + const sessions = []; + const files = listQwenSessionFiles(qwenDir); + + for (const filePath of files) { + const summary = parseQwenSessionFile(filePath); + if (!summary || !summary.sessionId) continue; + + const projectPath = summary.projectPath || ''; + sessions.push({ + id: summary.sessionId, + tool: 'qwen', + project: projectPath, + project_short: projectPath ? projectPath.replace(os.homedir(), '~') : '', + first_ts: summary.firstTs, + last_ts: summary.lastTs, + messages: summary.msgCount, + first_message: summary.firstMsg || '', + has_detail: true, + file_size: summary.fileSize, + detail_messages: summary.msgCount, + user_messages: summary.userMsgCount || 0, + model: summary.model || '', + mcp_servers: summary.mcpServers || [], + skills: [], + _session_file: filePath, + _qwen_dir: qwenDir, + }); + } + + return sessions; +} + function parseClaudeSessionFile(sessionFile) { if (!fs.existsSync(sessionFile)) return null; @@ -953,17 +1227,6 @@ function parseCodexSessionFile(sessionFile) { return null; } - const parseTimestamp = (value) => { - if (typeof value === 'number' && Number.isFinite(value)) return value; - if (typeof value === 'string') { - const trimmed = value.trim(); - if (!trimmed) return NaN; - if (/^\d+$/.test(trimmed)) return Number(trimmed); - return Date.parse(trimmed); - } - return NaN; - }; - let projectPath = ''; let msgCount = 0; let userMsgCount = 0; @@ -1447,6 +1710,16 @@ function loadSessions() { } catch {} } + // Load Qwen Code sessions + if (fs.existsSync(QWEN_DIR)) { + try { + const qwenSessions = scanQwenSessions(QWEN_DIR); + for (const qs of qwenSessions) { + sessions[qs.id] = qs; + } + } catch {} + } + // Load OpenCode sessions try { const opencodeSessions = scanOpenCodeSessions(); @@ -1544,6 +1817,15 @@ function loadSessions() { } catch {} } + for (const extraQwenDir of EXTRA_QWEN_DIRS) { + try { + const qwenSessions = scanQwenSessions(extraQwenDir); + for (const qs of qwenSessions) { + sessions[qs.id] = qs; + } + } catch {} + } + // Enrich Claude sessions with detail file info // Build file index once to avoid O(sessions*projects) existsSync scans _buildSessionFileIndex(); @@ -1675,6 +1957,11 @@ function loadSessionDetail(sessionId, project) { return loadCursorDetail(sessionId); } + // Qwen + if (found.format === 'qwen') { + return loadQwenDetail(sessionId, found.file, { maxMessages: 200 }); + } + // Kiro uses SQLite if (found.format === 'kiro') { return loadKiroDetail(sessionId); @@ -1748,6 +2035,25 @@ function loadSessionDetail(sessionId, project) { function deleteSession(sessionId, project) { const deleted = []; + const found = findSessionFile(sessionId, project); + + if (found && found.format === 'qwen' && fs.existsSync(found.file)) { + fs.unlinkSync(found.file); + deleted.push('session file'); + + const chatDir = path.dirname(found.file); + try { + if (fs.existsSync(chatDir) && fs.readdirSync(chatDir).length === 0) { + fs.rmdirSync(chatDir); + } + const projectDir = path.dirname(chatDir); + if (fs.existsSync(projectDir) && fs.readdirSync(projectDir).length === 0) { + fs.rmdirSync(projectDir); + } + } catch {} + + return deleted; + } // 1. Remove session JSONL file from project dir const projectKey = project.replace(/[^a-zA-Z0-9-]/g, '-'); @@ -1819,50 +2125,28 @@ function getGitCommits(projectDir, fromTs, toTs) { function exportSessionMarkdown(sessionId, project) { const found = findSessionFile(sessionId, project); - - // For non-Claude formats, use the detail loader for markdown export - if (found && found.format !== 'claude') { - const detail = - found.format === 'cursor' ? loadCursorDetail(sessionId) : - found.format === 'opencode' ? loadOpenCodeDetail(sessionId) : - found.format === 'kiro' ? loadKiroDetail(sessionId) : - null; - if (detail && detail.messages && detail.messages.length > 0) { - const parts = [`# Session ${sessionId}\n\n**Project:** ${project || '(none)'}\n`]; - for (const msg of detail.messages) { - const header = msg.role === 'user' ? '## User' : '## Assistant'; - parts.push(`\n${header}\n\n${msg.content}\n`); - } - return parts.join(''); - } - } - - if (!found || found.format !== 'claude' || !fs.existsSync(found.file)) { + if (!found) { return `# Session ${sessionId}\n\nSession file not found.\n`; } - const sessionFile = found.file; - const summary = parseClaudeSessionFile(sessionFile); - const lines = readLines(sessionFile); - const projectLabel = project || (summary && summary.projectPath) || '(none)'; - const parts = [`# Session ${sessionId}\n\n**Project:** ${projectLabel}\n`]; + const session = loadSessions().find(s => s.id === sessionId || s.id.startsWith(sessionId)); + const detail = loadSessionDetail(sessionId, project); + const projectLabel = (session && (session.project_short || session.project)) || project || '(none)'; + const toolLabel = session ? session.tool : found.format; + const parts = [ + `# Session ${sessionId}`, + '', + `**Tool:** ${toolLabel}`, + `**Project:** ${projectLabel}`, + '', + ]; - for (const line of lines) { - try { - const entry = JSON.parse(line); - if (entry.type === 'user' || entry.type === 'assistant') { - const msg = entry.message || {}; - let content = msg.content || ''; - if (Array.isArray(content)) { - content = content - .map(b => (typeof b === 'string' ? b : (b.type === 'text' ? b.text : ''))) - .filter(Boolean) - .join('\n'); - } - const header = entry.type === 'user' ? '## User' : '## Assistant'; - parts.push(`\n${header}\n\n${content}\n`); - } - } catch {} + for (const msg of (detail.messages || [])) { + const header = msg.role === 'user' ? '## User' : '## Assistant'; + parts.push(header); + parts.push(''); + parts.push(msg.content || ''); + parts.push(''); } return parts.join(''); @@ -1976,6 +2260,76 @@ function findSessionFile(sessionId, project) { if (codexFile) return { file: codexFile, format: 'codex' }; } + const findQwenInProjects = (projectsDir) => { + if (!fs.existsSync(projectsDir)) return null; + + const walkProjectDir = (dir, depth) => { + if (depth < 0 || !fs.existsSync(dir)) return null; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + const nested = walkProjectDir(full, depth - 1); + if (nested) return nested; + } else if (entry.name === `${sessionId}.jsonl`) { + return full; + } + } + return null; + }; + + if (project) { + const projectKey = project.replace(/[^a-zA-Z0-9-]/g, '-'); + const directCandidates = [ + path.join(projectsDir, projectKey, 'chats', `${sessionId}.jsonl`), + path.join(projectsDir, projectKey, 'sessions', `${sessionId}.jsonl`), + path.join(projectsDir, projectKey, `${sessionId}.jsonl`), + ]; + for (const candidate of directCandidates) { + if (fs.existsSync(candidate)) return candidate; + } + + const walked = walkProjectDir(path.join(projectsDir, projectKey), 2); + if (walked) return walked; + } + + for (const projectKey of fs.readdirSync(projectsDir)) { + const projectDir = path.join(projectsDir, projectKey); + if (!fs.existsSync(projectDir) || !fs.statSync(projectDir).isDirectory()) continue; + + const directCandidates = [ + path.join(projectDir, 'chats', `${sessionId}.jsonl`), + path.join(projectDir, 'sessions', `${sessionId}.jsonl`), + path.join(projectDir, `${sessionId}.jsonl`), + ]; + for (const candidate of directCandidates) { + if (fs.existsSync(candidate)) return candidate; + } + + try { + for (const subdir of ['chats', 'sessions']) { + const dir = path.join(projectDir, subdir); + if (!fs.existsSync(dir)) continue; + for (const file of fs.readdirSync(dir)) { + if (file === `${sessionId}.jsonl`) return path.join(dir, file); + } + } + } catch {} + + const walked = walkProjectDir(projectDir, 2); + if (walked) return walked; + } + + return null; + }; + + const qwenFile = findQwenInProjects(path.join(QWEN_DIR, 'projects')); + if (qwenFile) return { file: qwenFile, format: 'qwen' }; + + for (const extraQwenDir of EXTRA_QWEN_DIRS) { + const extraFile = findQwenInProjects(path.join(extraQwenDir, 'projects')); + if (extraFile) return { file: extraFile, format: 'qwen' }; + } + // Try OpenCode (SQLite — return special marker) if (fs.existsSync(OPENCODE_DB) && sessionId.startsWith('ses_')) { return { file: OPENCODE_DB, format: 'opencode', sessionId: sessionId }; @@ -2088,6 +2442,13 @@ function getSessionPreview(sessionId, project, limit) { }); } + if (found.format === 'qwen') { + const detail = loadQwenDetail(sessionId, found.file, { maxMessages: limit }); + return detail.messages.map(function(m) { + return { role: m.role, content: m.content.slice(0, 300) }; + }); + } + // Kiro: use loadKiroDetail and slice if (found.format === 'kiro') { var detail = loadKiroDetail(sessionId); @@ -2156,12 +2517,26 @@ function buildSearchIndex(sessions) { for (const s of sessions) { if (!s.has_detail) continue; - const found = findSessionFile(s.id, s.project); - if (!found) continue; + const found = findSessionFile(s.id, s.project); + if (!found) continue; - try { - const lines = readLines(found.file); - const texts = []; + try { + if (found.format === 'qwen') { + const detail = loadQwenDetail(s.id, found.file); + const texts = (detail.messages || []).map(function(m) { + return { role: m.role, content: (m.content || '').slice(0, 500) }; + }).filter(function(m) { + return m.content && !isSystemMessage(m.content); + }); + if (texts.length > 0) { + const fullText = texts.map(t => t.content).join(' ').toLowerCase(); + index.push({ sessionId: s.id, texts, fullText }); + } + continue; + } + + const lines = readLines(found.file); + const texts = []; for (const line of lines) { try { @@ -2247,6 +2622,28 @@ function getSessionReplay(sessionId, project) { const found = findSessionFile(sessionId, project); if (!found) return { messages: [], duration: 0 }; + if (found.format === 'qwen') { + const detail = loadQwenDetail(sessionId, found.file); + const messages = (detail.messages || []).map(function(m) { + const ms = m.timestamp ? new Date(m.timestamp).getTime() : 0; + return { + role: m.role, + content: (m.content || '').slice(0, 3000), + timestamp: m.timestamp || '', + ms: ms, + }; + }); + + const startMs = messages.length > 0 ? messages[0].ms : 0; + const endMs = messages.length > 0 ? messages[messages.length - 1].ms : 0; + return { + messages, + startMs, + endMs, + duration: endMs - startMs, + }; + } + const messages = []; const lines = readLines(found.file); @@ -2305,8 +2702,8 @@ const MODEL_PRICING = { 'gpt-5': { input: 1.25 / 1e6, output: 10.00 / 1e6, cache_read: 0.625 / 1e6, cache_create: 1.25 / 1e6 }, }; -function getModelPricing(model) { - if (!model) return MODEL_PRICING['claude-sonnet-4-6']; // default +function findModelPricing(model) { + if (!model) return null; for (const key in MODEL_PRICING) { if (model.includes(key) || model.startsWith(key)) return MODEL_PRICING[key]; } @@ -2315,7 +2712,11 @@ function getModelPricing(model) { if (model.includes('haiku')) return MODEL_PRICING['claude-haiku-4-5']; if (model.includes('sonnet')) return MODEL_PRICING['claude-sonnet-4-6']; if (model.includes('codex')) return MODEL_PRICING['codex-mini-latest']; - return MODEL_PRICING['claude-sonnet-4-6']; + return null; +} + +function getModelPricing(model) { + return findModelPricing(model) || MODEL_PRICING['claude-sonnet-4-6']; } // ── Compute real cost from session file token usage ──────── @@ -2341,13 +2742,23 @@ function _saveCostDiskCache() { } catch {} } -const EMPTY_COST = { cost: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreateTokens: 0, contextPctSum: 0, contextTurnCount: 0, model: '' }; +const EMPTY_COST = { + cost: 0, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheCreateTokens: 0, + contextPctSum: 0, + contextTurnCount: 0, + model: '', + estimated: false, + unavailable: false, +}; // In-memory cost cache (reset when sessions cache resets) const _costMemCache = {}; function computeSessionCost(sessionId, project) { - // Fast in-memory cache (same session never changes within request cycle) if (_costMemCache[sessionId] !== undefined) return _costMemCache[sessionId]; const found = findSessionFile(sessionId, project); @@ -2384,11 +2795,26 @@ function computeSessionCost(sessionId, project) { let contextPctSum = 0; let contextTurnCount = 0; let model = ''; + let estimated = false; + let unavailable = false; // OpenCode: query SQLite directly for token data if (found.format === 'opencode') { const safeId = /^[a-zA-Z0-9_-]+$/.test(found.sessionId) ? found.sessionId : ''; - if (!safeId) return { cost: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreateTokens: 0, contextPctSum: 0, contextTurnCount: 0, model: '' }; + if (!safeId) { + return { + cost: 0, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheCreateTokens: 0, + contextPctSum: 0, + contextTurnCount: 0, + model: '', + estimated: false, + unavailable: false, + }; + } try { const rows = execFileSync('sqlite3', [ OPENCODE_DB, @@ -2425,7 +2851,68 @@ function computeSessionCost(sessionId, project) { } } } catch {} - return { cost: totalCost, inputTokens: totalInput, outputTokens: totalOutput, cacheReadTokens: totalCacheRead, cacheCreateTokens: totalCacheCreate, contextPctSum, contextTurnCount, model }; + return { + cost: totalCost, + inputTokens: totalInput, + outputTokens: totalOutput, + cacheReadTokens: totalCacheRead, + cacheCreateTokens: totalCacheCreate, + contextPctSum, + contextTurnCount, + model, + estimated: false, + unavailable: false, + }; + } + + if (found.format === 'qwen') { + try { + const lines = readLines(found.file); + for (const line of lines) { + try { + const entry = JSON.parse(line); + if (entry.type !== 'assistant') continue; + + if (!model && typeof entry.model === 'string') model = entry.model; + const usage = normalizeQwenUsage(entry.usageMetadata); + if (!usage) continue; + + totalInput += usage.inputTokens; + totalOutput += usage.outputTokens; + totalCacheRead += usage.cacheReadTokens; + totalCacheCreate += usage.cacheCreateTokens; + + const pricing = findModelPricing(entry.model || model || ''); + if (pricing) { + totalCost += usage.inputTokens * pricing.input + + usage.cacheCreateTokens * pricing.cache_create + + usage.cacheReadTokens * pricing.cache_read + + usage.outputTokens * pricing.output; + } else if (usage.totalTokens > 0) { + unavailable = true; + } + + const contextThisTurn = usage.inputTokens + usage.cacheCreateTokens + usage.cacheReadTokens; + if (contextThisTurn > 0) { + contextPctSum += (contextThisTurn / (entry.contextWindowSize || 1000000)) * 100; + contextTurnCount++; + } + } catch {} + } + } catch {} + + return { + cost: totalCost, + inputTokens: totalInput, + outputTokens: totalOutput, + cacheReadTokens: totalCacheRead, + cacheCreateTokens: totalCacheCreate, + contextPctSum, + contextTurnCount, + model, + estimated: false, + unavailable, + }; } try { @@ -2475,10 +2962,22 @@ function computeSessionCost(sessionId, project) { totalInput = Math.round(tokens * 0.3); totalOutput = Math.round(tokens * 0.7); totalCost = totalInput * pricing.input + totalOutput * pricing.output; + estimated = true; } catch {} } - const result = { cost: totalCost, inputTokens: totalInput, outputTokens: totalOutput, cacheReadTokens: totalCacheRead, cacheCreateTokens: totalCacheCreate, contextPctSum, contextTurnCount, model }; + const result = { + cost: totalCost, + inputTokens: totalInput, + outputTokens: totalOutput, + cacheReadTokens: totalCacheRead, + cacheCreateTokens: totalCacheCreate, + contextPctSum, + contextTurnCount, + model, + estimated, + unavailable, + }; if (cacheKey) _costDiskCache[cacheKey] = result; _costMemCache[sessionId] = result; return result; @@ -2547,7 +3046,7 @@ function _computeCostAnalytics(sessions) { let sessionsWithData = 0; const agentNoCostData = {}; for (const s of sessions) { - if (!byAgent[s.tool]) byAgent[s.tool] = { cost: 0, sessions: 0, tokens: 0, estimated: false }; + if (!byAgent[s.tool]) byAgent[s.tool] = { cost: 0, sessions: 0, tokens: 0, estimated: false, unavailable: false }; } const sessionCosts = []; @@ -2634,12 +3133,13 @@ function _computeCostAnalytics(sessions) { // Per-agent breakdown const agent = s.tool || 'unknown'; - if (!byAgent[agent]) byAgent[agent] = { cost: 0, sessions: 0, tokens: 0, estimated: false }; + if (!byAgent[agent]) byAgent[agent] = { cost: 0, sessions: 0, tokens: 0, estimated: false, unavailable: false }; byAgent[agent].cost += cost; byAgent[agent].sessions++; byAgent[agent].tokens += tokens; - if (agent === 'codex') byAgent[agent].estimated = true; + if (agent === 'codex' || costData.estimated) byAgent[agent].estimated = true; if (agent === 'cursor' && costData.model && costData.model.includes('-estimated')) byAgent[agent].estimated = true; + if (costData.unavailable) byAgent[agent].unavailable = true; if (agent === 'opencode' && !costData.model) byAgent[agent].estimated = true; // Context % across all turns @@ -2726,6 +3226,62 @@ function _computeCostAnalytics(sessions) { // ── Active sessions detection ───────────────────────────── +function extractSessionIdFromCommand(cmd, tool) { + if (!cmd || !tool) return ''; + + const patternsByTool = { + qwen: [/(?:^|\s)(?:-r|--resume)\s+([0-9a-f-]{36})(?:\s|$)/i, /(?:^|\s)--session-id\s+([0-9a-f-]{36})(?:\s|$)/i], + codex: [/(?:^|\s)resume\s+([0-9a-f-]{36})(?:\s|$)/i], + claude: [/(?:^|\s)--resume\s+([0-9a-f-]{36})(?:\s|$)/i], + }; + + const patterns = patternsByTool[tool] || []; + for (const pattern of patterns) { + const match = cmd.match(pattern); + if (match) return match[1]; + } + + return ''; +} + +function findQwenSessionByPid(pid, cwd, allSessions) { + const byOpenFile = []; + const byCwd = []; + + try { + const lsofOut = execSync(`lsof -a -p ${pid} -Fn 2>/dev/null`, { + encoding: 'utf8', + timeout: 2000, + stdio: ['pipe', 'pipe', 'pipe'], + }); + for (const line of lsofOut.split('\n')) { + const match = line.match(/(\/.*\.qwen\/projects\/.*\/(?:chats|sessions)\/([0-9a-f-]{36})\.jsonl)$/i); + if (!match) continue; + const sessionId = match[2]; + const session = allSessions.find(s => s.id === sessionId); + if (session) byOpenFile.push(session); + } + } catch {} + + if (cwd) { + for (const session of allSessions) { + if (session.tool === 'qwen' && session.project === cwd) byCwd.push(session); + } + } + + if (byOpenFile.length > 0) { + byOpenFile.sort((a, b) => b.last_ts - a.last_ts); + return { session: byOpenFile[0], source: 'qwen-open-file' }; + } + + if (byCwd.length > 0) { + byCwd.sort((a, b) => b.last_ts - a.last_ts); + return { session: byCwd[0], source: 'cwd-match' }; + } + + return null; +} + function getActiveSessions() { const active = []; const seenPids = new Set(); @@ -2747,6 +3303,7 @@ function getActiveSessions() { const agentPatterns = [ { pattern: 'claude', tool: 'claude', match: /\/claude\s|^claude\s|\bclaude\b/ }, { pattern: 'codex', tool: 'codex', match: /\/codex\s|^codex\s|codex app-server|\bcodex\b/ }, + { pattern: 'qwen', tool: 'qwen', match: /(?:^|[\/\s])qwen(?:\s|$)/ }, { pattern: 'opencode', tool: 'opencode', match: /\/opencode\s|^opencode\s|\bopencode\b/ }, { pattern: 'kiro', tool: 'kiro', match: /kiro-cli/ }, { pattern: 'cursor-agent', tool: 'cursor', match: /cursor-agent/ }, @@ -2757,10 +3314,12 @@ function getActiveSessions() { try { const psOut = execSync( - 'ps aux 2>/dev/null | grep -E "claude|codex|opencode|kiro-cli|cursor-agent" | grep -v grep || true', + 'ps aux 2>/dev/null | grep -E "claude|codex|qwen|opencode|kiro-cli|cursor-agent" | grep -v grep || true', { encoding: 'utf8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'] } ); + const allSessions = loadSessions(); + for (const line of psOut.split('\n').filter(Boolean)) { const parts = line.trim().split(/\s+/); if (parts.length < 11) continue; @@ -2781,7 +3340,7 @@ function getActiveSessions() { if (!tool) continue; // Skip node/npm/shell wrappers, MCP servers, plugins — only main agent processes - if (cmd.includes('node bin/cli') || cmd.includes('npm') || cmd.includes('grep')) continue; + if (cmd.includes('node bin/cli') || /(^|\s)npm(\s|$)/.test(cmd) || /(^|\s)grep(\s|$)/.test(cmd)) continue; if (cmd.includes('mcp-server') || cmd.includes('mcp_server') || cmd.includes('/mcp/') || cmd.includes('/mcp-servers/')) continue; if (cmd.includes('/plugins/') || cmd.includes('plugin-') || cmd.includes('app-server-broker')) continue; if (cmd.includes('.claude/') && !cmd.includes('claude ') && tool === 'claude') continue; @@ -2794,6 +3353,11 @@ function getActiveSessions() { let cwd = ''; let startedAt = 0; let sessionSource = ''; + const explicitSessionId = extractSessionIdFromCommand(cmd, tool); + if (explicitSessionId) { + sessionId = explicitSessionId; + sessionSource = 'cmd-arg'; + } if (claudePidMap[pid]) { sessionId = claudePidMap[pid].sessionId || ''; cwd = claudePidMap[pid].cwd || ''; @@ -2804,7 +3368,7 @@ function getActiveSessions() { // Try to get cwd from lsof if not from PID file if (!cwd) { try { - const lsofOut = execSync(`lsof -d cwd -p ${pid} -Fn 2>/dev/null`, { encoding: 'utf8', timeout: 2000, stdio: ['pipe', 'pipe', 'pipe'] }); + const lsofOut = execSync(`lsof -a -p ${pid} -d cwd -Fn 2>/dev/null`, { encoding: 'utf8', timeout: 2000, stdio: ['pipe', 'pipe', 'pipe'] }); const match = lsofOut.match(/\nn(\/[^\n]+)/); if (match) cwd = match[1]; } catch {} @@ -2812,15 +3376,23 @@ function getActiveSessions() { // Try to find session ID by matching cwd + tool to loaded sessions if (!sessionId) { - const allS = loadSessions(); - const match = allS.find(s => s.tool === tool && s.project === cwd); + let match = null; + if (tool === 'qwen') { + const qwenMatch = findQwenSessionByPid(pid, cwd, allSessions); + if (qwenMatch) { + match = qwenMatch.session; + sessionSource = qwenMatch.source; + } + } else { + match = allSessions.find(s => s.tool === tool && s.project === cwd); + if (match) sessionSource = 'cwd-match'; + } if (match) { sessionId = match.id; - sessionSource = 'cwd-match'; } // If still no match, find latest session of this tool if (!sessionId) { - const latest = allS.filter(s => s.tool === tool).sort((a,b) => b.last_ts - a.last_ts)[0]; + const latest = allSessions.filter(s => s.tool === tool).sort((a,b) => b.last_ts - a.last_ts)[0]; if (latest) { sessionId = latest.id; sessionSource = 'fallback-latest'; @@ -2845,7 +3417,29 @@ function getActiveSessions() { } } catch {} - return active; + // Collapse wrapper/child duplicates into one visible live session. + const deduped = new Map(); + for (const entry of active) { + const key = entry.sessionId ? `${entry.kind}:${entry.sessionId}` : `pid:${entry.pid}`; + const existing = deduped.get(key); + if (!existing) { + deduped.set(key, entry); + continue; + } + + const existingScore = + (existing.sessionId ? 100 : 0) + + (existing.status === 'active' ? 10 : 0) + + Math.min(9, Math.round(existing.cpu)); + const entryScore = + (entry.sessionId ? 100 : 0) + + (entry.status === 'active' ? 10 : 0) + + Math.min(9, Math.round(entry.cpu)); + + if (entryScore > existingScore) deduped.set(key, entry); + } + + return Array.from(deduped.values()); } // ── Leaderboard stats ───────────────────────────────────── @@ -2917,6 +3511,11 @@ function _computeSessionDailyBreakdown(s, found) { const c = entry.message && entry.message.content; if (typeof c === 'string' && c.trim()) hasText = true; else if (Array.isArray(c)) { for (const p of c) { if (p.type === 'text' && p.text && p.text.trim()) { hasText = true; break; } } } + } else if (found.format === 'qwen') { + if (entry.type !== 'user') continue; + isUser = true; + if (entry.timestamp) ts = parseTimestamp(entry.timestamp); + hasText = !!extractQwenText((((entry.message || {}).parts))); } else if (found.format === 'cursor') { if (entry.role !== 'user') continue; isUser = true; @@ -3133,6 +3732,7 @@ module.exports = { loadOpenCodeDetail, CLAUDE_DIR, CODEX_DIR, + QWEN_DIR, OPENCODE_DB, KIRO_DB, HISTORY_FILE, diff --git a/src/frontend/analytics.js b/src/frontend/analytics.js index cabf110..2a7ff56 100644 --- a/src/frontend/analytics.js +++ b/src/frontend/analytics.js @@ -68,6 +68,11 @@ async function renderAnalytics(container) { coverageparts.push('Claude Extension \u2713'); if (byAgent['codex'] && byAgent['codex'].sessions > 0) coverageparts.push('Codex ~est.'); + if (byAgent['qwen'] && byAgent['qwen'].sessions > 0) { + coverageparts.push(byAgent['qwen'].unavailable + ? 'Qwen tokens only' + : 'Qwen Code \u2713'); + } if (byAgent['opencode'] && byAgent['opencode'].sessions > 0) coverageparts.push(byAgent['opencode'].estimated ? 'OpenCode ~est.' @@ -95,7 +100,7 @@ async function renderAnalytics(container) { html += '
' + formatTokens(data.totalCacheReadTokens) + 'Cache read' + pctOf(data.totalCacheReadTokens) + '%
'; html += '
' + formatTokens(data.totalCacheCreateTokens) + 'Cache write' + pctOf(data.totalCacheCreateTokens) + '%
'; if (data.avgContextPct > 0) { - html += '
' + data.avgContextPct + '%Avg context usedof 200K
'; + html += '
' + data.avgContextPct + '%Avg context usedwindow avg
'; } html += ''; html += ''; @@ -222,8 +227,10 @@ async function renderAnalytics(container) { agentEntries.forEach(function(entry) { var name = entry[0]; var info = entry[1]; var pct = maxAgentCost > 0 ? (info.cost / maxAgentCost * 100) : 0; - var label = { 'claude': 'Claude Code', 'claude-ext': 'Claude Ext', 'codex': 'Codex', 'opencode': 'OpenCode', 'cursor': 'Cursor', 'kiro': 'Kiro' }[name] || name; - var estMark = info.estimated ? ' ~est.' : ''; + var label = getToolLabel(name); + var estMark = info.unavailable + ? ' tokens only' + : (info.estimated ? ' ~est.' : ''); html += '
'; html += '' + label + estMark + ''; html += '
'; diff --git a/src/frontend/app.js b/src/frontend/app.js index 9e6ce60..d933e78 100644 --- a/src/frontend/app.js +++ b/src/frontend/app.js @@ -96,6 +96,33 @@ function getSessionDisplayName(session) { return session.session_name || session.first_message || ''; } +var TOOL_META = { + claude: { label: 'Claude Code', shortLabel: 'claude', color: '#60a5fa' }, + 'claude-ext': { label: 'Claude Ext', shortLabel: 'claude ext', color: '#60a5fa' }, + codex: { label: 'Codex', shortLabel: 'codex', color: '#22d3ee' }, + qwen: { label: 'Qwen Code', shortLabel: 'qwen', color: '#fbbf24' }, + cursor: { label: 'Cursor', shortLabel: 'cursor', color: '#4a9eff' }, + opencode: { label: 'OpenCode', shortLabel: 'opencode', color: '#c084fc' }, + kiro: { label: 'Kiro', shortLabel: 'kiro', color: '#fb923c' } +}; + +function getToolLabel(tool, shortLabel) { + var meta = TOOL_META[tool] || { label: tool || 'unknown', shortLabel: tool || 'unknown' }; + return shortLabel ? meta.shortLabel : meta.label; +} + +function getResumeCommand(tool, sessionId, project) { + if (tool === 'codex') return 'codex resume ' + sessionId; + if (tool === 'qwen') return 'qwen -r ' + sessionId; + if (tool === 'cursor') return 'cursor ' + (project ? '"' + project + '"' : '.'); + return 'claude --resume ' + sessionId; +} + +function getConvertTargets(tool) { + if (tool !== 'claude' && tool !== 'codex' && tool !== 'qwen') return []; + return ['claude', 'codex', 'qwen'].filter(function(target) { return target !== tool; }); +} + // ── Utilities ────────────────────────────────────────────────── function timeAgo(dateStr) { @@ -178,6 +205,11 @@ function estimateCost(fileSize) { return tokens * 0.3 * (3.0 / 1e6) + tokens * 0.7 * (15.0 / 1e6); } +function getEstimatedSessionCost(session) { + if (!session || session.tool === 'qwen') return 0; + return estimateCost(session.file_size); +} + // ── Subscription service plans (pricing as of 2025) ───────────── var SERVICE_PLANS = { 'Claude': { label: 'Claude (Anthropic)', plans: [ @@ -706,12 +738,12 @@ function renderCard(s, idx) { var isSelected = selectedIds.has(s.id); var isFocused = focusedIndex === idx; var sessionTags = tags[s.id] || []; - var cost = estimateCost(s.file_size); + var cost = getEstimatedSessionCost(s); var costStr = cost > 0 ? '~$' + cost.toFixed(2) : ''; var projName = getProjectName(s.project); var projColor = getProjectColor(projName); var toolClass = 'tool-' + s.tool; - var toolLabel = s.tool === 'claude-ext' ? 'claude ext' : s.tool; + var toolLabel = getToolLabel(s.tool, true); var classes = 'card'; if (isSelected) classes += ' selected'; @@ -812,7 +844,7 @@ function renderListCard(s, idx) { if (isFocused) classes += ' focused'; var html = '
'; - var listToolLabel = s.tool === 'claude-ext' ? 'claude ext' : s.tool; + var listToolLabel = getToolLabel(s.tool, true); html += '' + escHtml(listToolLabel) + ''; if (showBadges && s.mcp_servers && s.mcp_servers.length > 0) { s.mcp_servers.forEach(function(m) { @@ -1116,9 +1148,9 @@ function renderTimeline(container, sessions) { function renderQACard(s, idx) { var isStarred = stars.indexOf(s.id) >= 0; - var toolLabel = s.tool === 'claude-ext' ? 'claude ext' : s.tool; + var toolLabel = getToolLabel(s.tool, true); var toolClass = 'tool-' + s.tool; - var cost = estimateCost(s.file_size); + var cost = getEstimatedSessionCost(s); var costStr = cost > 0 ? '~$' + cost.toFixed(2) : ''; var classes = 'qa-item' + (selectedIds.has(s.id) ? ' selected' : ''); @@ -1159,7 +1191,7 @@ function renderProjects(container, sessions) { var list = entry[1].slice().sort(function(a, b) { return b.last_ts - a.last_ts; }); var color = getProjectColor(name); var totalMsgs = list.reduce(function(s, e) { return s + (e.messages || 0); }, 0); - var totalCost = list.reduce(function(s, e) { return s + estimateCost(e.file_size); }, 0); + var totalCost = list.reduce(function(s, e) { return s + getEstimatedSessionCost(e); }, 0); var costLabel = totalCost > 0 ? ' · ~$' + totalCost.toFixed(2) : ''; html += '
'; @@ -1459,7 +1491,7 @@ function renderRunningCard(a, s) { html += '
'; html += '' + (a.status === 'waiting' ? 'WAITING' : 'LIVE') + ''; html += '' + escHtml(projName) + ''; - html += '' + escHtml(a.entrypoint || a.kind || 'claude') + ''; + html += '' + escHtml(getToolLabel(a.entrypoint || a.kind || 'claude')) + ''; html += '
'; html += '
'; html += '
' + a.cpu.toFixed(1) + '%CPU
'; @@ -1486,7 +1518,7 @@ function renderDoneCard(s) { html += '
'; html += 'DONE'; html += '' + escHtml(projName) + ''; - html += '' + escHtml(s.tool || 'claude') + ''; + html += '' + escHtml(getToolLabel(s.tool || 'claude', true)) + ''; html += '
'; var displayName = getSessionDisplayName(s); if (displayName) html += '
' + escHtml(displayName.slice(0, 120)) + '
'; @@ -1511,7 +1543,7 @@ function renderRunning(container, sessions) { }).slice(0, 8); if (allActiveIds.length === 0 && done.length === 0) { - container.innerHTML = '
No running sessions detected.
Start a Claude Code or Codex session and it will appear here.
'; + container.innerHTML = '
No running sessions detected.
Start a supported agent session and it will appear here.
'; return; } @@ -1773,6 +1805,12 @@ var AGENT_INSTALL = { alt: 'brew install --cask codex', url: 'https://github.com/openai/codex', }, + qwen: { + name: 'Qwen Code', + cmd: 'npm i -g @qwen-code/qwen-code', + alt: null, + url: 'https://github.com/QwenLM/qwen-code', + }, kiro: { name: 'Kiro CLI', cmd: 'curl -fsSL https://cli.kiro.dev/install | bash', diff --git a/src/frontend/calendar.js b/src/frontend/calendar.js index 2e46d57..fefbdba 100644 --- a/src/frontend/calendar.js +++ b/src/frontend/calendar.js @@ -184,6 +184,9 @@ function setView(view) { } else if (view === 'codex-only') { toolFilter = toolFilter === 'codex' ? null : 'codex'; currentView = 'sessions'; + } else if (view === 'qwen-only') { + toolFilter = toolFilter === 'qwen' ? null : 'qwen'; + currentView = 'sessions'; } else if (view === 'cursor-only') { toolFilter = toolFilter === 'cursor' ? null : 'cursor'; currentView = 'sessions'; diff --git a/src/frontend/detail.js b/src/frontend/detail.js index 6b4fae1..7366858 100644 --- a/src/frontend/detail.js +++ b/src/frontend/detail.js @@ -9,7 +9,7 @@ async function openDetail(s) { title.textContent = escHtml(getProjectName(s.project)) + ' / ' + s.id.slice(0, 12); - var cost = estimateCost(s.file_size); + var cost = getEstimatedSessionCost(s); var costStr = cost > 0 ? '~$' + cost.toFixed(2) : ''; var isStarred = stars.indexOf(s.id) >= 0; var sessionTags = tags[s.id] || []; @@ -26,7 +26,7 @@ async function openDetail(s) { } else if (s.has_detail) { infoHtml += '
Name
'; } - var detailToolLabel = s.tool === 'claude-ext' ? 'claude ext' : s.tool; + var detailToolLabel = getToolLabel(s.tool); infoHtml += '
Tool' + escHtml(detailToolLabel) + '
'; infoHtml += '
Project' + escHtml(s.project_short || s.project || '') + '
'; infoHtml += '
'; @@ -81,8 +81,9 @@ async function openDetail(s) { if (s.has_detail) { infoHtml += ''; infoHtml += ''; - var convertTarget = s.tool === 'codex' ? 'claude' : 'codex'; - infoHtml += ''; + getConvertTargets(s.tool).forEach(function(target) { + infoHtml += ''; + }); infoHtml += ''; } infoHtml += ''; @@ -137,15 +138,20 @@ async function openDetail(s) { // Load real cost loadRealCost(s.id, s.project || '').then(function(costData) { - if (!costData || !costData.cost) return; + if (!costData) return; + var totalTokens = (costData.inputTokens || 0) + (costData.outputTokens || 0) + (costData.cacheReadTokens || 0) + (costData.cacheCreateTokens || 0); + if (!costData.cost && !totalTokens) return; var row = document.getElementById('detail-real-cost'); if (row) { row.style.display = ''; var cacheStr = ''; if ((costData.cacheReadTokens || 0) + (costData.cacheCreateTokens || 0) > 0) cacheStr = ' / ' + formatTokens((costData.cacheReadTokens||0) + (costData.cacheCreateTokens||0)) + ' cache'; + var valueHtml = costData.unavailable + ? 'pricing unavailable' + : '$' + costData.cost.toFixed(2) + ''; row.querySelector('span:last-child').innerHTML = - '$' + costData.cost.toFixed(2) + '' + + valueHtml + ' ' + formatTokens(costData.inputTokens) + ' in / ' + formatTokens(costData.outputTokens) + ' out' + cacheStr + (costData.model ? ' (' + costData.model + ')' : '') + ''; @@ -278,14 +284,7 @@ function launchSession(sessionId, tool, project, flags) { function copyResume(sessionId, tool) { var s = allSessions.find(function(x) { return x.id === sessionId; }); - var cmd; - if (tool === 'codex') { - cmd = 'codex resume ' + sessionId; - } else if (tool === 'cursor') { - cmd = 'cursor ' + (s && s.project ? '"' + s.project + '"' : '.'); - } else { - cmd = 'claude --resume ' + sessionId; - } + var cmd = getResumeCommand(tool, sessionId, s && s.project ? s.project : ''); copyText(cmd, 'Copied: ' + cmd); } diff --git a/src/frontend/heatmap.js b/src/frontend/heatmap.js index 17eaef8..5c3fc07 100644 --- a/src/frontend/heatmap.js +++ b/src/frontend/heatmap.js @@ -179,13 +179,21 @@ function renderHeatmap(container) { // Per-tool breakdown var toolTotals = {}; allSessions.forEach(function(s) { if (s.date >= yearStart) { toolTotals[s.tool] = (toolTotals[s.tool] || 0) + 1; } }); - var toolColors = { claude: '#60a5fa', codex: '#22d3ee', opencode: '#c084fc', kiro: '#fb923c' }; + var toolColors = { + claude: '#60a5fa', + 'claude-ext': '#60a5fa', + codex: '#22d3ee', + qwen: '#fbbf24', + cursor: '#4a9eff', + opencode: '#c084fc', + kiro: '#fb923c' + }; html += '
'; Object.keys(toolTotals).sort(function(a,b) { return toolTotals[b] - toolTotals[a]; }).forEach(function(tool) { var pct = (toolTotals[tool] / Math.max(totalThisYear, 1) * 100).toFixed(0); var color = toolColors[tool] || '#6b7280'; html += '
'; - html += '' + tool + ''; + html += '' + escHtml(getToolLabel(tool)) + ''; html += '
'; html += '' + toolTotals[tool] + ' (' + pct + '%)'; html += '
'; diff --git a/src/frontend/index.html b/src/frontend/index.html index c8b3fd9..d7f4462 100644 --- a/src/frontend/index.html +++ b/src/frontend/index.html @@ -60,6 +60,10 @@ Codex
+ + '; @@ -236,7 +236,7 @@ async function renderLeaderboard(container) { agentEntries.forEach(function(e) { var pct = data.totals.sessions > 0 ? Math.round(e[1] / data.totals.sessions * 100) : 0; html += '
'; - html += '' + escHtml(e[0]) + ''; + html += '' + escHtml(getToolLabel(e[0], true)) + ''; html += '
'; html += '' + e[1] + ' (' + pct + '%)'; html += '
'; diff --git a/src/frontend/styles.css b/src/frontend/styles.css index be798f4..2623873 100644 --- a/src/frontend/styles.css +++ b/src/frontend/styles.css @@ -299,6 +299,7 @@ body { .tool-chip:hover { color: var(--text-primary); } .tool-chip.active-claude { background: rgba(96, 165, 250, 0.2); border-color: var(--accent-blue); color: var(--accent-blue); } .tool-chip.active-codex { background: rgba(34, 211, 238, 0.2); border-color: var(--accent-cyan); color: var(--accent-cyan); } +.tool-chip.active-qwen { background: rgba(251, 191, 36, 0.2); border-color: #fbbf24; color: #fbbf24; } .stats { color: var(--text-muted); font-size: 13px; white-space: nowrap; } @@ -466,6 +467,7 @@ body { } .badge-claude { background: rgba(96, 165, 250, 0.15); color: var(--accent-blue); } .badge-codex { background: rgba(34, 211, 238, 0.15); color: var(--accent-cyan); } +.badge-qwen { background: rgba(251, 191, 36, 0.15); color: #fbbf24; } [data-theme="light"] .badge { background: rgba(0,0,0,0.05); } @@ -1186,6 +1188,11 @@ body { color: var(--accent-cyan); } +.tool-qwen { + background: rgba(251, 191, 36, 0.15); + color: #fbbf24; +} + .tool-opencode { background: rgba(192, 132, 252, 0.15); color: var(--accent-purple); diff --git a/src/handoff.js b/src/handoff.js index a449aa8..cda523a 100644 --- a/src/handoff.js +++ b/src/handoff.js @@ -26,6 +26,9 @@ function generateHandoff(sessionId, project, options) { const detail = loadSessionDetail(session.id, session.project || project); const messages = (detail.messages || []).slice(-msgLimit); const cost = computeSessionCost(session.id, session.project || project); + const costLabel = cost.unavailable + ? `unavailable (${cost.model || 'unknown model'})` + : `$${cost.cost.toFixed(2)} (${cost.model || 'unknown'})`; // Build handoff document const lines = []; @@ -34,7 +37,7 @@ function generateHandoff(sessionId, project, options) { lines.push(`> Transferred from **${session.tool}** session \`${session.id}\``); lines.push(`> Project: \`${session.project_short || session.project || 'unknown'}\``); lines.push(`> Started: ${session.first_time} | Last active: ${session.last_time}`); - lines.push(`> Messages: ${session.detail_messages || session.messages} | Cost: $${cost.cost.toFixed(2)} (${cost.model || 'unknown'})`); + lines.push(`> Messages: ${session.detail_messages || session.messages} | Cost: ${costLabel}`); lines.push(''); // Summary of what was being worked on diff --git a/src/server.js b/src/server.js index 1fd0ff8..74c7210 100644 --- a/src/server.js +++ b/src/server.js @@ -429,7 +429,7 @@ function startServer(host, port, openBrowser = true) { const bindAddr = host === 'localhost' ? DEFAULT_HOST : host; server.listen(port, bindAddr, () => { console.log(''); - console.log(' \x1b[36m\x1b[1mcodedash\x1b[0m — Claude & Codex Sessions Dashboard'); + console.log(' \x1b[36m\x1b[1mcodedash\x1b[0m — AI Coding Agent Sessions Dashboard'); console.log(` \x1b[2mbind ${bindAddr}:${port}\x1b[0m`); console.log(` \x1b[2m${browserUrl}\x1b[0m`); if (host === '0.0.0.0' || host === '::' || host === '[::]') { diff --git a/src/terminals.js b/src/terminals.js index 75286b7..878e59c 100644 --- a/src/terminals.js +++ b/src/terminals.js @@ -93,6 +93,8 @@ function openInTerminal(sessionId, tool, flags, projectDir, terminalId) { if (tool === 'codex') { cmd = `codex resume ${sessionId}`; + } else if (tool === 'qwen') { + cmd = `qwen -r ${sessionId}`; } else { cmd = `claude --resume ${sessionId}`; if (skipPerms) cmd += ' --dangerously-skip-permissions';