From ed8ef101a3c2b860c8a71ffad27f6be16cc7cbdf Mon Sep 17 00:00:00 2001 From: Valerii Kovalskii Date: Mon, 6 Apr 2026 21:15:32 +0300 Subject: [PATCH 01/39] v6.3.3: Merge PR #14 & #19, fix Windows errors, close issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merged PRs: - PR #14 (@tenishevnikita): Fix project key encoding — use /[^a-zA-Z0-9-]/g to match Claude Code's encoding (fixes paths with underscores). Closes #16. - PR #19 (@dimstunt): cmux proper Resume (new-workspace) and Focus (CMUX_WORKSPACE_ID env var) support. Windows fixes (#15): - Skip ps/grep process scanning on win32 (no ps aux on Windows) - Add stdio:pipe to lsof calls to suppress error output - Prevents mojibake error spam in PowerShell Co-Authored-By: Claude Opus 4.6 (1M context) --- package.json | 2 +- src/data.js | 17 +++++++++------- src/terminals.js | 53 ++++++++++++++++++++++++++++++++++++++---------- 3 files changed, 53 insertions(+), 19 deletions(-) diff --git a/package.json b/package.json index e256cc4..11c0723 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codedash-app", - "version": "6.3.2", + "version": "6.3.3", "description": "Dashboard + CLI for Claude Code, Codex & OpenCode sessions. View, search, resume, convert, handoff between agents.", "bin": { "codedash": "./bin/cli.js" diff --git a/src/data.js b/src/data.js index 83b08af..29847b4 100644 --- a/src/data.js +++ b/src/data.js @@ -283,7 +283,7 @@ function decodeCursorProjectFolderKey(proj) { for (var j = 0; j < dirs.length; j++) { var d = dirs[j]; // Cursor encodes both / and . as -, so compare against encoded dir name - var encoded = d.replace(/[\/\.]/g, '-'); + var encoded = d.replace(/[^a-zA-Z0-9-]/g, '-'); if (enc === encoded || (enc.startsWith(encoded) && (enc.length === encoded.length || enc[encoded.length] === '-'))) { matched = d; break; @@ -709,7 +709,7 @@ function loadSessions() { // Enrich Claude sessions with detail file info for (const [sid, s] of Object.entries(sessions)) { if (s.tool !== 'claude') continue; - const projectKey = s.project.replace(/[\/\.]/g, '-'); + const projectKey = s.project.replace(/[^a-zA-Z0-9-]/g, '-'); const sessionFile = path.join(PROJECTS_DIR, projectKey, `${sid}.jsonl`); if (fs.existsSync(sessionFile)) { s.has_detail = true; @@ -866,7 +866,7 @@ function deleteSession(sessionId, project) { const deleted = []; // 1. Remove session JSONL file from project dir - const projectKey = project.replace(/[\/\.]/g, '-'); + const projectKey = project.replace(/[^a-zA-Z0-9-]/g, '-'); const sessionFile = path.join(PROJECTS_DIR, projectKey, `${sessionId}.jsonl`); if (fs.existsSync(sessionFile)) { fs.unlinkSync(sessionFile); @@ -935,7 +935,7 @@ function getGitCommits(projectDir, fromTs, toTs) { } function exportSessionMarkdown(sessionId, project) { - const projectKey = project.replace(/[\/\.]/g, '-'); + const projectKey = project.replace(/[^a-zA-Z0-9-]/g, '-'); const sessionFile = path.join(PROJECTS_DIR, projectKey, `${sessionId}.jsonl`); if (!fs.existsSync(sessionFile)) { @@ -971,7 +971,7 @@ function exportSessionMarkdown(sessionId, project) { function findSessionFile(sessionId, project) { // Try Claude projects dir if (project) { - const projectKey = project.replace(/[\/\.]/g, '-'); + const projectKey = project.replace(/[^a-zA-Z0-9-]/g, '-'); const claudeFile = path.join(PROJECTS_DIR, projectKey, `${sessionId}.jsonl`); if (fs.existsSync(claudeFile)) return { file: claudeFile, format: 'claude' }; } @@ -1468,10 +1468,13 @@ function getActiveSessions() { { pattern: 'cursor-agent', tool: 'cursor', match: /cursor-agent/ }, ]; + // Skip process scanning on Windows (no ps/grep) + if (process.platform === 'win32') return active; + try { const psOut = execSync( 'ps aux 2>/dev/null | grep -E "claude|codex|opencode|kiro-cli|cursor-agent" | grep -v grep || true', - { encoding: 'utf8', timeout: 3000 } + { encoding: 'utf8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'] } ); for (const line of psOut.split('\n').filter(Boolean)) { @@ -1513,7 +1516,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 }); + const lsofOut = execSync(`lsof -d cwd -p ${pid} -Fn 2>/dev/null`, { encoding: 'utf8', timeout: 2000, stdio: ['pipe', 'pipe', 'pipe'] }); const match = lsofOut.match(/\nn(\/[^\n]+)/); if (match) cwd = match[1]; } catch {} diff --git a/src/terminals.js b/src/terminals.js index 9773f13..9020bf3 100644 --- a/src/terminals.js +++ b/src/terminals.js @@ -3,6 +3,13 @@ const fs = require('fs'); const { execSync, exec } = require('child_process'); +// Run cmux CLI command via osascript — needed because codedash runs as a detached server +// and cmux rejects direct socket connections from processes not inside a cmux terminal +function cmuxExec(args) { + const escaped = args.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + return execSync(`osascript -e 'do shell script "cmux ${escaped}"'`, { encoding: 'utf8', timeout: 5000 }).trim(); +} + // ── Detect available terminals ────────────────────────────── function detectTerminals() { @@ -116,10 +123,22 @@ function openInTerminal(sessionId, tool, flags, projectDir, terminalId) { case 'alacritty': exec(`alacritty -e bash -c '${fullCmd}; exec bash'`); break; - case 'cmux': - // cmux — just activate it, user manages sessions inside - execSync(`osascript -e 'tell application "cmux" to activate'`); + case 'cmux': { + // cmux — open new workspace with resume command, then switch to it + try { + const cwdArg = projectDir ? ` --cwd ${JSON.stringify(projectDir)}` : ''; + const cmdArg = ` --command ${JSON.stringify(cmd)}`; + const out = cmuxExec(`new-workspace${cwdArg}${cmdArg}`); + const wsMatch = out.match(/workspace:\d+/); + if (wsMatch) { + cmuxExec(`select-workspace --workspace ${wsMatch[0]}`); + } + execSync(`osascript -e 'tell application "cmux" to activate'`, { stdio: 'pipe', timeout: 2000 }); + } catch { + execSync(`osascript -e 'tell application "cmux" to activate'`); + } break; + } case 'iterm2': default: { const script = ` @@ -174,6 +193,24 @@ function openInTerminal(sessionId, tool, flags, projectDir, terminalId) { } } +// ── Focus cmux workspace by PID → env var ─────────────────── + +function focusCmuxWorkspace(pid) { + if (pid) { + try { + const psEnv = execSync(`ps eww -p ${pid} 2>/dev/null`, { encoding: 'utf8', timeout: 2000 }); + const wsMatch = psEnv.match(/CMUX_WORKSPACE_ID=([0-9A-F-]{36})/i); + if (wsMatch) { + cmuxExec(`select-workspace --workspace ${wsMatch[1]}`); + execSync(`osascript -e 'tell application "cmux" to activate'`, { stdio: 'pipe', timeout: 2000 }); + return { ok: true, terminal: 'cmux' }; + } + } catch {} + } + execSync(`osascript -e 'tell application "cmux" to activate'`, { stdio: 'pipe', timeout: 2000 }); + return { ok: true, terminal: 'cmux' }; +} + // ── Focus existing terminal by PID ────────────────────────── function focusTerminalByPid(pid) { @@ -209,15 +246,9 @@ function focusTerminalByPid(pid) { termLog('FOCUS', `detected terminal from parent chain: ${detectedTerminal || '(none)'}`); - // cmux: activate + flash the surface + // cmux: select workspace by PID's CMUX_WORKSPACE_ID env var if (detectedTerminal === 'cmux') { - try { - execSync(`osascript -e 'tell application "cmux" to activate'`, { stdio: 'pipe', timeout: 2000 }); - try { - execSync(`cmux trigger-flash --surface ${ttyOut.replace('tty','')} 2>/dev/null`, { stdio: 'pipe', timeout: 2000 }); - } catch {} - return { ok: true, terminal: 'cmux' }; - } catch {} + return focusCmuxWorkspace(pid); } // iTerm2: activate and select the right tab/window by tty From 41c2a95dde11781ce486cf5672954638e7b0ffcc Mon Sep 17 00:00:00 2001 From: Valerii Kovalskii Date: Mon, 6 Apr 2026 21:22:36 +0300 Subject: [PATCH 02/39] chore: document branch protection and git workflow (#20) - CLAUDE.md: add Git workflow section (branch naming, PR rules, publish flow) - README.md: add Contributing section - docs/ARCHITECTURE.md: add Contributing section with workflow diagram - main branch is now protected (1 approval required) Co-authored-by: Claude Opus 4.6 (1M context) --- CLAUDE.md | 43 ++++++++++++++++++++++++++++++++++++++----- README.md | 17 +++++++++++++++++ docs/ARCHITECTURE.md | 26 ++++++++++++++++++++++++++ 3 files changed, 81 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c630bd1..fc44a03 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -85,6 +85,38 @@ GET /api/session/:id/export Download session as Markdown - System messages from Codex/Kiro (AGENTS.md, permissions, exit) are filtered via `isSystemMessage()` - Cursor `` wrappers are stripped in `loadCursorDetail()` +## Git workflow + +**`main` branch is protected.** All changes go through feature branches + pull requests. + +```bash +# 1. Create a feature branch +git checkout -b feat/my-feature # or fix/bug-name, chore/cleanup + +# 2. Make changes, commit +git add && git commit -m "feat: description" + +# 3. Push and create PR +git push -u origin feat/my-feature +gh pr create --title "feat: description" --body "..." + +# 4. After review/approval, merge via GitHub +gh pr merge --squash +``` + +**Branch naming:** +- `feat/` — new features +- `fix/` — bug fixes +- `chore/` — refactoring, docs, CI +- `release/` — version bumps + publish + +**Commit messages:** Use conventional format: `feat:`, `fix:`, `chore:`, `docs:`, `perf:`. + +**PR rules:** +- 1 approval required to merge into main +- Keep PRs small and focused — one feature/fix per PR +- Large PRs touching 5+ files should be split + ## Versioning rules **IMPORTANT: Do not bump versions aggressively.** @@ -100,11 +132,12 @@ Before bumping minor/major, ask: "Does this really warrant a version bump, or ca ## Publishing ```bash -# Bump version in package.json, then: -git add -A && git commit && git push && npm publish --access public - -# Also sync to ~/codedash: -cp -r src/* ~/codedash/src/ && cp bin/cli.js ~/codedash/bin/ && cp package.json ~/codedash/ +# From a release branch: +git checkout -b release/6.x.x +# Bump version in package.json, commit, push, create PR +# After merge to main: +git checkout main && git pull +npm publish --access public ``` Package name: `codedash-app`, binary name: `codedash` diff --git a/README.md b/README.md index 91e8ce1..2956888 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,23 @@ curl -fsSL https://opencode.ai/install | bash # OpenCode - At least one AI coding agent installed - macOS / Linux / Windows +## Contributing + +`main` is protected. All changes go through feature branches and pull requests. + +```bash +git checkout -b fix/my-fix +# make changes +git push -u origin fix/my-fix +gh pr create +``` + +- **Branch naming:** `feat/`, `fix/`, `chore/`, `release/` +- **1 approval** required to merge +- Keep PRs small and focused + +See [ARCHITECTURE.md](docs/ARCHITECTURE.md) for codebase details. + ## License MIT diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index af44398..1343794 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -323,3 +323,29 @@ GET /api/version Current + latest npm version GET /api/changelog Changelog entries GET /api/terminals Available terminal apps ``` + +--- + +## Contributing + +### Git Workflow + +`main` is protected. All changes require a pull request with 1 approval. + +``` +main (protected) + ├── feat/session-titles → PR → merge + ├── fix/cursor-path → PR → merge + └── release/6.4.0 → PR → merge + npm publish +``` + +**Branch naming:** `feat/`, `fix/`, `chore/`, `release/` + +**Commit format:** Conventional — `feat:`, `fix:`, `chore:`, `docs:`, `perf:` + +### PR Guidelines + +- One feature or fix per PR +- Keep PRs under 5 files when possible +- Large features should be split into incremental PRs +- Test locally with `node -e "require('./src/server')"` before pushing From 297d56563503ef81bdc1132ba2cf6258b6c05da6 Mon Sep 17 00:00:00 2001 From: Valerii Kovalskii Date: Tue, 7 Apr 2026 09:12:16 +0300 Subject: [PATCH 03/39] =?UTF-8?q?feat:=20=D1=80=D0=B0=D0=B7=D0=B4=D0=B5?= =?UTF-8?q?=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9=20=D1=82=D1=80=D0=B5=D0=BA=D0=B8?= =?UTF-8?q?=D0=BD=D0=B3=20=D0=BA=D1=8D=D1=88-=D1=82=D0=BE=D0=BA=D0=B5?= =?UTF-8?q?=D0=BD=D0=BE=D0=B2=20=D0=B8=20=D0=BA=D0=BE=D0=BD=D1=82=D0=B5?= =?UTF-8?q?=D0=BA=D1=81=D1=82=D0=B0=20=D0=B2=20computeSessionCost=20(#32)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Кэш-токены (cache_read, cache_create) трекаются отдельно от input - Добавлен расчёт утилизации контекстного окна (средний % по всем ходам, база 200K) - getCostAnalytics расширен: totalInputTokens, totalOutputTokens, totalCacheReadTokens, totalCacheCreateTokens, avgContextPct, dailyRate, byAgent, agentNoCostData - totalSessions теперь считает только сессии с реальными данными - Обратно совместимо: новые поля добавлены, старые не изменены Co-authored-by: Pawel Co-authored-by: Claude Opus 4.6 --- src/data.js | 85 ++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 77 insertions(+), 8 deletions(-) diff --git a/src/data.js b/src/data.js index 29847b4..e423118 100644 --- a/src/data.js +++ b/src/data.js @@ -1300,6 +1300,8 @@ function getSessionReplay(sessionId, project) { }; } +const CONTEXT_WINDOW = 200_000; // Claude's max context window (tokens) + // ── Pricing per model (per token, April 2026) ───────────── const MODEL_PRICING = { @@ -1329,11 +1331,15 @@ function getModelPricing(model) { function computeSessionCost(sessionId, project) { const found = findSessionFile(sessionId, project); - if (!found) return { cost: 0, inputTokens: 0, outputTokens: 0, model: '' }; + if (!found) return { cost: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreateTokens: 0, contextPctSum: 0, contextTurnCount: 0, model: '' }; let totalCost = 0; let totalInput = 0; let totalOutput = 0; + let totalCacheRead = 0; + let totalCacheCreate = 0; + let contextPctSum = 0; + let contextTurnCount = 0; let model = ''; try { @@ -1353,12 +1359,21 @@ function computeSessionCost(sessionId, project) { const cacheRead = u.cache_read_input_tokens || 0; const out = u.output_tokens || 0; - totalInput += inp + cacheCreate + cacheRead; + totalInput += inp; totalOutput += out; + totalCacheRead += cacheRead; + totalCacheCreate += cacheCreate; totalCost += inp * pricing.input + cacheCreate * pricing.cache_create + cacheRead * pricing.cache_read + out * pricing.output; + + // Track per-turn context window usage (average, not peak) + const contextThisTurn = inp + cacheCreate + cacheRead; + if (contextThisTurn > 0) { + contextPctSum += (contextThisTurn / CONTEXT_WINDOW) * 100; + contextTurnCount++; + } } // Codex: estimate from file size (no token usage in session files) } catch {} @@ -1377,7 +1392,7 @@ function computeSessionCost(sessionId, project) { } catch {} } - return { cost: totalCost, inputTokens: totalInput, outputTokens: totalOutput, model }; + return { cost: totalCost, inputTokens: totalInput, outputTokens: totalOutput, cacheReadTokens: totalCacheRead, cacheCreateTokens: totalCacheCreate, contextPctSum, contextTurnCount, model }; } // ── Cost analytics ──────────────────────────────────────── @@ -1386,20 +1401,59 @@ function getCostAnalytics(sessions) { const byDay = {}; const byProject = {}; const byWeek = {}; + const byAgent = {}; let totalCost = 0; let totalTokens = 0; + let totalInputTokens = 0; + let totalOutputTokens = 0; + let totalCacheReadTokens = 0; + let totalCacheCreateTokens = 0; + let globalContextPctSum = 0; + let globalContextTurnCount = 0; + let firstDate = null; + let lastDate = null; + 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 }; + } const sessionCosts = []; for (const s of sessions) { const costData = computeSessionCost(s.id, s.project); const cost = costData.cost; - const tokens = costData.inputTokens + costData.outputTokens; - if (cost === 0 && tokens === 0) continue; + const tokens = costData.inputTokens + costData.outputTokens + costData.cacheReadTokens + costData.cacheCreateTokens; + if (cost === 0 && tokens === 0) { + if (!agentNoCostData[s.tool]) agentNoCostData[s.tool] = 0; + agentNoCostData[s.tool]++; + continue; + } + sessionsWithData++; totalCost += cost; totalTokens += tokens; - - // By day + totalInputTokens += costData.inputTokens; + totalOutputTokens += costData.outputTokens; + totalCacheReadTokens += costData.cacheReadTokens; + totalCacheCreateTokens += costData.cacheCreateTokens; + + // Per-agent breakdown + const agent = s.tool || 'unknown'; + if (!byAgent[agent]) byAgent[agent] = { cost: 0, sessions: 0, tokens: 0, estimated: false }; + byAgent[agent].cost += cost; + byAgent[agent].sessions++; + byAgent[agent].tokens += tokens; + if (agent === 'codex') byAgent[agent].estimated = true; + + // Context % across all turns + globalContextPctSum += costData.contextPctSum; + globalContextTurnCount += costData.contextTurnCount; + + // Date range const day = s.date || 'unknown'; + if (s.date) { + if (!firstDate || s.date < firstDate) firstDate = s.date; + if (!lastDate || s.date > lastDate) lastDate = s.date; + } if (!byDay[day]) byDay[day] = { cost: 0, sessions: 0, tokens: 0 }; byDay[day].cost += cost; byDay[day].sessions++; @@ -1429,14 +1483,29 @@ function getCostAnalytics(sessions) { // Sort top sessions by cost sessionCosts.sort((a, b) => b.cost - a.cost); + const days = firstDate && lastDate + ? Math.max(1, Math.round((new Date(lastDate) - new Date(firstDate)) / 86400000) + 1) + : 1; + return { totalCost, totalTokens, - totalSessions: sessions.length, + totalInputTokens, + totalOutputTokens, + totalCacheReadTokens, + totalCacheCreateTokens, + avgContextPct: globalContextTurnCount > 0 ? Math.round(globalContextPctSum / globalContextTurnCount) : 0, + dailyRate: totalCost / days, + firstDate, + lastDate, + days, + totalSessions: sessionsWithData, byDay, byWeek, byProject, topSessions: sessionCosts.slice(0, 10), + byAgent, + agentNoCostData, }; } From 2c0b619ead5a9a611e77d42cfff50ff0e8a6a6d3 Mon Sep 17 00:00:00 2001 From: Valerii Kovalskii Date: Tue, 7 Apr 2026 09:12:56 +0300 Subject: [PATCH 04/39] =?UTF-8?q?feat:=20=D0=BF=D0=BE=D0=B4=D0=B4=D0=B5?= =?UTF-8?q?=D1=80=D0=B6=D0=BA=D0=B0=20=D1=80=D0=B5=D0=B0=D0=BB=D1=8C=D0=BD?= =?UTF-8?q?=D1=8B=D1=85=20=D1=82=D0=BE=D0=BA=D0=B5=D0=BD=D0=BE=D0=B2=20Ope?= =?UTF-8?q?nCode=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20SQLite=20(#33)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - computeSessionCost: новая ветка для format=opencode, читает tokens.input/output, tokens.cache.read/write, tokens.reasoning из SQLite message.data - Валидация sessionId через /^[a-zA-Z0-9_-]+$/ (защита от SQL/shell инъекций) - getCostAnalytics: batch-запрос всех OpenCode сессий одним execSync вместо O(n) - Результат кэшируется в opencodeCostCache и переиспользуется в основном цикле - OpenCode без modelID помечается как estimated в byAgent Co-authored-by: Pawel Co-authored-by: Claude Opus 4.6 --- src/data.js | 88 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 87 insertions(+), 1 deletion(-) diff --git a/src/data.js b/src/data.js index e423118..920028c 100644 --- a/src/data.js +++ b/src/data.js @@ -1342,6 +1342,49 @@ function computeSessionCost(sessionId, project) { let contextTurnCount = 0; let model = ''; + // 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: '' }; + try { + const rows = execSync( + `sqlite3 "${OPENCODE_DB}" "SELECT data FROM message WHERE session_id = '${safeId}' AND json_extract(data, '$.role') = 'assistant' ORDER BY time_created"`, + { encoding: 'utf8', timeout: 10000 } + ).trim(); + if (rows) { + for (const row of rows.split('\n')) { + try { + const msgData = JSON.parse(row); + const t = msgData.tokens || {}; + if (!model && msgData.modelID) model = msgData.modelID; + const inp = t.input || 0; + const out = (t.output || 0) + (t.reasoning || 0); + const cacheRead = (t.cache && t.cache.read) || 0; + const cacheCreate = (t.cache && t.cache.write) || 0; + if (inp === 0 && out === 0) continue; + + const pricing = getModelPricing(msgData.modelID || model); + totalInput += inp; + totalOutput += out; + totalCacheRead += cacheRead; + totalCacheCreate += cacheCreate; + totalCost += inp * pricing.input + + cacheCreate * pricing.cache_create + + cacheRead * pricing.cache_read + + out * pricing.output; + + const contextThisTurn = inp + cacheCreate + cacheRead; + if (contextThisTurn > 0) { + contextPctSum += (contextThisTurn / CONTEXT_WINDOW) * 100; + contextTurnCount++; + } + } catch {} + } + } + } catch {} + return { cost: totalCost, inputTokens: totalInput, outputTokens: totalOutput, cacheReadTokens: totalCacheRead, cacheCreateTokens: totalCacheCreate, contextPctSum, contextTurnCount, model }; + } + try { const lines = readLines(found.file); for (const line of lines) { @@ -1419,8 +1462,50 @@ function getCostAnalytics(sessions) { } const sessionCosts = []; + // Pre-compute OpenCode costs in one batch query (avoids O(n) execSync calls) + const opencodeCostCache = {}; + const opencodeSessions = sessions.filter(s => s.tool === 'opencode'); + if (opencodeSessions.length > 0 && fs.existsSync(OPENCODE_DB)) { + try { + const batchRows = execSync( + `sqlite3 "${OPENCODE_DB}" "SELECT session_id, data FROM message WHERE json_extract(data, '$.role') = 'assistant' ORDER BY time_created"`, + { encoding: 'utf8', timeout: 30000 } + ).trim(); + if (batchRows) { + for (const row of batchRows.split('\n')) { + const sepIdx = row.indexOf('|'); + if (sepIdx < 0) continue; + const sessId = row.slice(0, sepIdx); + const jsonStr = row.slice(sepIdx + 1); + try { + const msgData = JSON.parse(jsonStr); + const t = msgData.tokens || {}; + const inp = t.input || 0; + const out = (t.output || 0) + (t.reasoning || 0); + const cacheRead = (t.cache && t.cache.read) || 0; + const cacheCreate = (t.cache && t.cache.write) || 0; + if (inp === 0 && out === 0) continue; + if (!opencodeCostCache[sessId]) opencodeCostCache[sessId] = { cost: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreateTokens: 0, contextPctSum: 0, contextTurnCount: 0, model: '' }; + const c = opencodeCostCache[sessId]; + if (!c.model && msgData.modelID) c.model = msgData.modelID; + const pricing = getModelPricing(msgData.modelID || c.model); + c.inputTokens += inp; + c.outputTokens += out; + c.cacheReadTokens += cacheRead; + c.cacheCreateTokens += cacheCreate; + c.cost += inp * pricing.input + cacheCreate * pricing.cache_create + cacheRead * pricing.cache_read + out * pricing.output; + const ctx = inp + cacheCreate + cacheRead; + if (ctx > 0) { c.contextPctSum += (ctx / CONTEXT_WINDOW) * 100; c.contextTurnCount++; } + } catch {} + } + } + } catch {} + } + for (const s of sessions) { - const costData = computeSessionCost(s.id, s.project); + const costData = (s.tool === 'opencode' && opencodeCostCache[s.id]) + ? opencodeCostCache[s.id] + : computeSessionCost(s.id, s.project); const cost = costData.cost; const tokens = costData.inputTokens + costData.outputTokens + costData.cacheReadTokens + costData.cacheCreateTokens; if (cost === 0 && tokens === 0) { @@ -1443,6 +1528,7 @@ function getCostAnalytics(sessions) { byAgent[agent].sessions++; byAgent[agent].tokens += tokens; if (agent === 'codex') byAgent[agent].estimated = true; + if (agent === 'opencode' && !costData.model) byAgent[agent].estimated = true; // Context % across all turns globalContextPctSum += costData.contextPctSum; From 4321d810b86ba3a18a7c859ddb5d5808acc03c57 Mon Sep 17 00:00:00 2001 From: Valerii Kovalskii Date: Tue, 7 Apr 2026 09:13:24 +0300 Subject: [PATCH 05/39] =?UTF-8?q?feat:=20UI=20=D1=80=D0=B0=D0=B7=D0=B1?= =?UTF-8?q?=D0=B8=D0=B2=D0=BA=D0=B8=20=D1=82=D0=BE=D0=BA=D0=B5=D0=BD=D0=BE?= =?UTF-8?q?=D0=B2,=20=D0=BF=D0=BE=D0=BA=D1=80=D1=8B=D1=82=D0=B8=D0=B5=20?= =?UTF-8?q?=D0=B0=D0=B3=D0=B5=D0=BD=D1=82=D0=BE=D0=B2,=20=D1=81=D1=82?= =?UTF-8?q?=D0=BE=D0=B8=D0=BC=D0=BE=D1=81=D1=82=D1=8C=20=D0=BF=D0=BE=20?= =?UTF-8?q?=D0=B0=D0=B3=D0=B5=D0=BD=D1=82=D0=B0=D0=BC=20(#34)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Карточки сводки: daily rate, убраны префиксы ~$ - Бейджи покрытия данных: Claude ✓ / Codex ~est. / OpenCode ✓|~est. / Cursor ✗ - Сетка Token Breakdown: input / output / cache read / cache write / avg context % - Диаграмма Cost by Agent (появляется при 2+ агентах с данными) - Кэш-токены в детальном просмотре сессии - CSS: .analytics-coverage, .token-breakdown-grid, .token-type-card Зависит от: feat/cost-cache-tokens-breakdown (расширенный API) Co-authored-by: Pawel Co-authored-by: Claude Opus 4.6 --- src/frontend/app.js | 92 +++++++++++++++++++++++++++++++++++------ src/frontend/styles.css | 43 +++++++++++++++++++ 2 files changed, 123 insertions(+), 12 deletions(-) diff --git a/src/frontend/app.js b/src/frontend/app.js index 3519af5..eee3953 100644 --- a/src/frontend/app.js +++ b/src/frontend/app.js @@ -1277,10 +1277,13 @@ async function openDetail(s) { 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'; row.querySelector('span:last-child').innerHTML = '$' + costData.cost.toFixed(2) + '' + ' ' + - formatTokens(costData.inputTokens) + ' in / ' + formatTokens(costData.outputTokens) + ' out' + + formatTokens(costData.inputTokens) + ' in / ' + formatTokens(costData.outputTokens) + ' out' + cacheStr + (costData.model ? ' (' + costData.model + ')' : '') + ''; } // Update estimated badge to show it was estimated @@ -1826,17 +1829,61 @@ async function renderAnalytics(container) { var html = '
'; html += '

Cost Analytics

'; - // Summary cards + // ── Summary cards ────────────────────────────────────────── html += '
'; - html += '
~$' + data.totalCost.toFixed(2) + 'Total estimated cost
'; + html += '
$' + data.totalCost.toFixed(2) + 'Total cost (API-equivalent)
'; html += '
' + formatTokens(data.totalTokens) + 'Total tokens
'; + html += '
$' + (data.dailyRate || 0).toFixed(2) + 'Avg per day (' + (data.days || 1) + ' days)
'; html += '
' + data.totalSessions + 'Sessions
'; - html += '
~$' + (data.totalCost / Math.max(data.totalSessions, 1)).toFixed(2) + 'Avg per session
'; html += '
'; - // Cost by day chart (bar chart) - var days = Object.keys(data.byDay).sort(); - var last30 = days.slice(-30); + // ── Data coverage note ──────────────────────────────────── + if (data.byAgent || data.agentNoCostData) { + var coverageparts = []; + var byAgent = data.byAgent || {}; + var noCost = data.agentNoCostData || {}; + if (byAgent['claude'] && byAgent['claude'].sessions > 0) + coverageparts.push('Claude Code \u2713'); + if (byAgent['claude-ext'] && byAgent['claude-ext'].sessions > 0) + coverageparts.push('Claude Extension \u2713'); + if (byAgent['codex'] && byAgent['codex'].sessions > 0) + coverageparts.push('Codex ~est.'); + if (byAgent['opencode'] && byAgent['opencode'].sessions > 0) + coverageparts.push(byAgent['opencode'].estimated + ? 'OpenCode ~est.' + : 'OpenCode \u2713'); + ['cursor', 'kiro'].forEach(function(a) { + if (noCost[a] > 0) + coverageparts.push('' + a + ' \u2717 (no token data)'); + }); + if (noCost['opencode'] > 0 && !(byAgent['opencode'] && byAgent['opencode'].sessions > 0)) + coverageparts.push('opencode \u2717 (no token data)'); + if (coverageparts.length > 0) { + html += '
Cost data: ' + coverageparts.join(' \u00b7 ') + '
'; + } + } + + // ── Token breakdown ──────────────────────────────────────── + if (data.totalInputTokens !== undefined) { + var totalTok = data.totalInputTokens + data.totalOutputTokens + data.totalCacheReadTokens + data.totalCacheCreateTokens; + var pctOf = function(n) { return totalTok > 0 ? Math.round(n / totalTok * 100) : 0; }; + html += '
'; + html += '

Token Breakdown

'; + html += '
'; + html += '
' + formatTokens(data.totalInputTokens) + 'Input' + pctOf(data.totalInputTokens) + '%
'; + html += '
' + formatTokens(data.totalOutputTokens) + 'Output' + pctOf(data.totalOutputTokens) + '%
'; + 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 += '
'; + html += '
'; + } + + // ── Daily cost chart ─────────────────────────────────────── + var dayKeys = Object.keys(data.byDay).sort(); + var last30 = dayKeys.slice(-30); if (last30.length > 0) { var maxCost = Math.max.apply(null, last30.map(function(d) { return data.byDay[d].cost; })); html += '

Daily Cost (last 30 days)

'; @@ -1845,7 +1892,7 @@ async function renderAnalytics(container) { var c = data.byDay[d]; var pct = maxCost > 0 ? (c.cost / maxCost * 100) : 0; var label = d.slice(5); // MM-DD - html += '
'; + html += '
'; html += '
'; html += '
' + label + '
'; html += '
'; @@ -1853,7 +1900,7 @@ async function renderAnalytics(container) { html += '
'; } - // Cost by project (horizontal bars) + // ── Cost by project ──────────────────────────────────────── var projects = Object.entries(data.byProject).sort(function(a, b) { return b[1].cost - a[1].cost; }); var topProjects = projects.slice(0, 10); if (topProjects.length > 0) { @@ -1867,19 +1914,19 @@ async function renderAnalytics(container) { html += '
'; html += '' + escHtml(name) + ''; html += '
'; - html += '~$' + info.cost.toFixed(2) + ''; + html += '$' + info.cost.toFixed(2) + ''; html += '
'; }); html += '
'; } - // Top expensive sessions + // ── Top expensive sessions ───────────────────────────────── if (data.topSessions && data.topSessions.length > 0) { html += '

Most Expensive Sessions

'; html += '
'; data.topSessions.forEach(function(s) { html += '
'; - html += '~$' + s.cost.toFixed(2) + ''; + html += '$' + s.cost.toFixed(2) + ''; html += '' + escHtml(s.project) + ''; html += '' + (s.date || '') + ''; html += '' + s.id.slice(0, 8) + ''; @@ -1888,6 +1935,27 @@ async function renderAnalytics(container) { html += '
'; } + // ── Cost by agent ────────────────────────────────────────── + var agentEntries = Object.entries(data.byAgent || {}).filter(function(e) { return e[1].sessions > 0; }); + if (agentEntries.length > 1) { + agentEntries.sort(function(a, b) { return b[1].cost - a[1].cost; }); + html += '

Cost by Agent

'; + html += '
'; + var maxAgentCost = agentEntries[0][1].cost || 1; + 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.' : ''; + html += '
'; + html += '' + label + estMark + ''; + html += '
'; + html += '$' + info.cost.toFixed(2) + ' (' + info.sessions + ' sess.)'; + html += '
'; + }); + html += '
'; + } + html += '
'; container.innerHTML = html; } catch (e) { diff --git a/src/frontend/styles.css b/src/frontend/styles.css index 3758fcb..50a136c 100644 --- a/src/frontend/styles.css +++ b/src/frontend/styles.css @@ -2073,6 +2073,49 @@ body { .top-session-date { color: var(--text-muted); font-size: 12px; } .top-session-id { font-family: monospace; font-size: 11px; color: var(--text-muted); } +/* ── Data coverage indicators ──────────────────────────────── */ + +.analytics-coverage { + font-size: 12px; + color: var(--text-muted); + margin: -8px 0 16px; + display: flex; + gap: 8px; + flex-wrap: wrap; + align-items: center; +} + +.coverage-ok { color: var(--accent-green); } +.coverage-est { color: var(--accent-orange, #f59e0b); } +.coverage-none { color: var(--text-muted); opacity: 0.6; } + +/* ── Token breakdown grid ─────────────────────────────────── */ + +.token-breakdown-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 10px; +} + +.token-type-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 8px; + padding: 12px; + text-align: center; + display: flex; + flex-direction: column; + gap: 4px; +} + +.token-type-val { font-size: 18px; font-weight: 700; color: var(--text); } +.token-type-label { font-size: 11px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; } +.token-type-pct { font-size: 12px; color: var(--text-muted); } + +.token-cache-read { border-color: rgba(96, 165, 250, 0.3); } +.token-cache-create { border-color: rgba(251, 191, 36, 0.3); } +.token-context { border-color: rgba(168, 85, 247, 0.3); } + /* ── Update banner ──────────────────────────────────────────── */ .update-banner { From d6b797ace0595d5d2e5dc4cf89ddaf641a2398d2 Mon Sep 17 00:00:00 2001 From: Valerii Kovalskii Date: Tue, 7 Apr 2026 09:13:56 +0300 Subject: [PATCH 06/39] =?UTF-8?q?feat:=20=D0=BC=D1=83=D0=BB=D1=8C=D1=82?= =?UTF-8?q?=D0=B8=D0=BF=D0=B5=D1=80=D0=B8=D0=BE=D0=B4=D0=BD=D0=BE=D0=B5=20?= =?UTF-8?q?=D1=81=D1=80=D0=B0=D0=B2=D0=BD=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=BF=D0=B8=D1=81=D0=BA=D0=B8=20vs=20API=20(#35)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Хелперы: getSubscriptionConfig() с миграцией старого формата, saveSubscriptionConfig(), subTotalPaid(), addSubEntry(), removeSubEntry() - localStorage ключ: codedash-subscription → {entries: [{plan, paid, from}]} - UI: карточки paid/API-equivalent/savings, прогресс-бар, ROI множитель - Список периодов с кнопкой удаления × и формой добавления - CSS: .subscription-section, .sub-comparison, .sub-card, .sub-bar-*, .sub-entry-*, .sub-add-form Зависит от: feat/token-breakdown-ui Co-authored-by: Pawel Co-authored-by: Claude Opus 4.6 --- src/frontend/app.js | 78 +++++++++++++++++++++++++++++ src/frontend/styles.css | 106 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 184 insertions(+) diff --git a/src/frontend/app.js b/src/frontend/app.js index eee3953..030bead 100644 --- a/src/frontend/app.js +++ b/src/frontend/app.js @@ -100,6 +100,34 @@ function estimateCost(fileSize) { return tokens * 0.3 * (3.0 / 1e6) + tokens * 0.7 * (15.0 / 1e6); } +// ── Subscription config helpers ────────────────────────────────── +function getSubscriptionConfig() { + var raw = JSON.parse(localStorage.getItem('codedash-subscription') || 'null'); + if (!raw) return { entries: [] }; + // Migrate old single-entry format {plan, paid} → new multi-period {entries: [...]} + if (!raw.entries) return { entries: [{ plan: raw.plan || 'Subscription', paid: raw.paid || 0, from: '' }] }; + return raw; +} +function saveSubscriptionConfig(cfg) { localStorage.setItem('codedash-subscription', JSON.stringify(cfg)); } +function subTotalPaid(entries) { return entries.reduce(function(s,e){return s+(parseFloat(e.paid)||0);},0); } +function addSubEntry() { + var plan = (document.getElementById('sub-new-plan').value || '').trim(); + var paid = parseFloat(document.getElementById('sub-new-paid').value) || 0; + var from = (document.getElementById('sub-new-from').value || '').trim(); + if (!paid) return; + var cfg = getSubscriptionConfig(); + cfg.entries.push({ plan: plan || 'Subscription', paid: paid, from: from }); + cfg.entries.sort(function(a,b){return (a.from||'').localeCompare(b.from||'');}); + saveSubscriptionConfig(cfg); + render(); +} +function removeSubEntry(idx) { + var cfg = getSubscriptionConfig(); + cfg.entries.splice(idx, 1); + saveSubscriptionConfig(cfg); + render(); +} + async function loadRealCost(sessionId, project) { try { var resp = await fetch('/api/cost/' + sessionId + '?project=' + encodeURIComponent(project)); @@ -1881,6 +1909,56 @@ async function renderAnalytics(container) { html += ''; } + // ── Subscription vs API ──────────────────────────────────── + var sub = getSubscriptionConfig(); + var subEntries = (sub && sub.entries) || []; + var totalPaid = subTotalPaid(subEntries); + html += '
'; + html += '

Subscription vs API

'; + + if (totalPaid > 0) { + var savings = data.totalCost - totalPaid; + var multiplier = data.totalCost / totalPaid; + var savingsPositive = savings > 0; + var breakdown = subEntries.map(function(e) { + return escHtml(e.plan || 'Sub') + ' $' + parseFloat(e.paid).toFixed(0); + }).join(' + '); + html += '
'; + html += '
$' + totalPaid.toFixed(2) + 'Paid (' + breakdown + ')
'; + html += '
$' + data.totalCost.toFixed(2) + 'Would cost at API rates
'; + html += '
' + (savingsPositive ? '+' : '') + '$' + Math.abs(savings).toFixed(2) + '' + (savingsPositive ? 'Saved (' + multiplier.toFixed(1) + '\u00d7 ROI)' : 'API would be cheaper') + '
'; + html += '
'; + var barPct = Math.min(100, data.totalCost > 0 ? (totalPaid / data.totalCost * 100) : 100); + html += '
'; + html += '
'; + html += '
'; + } else { + html += '

Add your subscription periods below to see how much you\'re saving vs API rates.

'; + } + + // Period list + html += '
'; + if (subEntries.length > 0) { + subEntries.forEach(function(e, i) { + html += '
'; + html += '' + escHtml(e.plan || '\u2014') + ''; + html += '$' + parseFloat(e.paid || 0).toFixed(2) + ''; + html += '' + (e.from ? 'from ' + e.from : 'no date') + ''; + html += ''; + html += '
'; + }); + } + html += '
'; + + // Add form + html += '
'; + html += ''; + html += ''; + html += ''; + html += ''; + html += '
'; + html += '
'; + // ── Daily cost chart ─────────────────────────────────────── var dayKeys = Object.keys(data.byDay).sort(); var last30 = dayKeys.slice(-30); diff --git a/src/frontend/styles.css b/src/frontend/styles.css index 50a136c..f2df9e1 100644 --- a/src/frontend/styles.css +++ b/src/frontend/styles.css @@ -2116,6 +2116,112 @@ body { .token-cache-create { border-color: rgba(251, 191, 36, 0.3); } .token-context { border-color: rgba(168, 85, 247, 0.3); } +/* ── Subscription vs API ──────────────────────────────────── */ + +.subscription-section { margin-top: 8px; } + +.sub-comparison { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 10px; + margin-bottom: 12px; +} + +.sub-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 8px; + padding: 14px; + text-align: center; +} + +.sub-val { font-size: 20px; font-weight: 700; display: block; } +.sub-label { font-size: 11px; color: var(--text-muted); display: block; margin-top: 4px; } + +.sub-paid .sub-val { color: var(--text); } +.sub-api .sub-val { color: var(--accent-blue, #60a5fa); } +.sub-savings .sub-val { color: var(--accent-green); } +.sub-loss .sub-val { color: var(--accent-red, #f87171); } + +.sub-bar-track { + height: 8px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 4px; + margin-bottom: 14px; + overflow: hidden; +} + +.sub-bar-fill { + height: 100%; + background: var(--accent-green); + border-radius: 4px; + transition: width 0.3s; +} + +.sub-hint { + color: var(--text-muted); + font-size: 13px; + margin: 8px 0; +} + +.sub-entries { margin-bottom: 10px; } + +.sub-entry-row { + display: flex; + align-items: center; + gap: 12px; + padding: 6px 0; + border-bottom: 1px solid var(--border); + font-size: 13px; +} + +.sub-entry-plan { font-weight: 600; min-width: 80px; } +.sub-entry-paid { color: var(--accent-green); min-width: 70px; } +.sub-entry-from { color: var(--text-muted); flex: 1; } +.sub-entry-remove { + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + font-size: 16px; + padding: 2px 6px; + border-radius: 4px; +} +.sub-entry-remove:hover { background: rgba(248, 113, 113, 0.15); color: var(--accent-red, #f87171); } + +.sub-add-form { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; +} + +.sub-add-form input { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 6px; + padding: 6px 10px; + color: var(--text); + font-size: 13px; +} + +.sub-add-form input[type="text"] { width: 130px; } +.sub-add-form input[type="number"] { width: 100px; } +.sub-add-form input[type="date"] { width: 140px; } + +.sub-add-form button { + background: var(--accent-blue, #60a5fa); + color: #fff; + border: none; + border-radius: 6px; + padding: 6px 14px; + cursor: pointer; + font-size: 13px; + font-weight: 600; +} +.sub-add-form button:hover { opacity: 0.85; } + /* ── Update banner ──────────────────────────────────────────── */ .update-banner { From c0498962c8c6d76af9c32bc9851fefa57c8210eb Mon Sep 17 00:00:00 2001 From: Valerii Kovalskii Date: Tue, 7 Apr 2026 09:15:08 +0300 Subject: [PATCH 07/39] =?UTF-8?q?release:=20v6.4.0=20=E2=80=94=20token=20a?= =?UTF-8?q?nalytics=20chain=20(PRs=20#24-27)=20(#36)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merged contributions from @NovakPAai: - PR #24: Cache token breakdown in computeSessionCost (cacheRead/cacheCreate tokens) - PR #25: Real OpenCode token extraction via SQLite - PR #26: UI token breakdown, agent coverage, cost by agent in analytics - PR #27: Multi-period subscription vs API comparison with ROI Co-authored-by: Claude Opus 4.6 (1M context) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 11c0723..60fc93e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codedash-app", - "version": "6.3.3", + "version": "6.4.0", "description": "Dashboard + CLI for Claude Code, Codex & OpenCode sessions. View, search, resume, convert, handoff between agents.", "bin": { "codedash": "./bin/cli.js" From 38b85b07cfbfef35d839f86256f4bb3e0534af9d Mon Sep 17 00:00:00 2001 From: Max Irgiznov <11463419+xeonvs@users.noreply.github.com> Date: Tue, 7 Apr 2026 11:08:14 +0200 Subject: [PATCH 08/39] feat: add --host flag and CODEDASH_HOST env to configure server bind address (#42) Allow binding the server to an arbitrary network interface via `--host=ADDR` CLI flag or `CODEDASH_HOST` environment variable. Defaults to localhost (127.0.0.1) to preserve existing behavior. Supported in `run`, `start`, and `restart` commands. Co-authored-by: Claude Opus 4.6 (1M context) --- bin/cli.js | 18 ++++++++++++++++-- src/server.js | 20 ++++++++++++++------ 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/bin/cli.js b/bin/cli.js index 57e4452..eb626d0 100755 --- a/bin/cli.js +++ b/bin/cli.js @@ -7,6 +7,7 @@ const { convertSession } = require('../src/convert'); const { generateHandoff, quickHandoff } = require('../src/handoff'); const DEFAULT_PORT = 3847; +const DEFAULT_HOST = 'localhost'; const args = process.argv.slice(2); const command = args[0] || 'help'; @@ -15,8 +16,10 @@ switch (command) { case 'start': { const portArg = args.find(a => a.startsWith('--port=')); const port = portArg ? parseInt(portArg.split('=')[1]) : (parseInt(args[1]) || DEFAULT_PORT); + const hostArg = args.find(a => a.startsWith('--host=')); + const host = hostArg ? hostArg.split('=')[1] : (process.env.CODEDASH_HOST || DEFAULT_HOST); const noBrowser = args.includes('--no-browser'); - startServer(port, !noBrowser); + startServer(host, port, !noBrowser); break; } @@ -253,6 +256,8 @@ switch (command) { const { execSync } = require('child_process'); const portArg = args.find(a => a.startsWith('--port=')); const port = portArg ? parseInt(portArg.split('=')[1]) : DEFAULT_PORT; + const hostArg = args.find(a => a.startsWith('--host=')); + const host = hostArg ? hostArg.split('=')[1] : (process.env.CODEDASH_HOST || DEFAULT_HOST); console.log(`\n Stopping codedash on port ${port}...`); try { execSync(`lsof -ti:${port} | xargs kill -9 2>/dev/null`, { stdio: 'pipe' }); @@ -263,7 +268,7 @@ switch (command) { setTimeout(() => { console.log(' Starting...\n'); const noBrowser = args.includes('--no-browser'); - startServer(port, !noBrowser); + startServer(host, port, !noBrowser); }, 500); break; } @@ -328,9 +333,18 @@ switch (command) { codedash help Show this help codedash version Show version + \x1b[1mServer options:\x1b[0m + --port=N Listen on port N (default: ${DEFAULT_PORT}) + --host=ADDR Bind to address (default: localhost) + --no-browser Don't open browser on start + + \x1b[1mEnvironment variables:\x1b[0m + CODEDASH_HOST Bind address (same as --host) + \x1b[1mExamples:\x1b[0m codedash run Start on port ${DEFAULT_PORT} codedash run --port=4000 Start on port 4000 + codedash run --host=0.0.0.0 Listen on all interfaces codedash run --no-browser Start without opening browser codedash list 50 Show last 50 sessions codedash ls Alias for list diff --git a/src/server.js b/src/server.js index 7dfcd0c..44978ae 100644 --- a/src/server.js +++ b/src/server.js @@ -25,9 +25,9 @@ function log(tag, msg, data) { console.log(line); } -function startServer(port, openBrowser = true) { +function startServer(host, port, openBrowser = true) { const server = http.createServer((req, res) => { - const parsed = new URL(req.url, `http://localhost:${port}`); + const parsed = new URL(req.url, `http://${host}:${port}`); const pathname = parsed.pathname; const reqStart = Date.now(); @@ -370,18 +370,26 @@ function startServer(port, openBrowser = true) { } }); - server.listen(port, '127.0.0.1', () => { + const bindAddr = host === 'localhost' ? '127.0.0.1' : host; + const displayHost = host === '0.0.0.0' ? 'localhost' : host; + const displayUrl = `http://${displayHost}:${port}`; + + server.listen(port, bindAddr, () => { console.log(''); console.log(' \x1b[36m\x1b[1mcodedash\x1b[0m — Claude & Codex Sessions Dashboard'); - console.log(` \x1b[2mhttp://localhost:${port}\x1b[0m`); + console.log(` \x1b[2m${displayUrl}\x1b[0m`); + if (host === '0.0.0.0') { + console.log(' \x1b[2mListening on all interfaces\x1b[0m'); + } console.log(' \x1b[2mPress Ctrl+C to stop\x1b[0m'); console.log(''); if (openBrowser) { + const browserUrl = `http://localhost:${port}`; if (process.platform === 'darwin') { - exec(`open http://localhost:${port}`); + exec(`open ${browserUrl}`); } else if (process.platform === 'linux') { - exec(`xdg-open http://localhost:${port}`); + exec(`xdg-open ${browserUrl}`); } } }); From 1e6d72ee3cec66effb3e6135ec8f9c55797a686f Mon Sep 17 00:00:00 2001 From: Alexander Kolotov Date: Tue, 7 Apr 2026 03:08:36 -0600 Subject: [PATCH 09/39] feat: use explicit session names for Claude and Codex previews (#31) * Session name for Claude Code sessions * Session name for Codex sessions * Code review comment addressed * Code review comment addressed * Code review comments addressed * removed tests related block --------- Co-authored-by: Alexander Kolotov --- src/data.js | 326 ++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 227 insertions(+), 99 deletions(-) diff --git a/src/data.js b/src/data.js index 920028c..50f3569 100644 --- a/src/data.js +++ b/src/data.js @@ -71,6 +71,139 @@ function readLines(filePath) { return fs.readFileSync(filePath, 'utf8').split('\n').map(l => l.replace(/\r$/, '')).filter(Boolean); } +function parseClaudeSessionFile(sessionFile) { + if (!fs.existsSync(sessionFile)) return null; + + let stat; + let lines; + try { + stat = fs.statSync(sessionFile); + lines = readLines(sessionFile); + } catch { + return null; + } + let projectPath = ''; + let tool = 'claude'; + let msgCount = 0; + let firstMsg = ''; + let customTitle = ''; + let firstTs = stat.mtimeMs; + let lastTs = stat.mtimeMs; + let entrypointFound = false; + + for (const line of lines) { + try { + const entry = JSON.parse(line); + if (entry.type === 'user' || entry.type === 'assistant') msgCount++; + if (entry.timestamp) { + if (entry.timestamp < firstTs) firstTs = entry.timestamp; + if (entry.timestamp > lastTs) lastTs = entry.timestamp; + } + if (!projectPath && entry.type === 'user' && entry.cwd) { + projectPath = entry.cwd; + } + if (!entrypointFound && entry.type === 'user' && entry.entrypoint) { + entrypointFound = true; + if (entry.entrypoint !== 'cli') tool = 'claude-ext'; + } + if (entry.type === 'custom-title' && typeof entry.customTitle === 'string') { + const title = entry.customTitle.trim(); + if (title) customTitle = title.slice(0, 200); + } + if (!firstMsg && entry.type === 'user' && entry.message && entry.message.content) { + const content = extractContent(entry.message.content).trim(); + if (content) firstMsg = content.slice(0, 200); + } + } catch {} + } + + return { + projectPath, + tool, + msgCount, + firstMsg, + customTitle, + firstTs, + lastTs, + fileSize: stat.size, + }; +} + +function mergeClaudeSessionDetail(session, summary, sessionFile) { + if (!session || !summary) return; + + session.tool = summary.tool || session.tool; + session.has_detail = true; + session.file_size = summary.fileSize; + session.detail_messages = summary.msgCount; + session._session_file = sessionFile; + + if (!session.project && summary.projectPath) { + session.project = summary.projectPath; + session.project_short = summary.projectPath.replace(os.homedir(), '~'); + } + + if (summary.customTitle) { + session.first_message = summary.customTitle; + } +} + +function parseCodexSessionIndex(codexDir) { + const titles = {}; + const titleMeta = {}; + const indexFile = path.join(codexDir, 'session_index.jsonl'); + if (!fs.existsSync(indexFile)) return titles; + + const parseUpdatedAt = (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 lines; + try { + lines = readLines(indexFile); + } catch { + return titles; + } + + for (const line of lines) { + try { + const entry = JSON.parse(line); + const sid = entry.id || entry.session_id || entry.sessionId; + if (!sid || typeof entry.thread_name !== 'string') continue; + const title = entry.thread_name.trim(); + if (!title) continue; + + const updatedAt = parseUpdatedAt(entry.updated_at); + const hasUpdatedAt = Number.isFinite(updatedAt); + const existing = titleMeta[sid]; + + if (!existing) { + titles[sid] = title.slice(0, 200); + titleMeta[sid] = { updatedAt, hasUpdatedAt }; + continue; + } + + if ( + (hasUpdatedAt && !existing.hasUpdatedAt) || + (hasUpdatedAt && existing.hasUpdatedAt && updatedAt >= existing.updatedAt) || + (!hasUpdatedAt && !existing.hasUpdatedAt) + ) { + titles[sid] = title.slice(0, 200); + titleMeta[sid] = { updatedAt, hasUpdatedAt }; + } + } catch {} + } + + return titles; +} + function scanOpenCodeSessions() { const sessions = []; if (!fs.existsSync(OPENCODE_DB)) return sessions; @@ -475,6 +608,7 @@ function loadCursorDetail(sessionId) { function scanCodexSessions() { const sessions = []; + const codexTitles = parseCodexSessionIndex(CODEX_DIR); const codexHistory = path.join(CODEX_DIR, 'history.jsonl'); if (fs.existsSync(codexHistory)) { const lines = readLines(codexHistory); @@ -494,7 +628,7 @@ function scanCodexSessions() { first_ts: ts, last_ts: ts, messages: 1, - first_message: d.text || d.display || d.prompt || '', + first_message: codexTitles[sid] || d.text || d.display || d.prompt || '', has_detail: false, file_size: 0, detail_messages: 0, @@ -540,6 +674,9 @@ function scanCodexSessions() { if (existing) { existing.has_detail = true; existing.file_size = stat.size; + if (codexTitles[sid]) { + existing.first_message = codexTitles[sid]; + } if (cwd && !existing.project) { existing.project = cwd; existing.project_short = cwd.replace(os.homedir(), '~'); @@ -553,7 +690,7 @@ function scanCodexSessions() { first_ts: stat.mtimeMs, last_ts: stat.mtimeMs, messages: 0, - first_message: '', + first_message: codexTitles[sid] || '', has_detail: true, file_size: stat.size, detail_messages: 0, @@ -598,6 +735,7 @@ function loadSessions() { last_ts: d.timestamp, messages: 0, first_message: '', + _claude_dir: CLAUDE_DIR, }; } @@ -654,17 +792,22 @@ function loadSessions() { if (fs.existsSync(extraHistory)) { const lines = readLines(extraHistory); for (const line of lines) { + let d; try { - const d = JSON.parse(line); + d = JSON.parse(line); const sid = d.sessionId; - if (!sid || sessions[sid]) continue; - sessions[sid] = { - id: sid, tool: 'claude', - project: d.project || '', project_short: (d.project || '').replace(os.homedir(), '~'), - first_ts: d.timestamp, last_ts: d.timestamp, - messages: 0, first_message: '', - }; + if (!sid) continue; + if (!sessions[sid]) { + sessions[sid] = { + id: sid, tool: 'claude', + project: d.project || '', project_short: (d.project || '').replace(os.homedir(), '~'), + first_ts: d.timestamp, last_ts: d.timestamp, + messages: 0, first_message: '', + _claude_dir: extraClaudeDir, + }; + } } catch {} + if (!d || !d.sessionId) continue; const s = sessions[d.sessionId]; if (s) { s.last_ts = Math.max(s.last_ts, d.timestamp); s.first_ts = Math.min(s.first_ts, d.timestamp); s.messages++; if (d.display && d.display !== 'exit' && !s.first_message) s.first_message = d.display.slice(0, 200); } } @@ -678,28 +821,34 @@ function loadSessions() { for (const file of fs.readdirSync(projDir)) { if (!file.endsWith('.jsonl')) continue; const sid = file.replace('.jsonl', ''); - if (sessions[sid]) { if (!sessions[sid].has_detail) { sessions[sid].has_detail = true; sessions[sid].file_size = fs.statSync(path.join(projDir, file)).size; } continue; } const fp = path.join(projDir, file); - const stat = fs.statSync(fp); - let projectPath = '', tool = 'claude', msgCount = 0, firstMsg = '', firstTs = stat.mtimeMs, lastTs = stat.mtimeMs; - try { - const sLines = readLines(fp); - let entrypointFound = false; - for (const sl of sLines) { - try { - const entry = JSON.parse(sl); - if (entry.type === 'user' || entry.type === 'assistant') msgCount++; - if (entry.timestamp) { if (entry.timestamp < firstTs) firstTs = entry.timestamp; if (entry.timestamp > lastTs) lastTs = entry.timestamp; } - if (!projectPath && entry.type === 'user' && entry.cwd) projectPath = entry.cwd; - if (!entrypointFound && entry.type === 'user' && entry.entrypoint) { entrypointFound = true; if (entry.entrypoint !== 'cli') tool = 'claude-ext'; } - if (!firstMsg && entry.type === 'user' && entry.message && entry.message.content) { - const content = entry.message.content; - if (typeof content === 'string') firstMsg = content.slice(0, 200); - } - } catch {} + if (sessions[sid]) { + const summary = parseClaudeSessionFile(fp); + if (summary) mergeClaudeSessionDetail(sessions[sid], summary, fp); + else if (!sessions[sid].has_detail) { + sessions[sid].has_detail = true; + sessions[sid].file_size = fs.statSync(fp).size; + sessions[sid]._session_file = fp; } - } catch {} - sessions[sid] = { id: sid, tool, project: projectPath, project_short: projectPath.replace(os.homedir(), '~'), first_ts: firstTs, last_ts: lastTs, messages: msgCount, first_message: firstMsg, has_detail: true, file_size: stat.size, detail_messages: msgCount }; + continue; + } + const summary = parseClaudeSessionFile(fp); + if (!summary) continue; + sessions[sid] = { + id: sid, + tool: summary.tool, + project: summary.projectPath, + project_short: summary.projectPath.replace(os.homedir(), '~'), + first_ts: summary.firstTs, + last_ts: summary.lastTs, + messages: summary.msgCount, + first_message: summary.customTitle || summary.firstMsg, + has_detail: true, + file_size: summary.fileSize, + detail_messages: summary.msgCount, + _claude_dir: extraClaudeDir, + _session_file: fp, + }; } } } @@ -708,24 +857,31 @@ function loadSessions() { // Enrich Claude sessions with detail file info for (const [sid, s] of Object.entries(sessions)) { - if (s.tool !== 'claude') continue; - const projectKey = s.project.replace(/[^a-zA-Z0-9-]/g, '-'); - const sessionFile = path.join(PROJECTS_DIR, projectKey, `${sid}.jsonl`); + if (s.tool !== 'claude' && s.tool !== 'claude-ext') continue; + let sessionFile = ''; + if (s._session_file && fs.existsSync(s._session_file)) { + sessionFile = s._session_file; + } else if (s.project) { + const claudeDir = s._claude_dir || CLAUDE_DIR; + const projectsDir = path.join(claudeDir, 'projects'); + const projectKey = s.project.replace(/[^a-zA-Z0-9-]/g, '-'); + const candidate = path.join(projectsDir, projectKey, `${sid}.jsonl`); + if (fs.existsSync(candidate)) sessionFile = candidate; + } + if (!sessionFile) { + const found = findSessionFile(sid, s.project); + if (found && found.format === 'claude') sessionFile = found.file; + } + if (fs.existsSync(sessionFile)) { - s.has_detail = true; - s.file_size = fs.statSync(sessionFile).size; - try { - let msgCount = 0; - const sLines = readLines(sessionFile); - for (const sl of sLines) { - try { - const entry = JSON.parse(sl); - if (entry.type === 'user' || entry.type === 'assistant') msgCount++; - } catch {} - } - s.detail_messages = msgCount; - } catch { s.detail_messages = 0; } - } else { + const summary = parseClaudeSessionFile(sessionFile); + if (summary) mergeClaudeSessionDetail(s, summary, sessionFile); + else { + s.has_detail = true; + s.file_size = fs.statSync(sessionFile).size; + s._session_file = sessionFile; + } + } else if (!s.has_detail) { s.has_detail = false; s.file_size = 0; s.detail_messages = 0; @@ -741,57 +897,28 @@ function loadSessions() { for (const file of fs.readdirSync(projDir)) { if (!file.endsWith('.jsonl')) continue; const sid = file.replace('.jsonl', ''); - if (sessions[sid]) continue; // already loaded const filePath = path.join(projDir, file); - const stat = fs.statSync(filePath); - let projectPath = ''; - let tool = 'claude'; - let msgCount = 0; - let firstMsg = ''; - let firstTs = stat.mtimeMs; - let lastTs = stat.mtimeMs; - try { - const sLines = readLines(filePath); - let entrypointFound = false; - for (const sl of sLines) { - try { - const entry = JSON.parse(sl); - if (entry.type === 'user' || entry.type === 'assistant') msgCount++; - if (entry.timestamp) { - if (entry.timestamp < firstTs) firstTs = entry.timestamp; - if (entry.timestamp > lastTs) lastTs = entry.timestamp; - } - if (!projectPath && entry.type === 'user' && entry.cwd) { - projectPath = entry.cwd; - } - if (!entrypointFound && entry.type === 'user' && entry.entrypoint) { - entrypointFound = true; - if (entry.entrypoint !== 'cli') tool = 'claude-ext'; - } - if (!firstMsg && entry.type === 'user' && entry.message && entry.message.content) { - const content = entry.message.content; - if (typeof content === 'string') firstMsg = content.slice(0, 200); - else if (Array.isArray(content)) { - for (let ci = 0; ci < content.length; ci++) { - if (content[ci].type === 'text' && content[ci].text) { firstMsg = content[ci].text.slice(0, 200); break; } - } - } - } - } catch {} - } - } catch {} + if (sessions[sid]) { + const summary = parseClaudeSessionFile(filePath); + if (summary) mergeClaudeSessionDetail(sessions[sid], summary, filePath); + continue; + } + const summary = parseClaudeSessionFile(filePath); + if (!summary) continue; sessions[sid] = { id: sid, - tool: tool, - project: projectPath, - project_short: projectPath.replace(os.homedir(), '~'), - first_ts: firstTs, - last_ts: lastTs, - messages: msgCount, - first_message: firstMsg, + tool: summary.tool, + project: summary.projectPath, + project_short: summary.projectPath.replace(os.homedir(), '~'), + first_ts: summary.firstTs, + last_ts: summary.lastTs, + messages: summary.msgCount, + first_message: summary.customTitle || summary.firstMsg, has_detail: true, - file_size: stat.size, - detail_messages: msgCount, + file_size: summary.fileSize, + detail_messages: summary.msgCount, + _claude_dir: CLAUDE_DIR, + _session_file: filePath, }; } } @@ -935,15 +1062,16 @@ function getGitCommits(projectDir, fromTs, toTs) { } function exportSessionMarkdown(sessionId, project) { - const projectKey = project.replace(/[^a-zA-Z0-9-]/g, '-'); - const sessionFile = path.join(PROJECTS_DIR, projectKey, `${sessionId}.jsonl`); - - if (!fs.existsSync(sessionFile)) { + const found = findSessionFile(sessionId, project); + if (!found || found.format !== 'claude' || !fs.existsSync(found.file)) { return `# Session ${sessionId}\n\nSession file not found.\n`; } + const sessionFile = found.file; + const summary = parseClaudeSessionFile(sessionFile); const lines = readLines(sessionFile); - const parts = [`# Session ${sessionId}\n\n**Project:** ${project}\n`]; + const projectLabel = project || (summary && summary.projectPath) || '(none)'; + const parts = [`# Session ${sessionId}\n\n**Project:** ${projectLabel}\n`]; for (const line of lines) { try { From 07f4f9366ae0f6a2560ec8902e6e6e53a0783d0d Mon Sep 17 00:00:00 2001 From: Zahar Izmailov Date: Tue, 7 Apr 2026 14:09:20 +0500 Subject: [PATCH 10/39] feat: dedicated Settings page + unified sidebar font sizes (#41) * feat: unify sidebar font sizes and move settings to dedicated page - All sidebar items use 13px (removed .small variant) - New "Settings" sidebar item with gear icon - Settings page contains: Theme, Terminal, AI Titles toggle, LLM Settings - Removed sidebar-settings block from bottom * feat: inline LLM settings in Settings page, remove popup overlay * feat: remove version footer from Settings page --- src/frontend/app.js | 86 ++++++++++++++++++++++++++++++++++++----- src/frontend/index.html | 53 +++++-------------------- src/frontend/styles.css | 61 +++++++++++++++++++++-------- 3 files changed, 130 insertions(+), 70 deletions(-) diff --git a/src/frontend/app.js b/src/frontend/app.js index 030bead..36ae13e 100644 --- a/src/frontend/app.js +++ b/src/frontend/app.js @@ -198,19 +198,17 @@ function toggleAITitles(checked) { render(); } -function openLLMSettings() { - document.getElementById('llmSettingsOverlay').style.display = 'flex'; +function loadLLMSettings() { fetch('/api/llm-config').then(function(r) { return r.json(); }).then(function(c) { - document.getElementById('llmUrl').value = c.url || ''; - document.getElementById('llmApiKey').value = c.apiKey || ''; - document.getElementById('llmModel').value = c.model || ''; + var u = document.getElementById('llmUrl'); + var k = document.getElementById('llmApiKey'); + var m = document.getElementById('llmModel'); + if (u) u.value = c.url || ''; + if (k) k.value = c.apiKey || ''; + if (m) m.value = c.model || ''; }); } -function closeLLMSettings() { - document.getElementById('llmSettingsOverlay').style.display = 'none'; -} - function saveLLMSettings() { var config = { url: document.getElementById('llmUrl').value.trim(), @@ -222,7 +220,6 @@ function saveLLMSettings() { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(config), }).then(function() { - closeLLMSettings(); showToast('LLM settings saved'); }); } @@ -834,6 +831,11 @@ function render() { return; } + if (currentView === 'settings') { + renderSettings(content); + return; + } + if (currentView === 'running') { renderRunning(content, sessions); return; @@ -2072,6 +2074,70 @@ function focusSession(sessionId) { // ── Changelog view ──────────────────────────────────────────── +function renderSettings(container) { + var savedTheme = localStorage.getItem('codedash-theme') || 'dark'; + var savedTerminal = localStorage.getItem('codedash-terminal') || ''; + var aiTitlesOn = localStorage.getItem('codedash-ai-titles') === 'true'; + + var html = '
'; + html += '

Settings

'; + + // Theme + html += '
'; + html += ''; + html += '
'; + ['dark', 'light', 'system'].forEach(function(t) { + var active = savedTheme === t ? ' active' : ''; + html += ''; + }); + html += '
'; + html += '
'; + + // Terminal + html += '
'; + html += ''; + html += ''; + html += '
'; + + // AI Titles + html += '
'; + html += ''; + html += '
'; + html += ''; + html += 'Show generated titles'; + html += '
'; + html += '
'; + + // LLM Configuration + html += '
'; + html += ''; + html += '

OpenAI-compatible API for session title generation

'; + html += '
'; + html += ''; + html += ''; + html += ''; + html += '
'; + html += '
'; + html += ''; + html += ''; + html += '
'; + html += '
'; + + html += '
'; + container.innerHTML = html; + + // Load LLM config into the inputs + loadLLMSettings(); +} + async function renderChangelog(container) { container.innerHTML = '
Loading changelog...
'; try { diff --git a/src/frontend/index.html b/src/frontend/index.html index 8662d48..23278a8 100644 --- a/src/frontend/index.html +++ b/src/frontend/index.html @@ -66,19 +66,19 @@ -