diff --git a/desktop/package.json b/desktop/package.json index 90178d1..28adc00 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "codbash-desktop", "productName": "codbash", - "version": "7.14.4", + "version": "7.14.5", "private": true, "description": "Desktop shell (Electron) for codbash — wraps the codbash server in a native window.", "main": "main.js", diff --git a/package.json b/package.json index 651c53f..e593dfd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codbash-app", - "version": "7.14.4", + "version": "7.14.5", "description": "Dashboard + CLI for AI coding agents — Claude Code, Codex, Cursor, OpenCode, Kiro. View, search, resume, convert, sync sessions.", "bin": { "codbash": "./bin/cli.js", diff --git a/src/changelog.js b/src/changelog.js index 50e6f47..3daff8e 100644 --- a/src/changelog.js +++ b/src/changelog.js @@ -1,6 +1,21 @@ 'use strict'; const CHANGELOG = [ + { + version: '7.14.5', + date: '2026-07-20', + title: 'Bug-hunt sweep: crash-safety, data-loss fix, and many correctness fixes', + changes: [ + 'Server no longer crashes on a malformed session id (e.g. /api/session/__proto__); read routes are prototype-safe and every route now returns 500 instead of taking down the process', + 'Import now truly merges history — it no longer overwrites your local Claude/Codex history before dedup (previously local sessions could be lost)', + 'Fixed WSL terminal launch (a ReferenceError broke every launch on WSL)', + 'Delete / bulk-delete now validate the session id; archive tar calls no longer go through a shell (path-injection safe)', + 'Cost totals for a running session now update instead of freezing at first read; Copilot sessions are now included in full-text search', + 'Live badges reappear after navigating; keyboard navigation works in List layout; the Agent Board refreshes on activity; a tool filter no longer stays highlighted after you toggle it off', + 'Delete during an Escape no longer shows a false failure; pressing Escape while renaming a terminal tab now cancels instead of committing', + 'Claude session first/last timestamps, IPv6 loopback access, ~ in a terminal cwd, Codex export assistant blocks, and the handoff "Original Task" section all fixed', + ], + }, { version: '7.14.4', date: '2026-07-20', diff --git a/src/convert.js b/src/convert.js index c71d892..4e4053b 100644 --- a/src/convert.js +++ b/src/convert.js @@ -236,7 +236,9 @@ function writeCodex(canonical, targetProject) { payload: { type: 'message', role: msg.role, - content: [{ type: 'input_text', text: msg.content }], + // Codex tags assistant turns as output_text and user turns as + // input_text; using input_text for everything makes malformed rollouts. + content: [{ type: msg.role === 'assistant' ? 'output_text' : 'input_text', text: msg.content }], }, })); } diff --git a/src/data.js b/src/data.js index b7f6842..591ca1f 100644 --- a/src/data.js +++ b/src/data.js @@ -931,9 +931,13 @@ function parseClaudeSessionFile(sessionFile) { if (entry.type === 'user' || entry.type === 'assistant') msgCount++; if (entry.type === 'user') totalUserMsgs++; if (isRealUserPrompt(entry)) userMsgCount++; - if (entry.timestamp) { - if (entry.timestamp < firstTs) firstTs = entry.timestamp; - if (entry.timestamp > lastTs) lastTs = entry.timestamp; + // entry.timestamp is an ISO 8601 string; firstTs/lastTs are ms numbers + // (init'd to stat.mtimeMs). Comparing string lastTs) lastTs = entryTs; } if (!projectPath && entry.type === 'user' && entry.cwd) { projectPath = entry.cwd; @@ -4092,8 +4096,13 @@ function _buildSessionFileIndex() { function findSessionFile(sessionId, project) { _buildSessionFileIndex(); - // Fast index lookup - if (_sessionFileIndex[sessionId]) return _sessionFileIndex[sessionId]; + // Fast index lookup. Use hasOwnProperty so a prototype key like '__proto__' + // or 'constructor' can't return Object.prototype (a truthy non-record) and + // crash callers that then read .file/.format off it. + if (typeof sessionId === 'string' && + Object.prototype.hasOwnProperty.call(_sessionFileIndex, sessionId)) { + return _sessionFileIndex[sessionId]; + } // Try Claude projects dir (direct path if project known) if (project) { @@ -4731,6 +4740,17 @@ function buildSearchIndex(sessions) { texts.push({ role: msg.role, content: msg.content.slice(0, 500) }); } } + } else if (found.format === 'copilot-chat' || found.format === 'copilot') { + // Copilot Chat (VS Code JSON) and Copilot CLI use bespoke loaders; the + // generic JSONL branch below would mis-parse them and index nothing. + const detail = found.format === 'copilot' + ? loadCopilotCliDetail(s.id) + : loadCopilotDetail(s.id); + for (const msg of (detail.messages || [])) { + if (msg.content && !isSystemMessage(msg.content)) { + texts.push({ role: msg.role, content: msg.content.slice(0, 500) }); + } + } } else { const lines = readLines(found.file); @@ -5003,8 +5023,6 @@ const EMPTY_COST = { const _costMemCache = {}; function computeSessionCost(sessionId, project) { - if (_costMemCache[sessionId] !== undefined) return _costMemCache[sessionId]; - const found = findSessionFile(sessionId, project); if (!found) { _costMemCache[sessionId] = EMPTY_COST; return EMPTY_COST; } @@ -5031,6 +5049,11 @@ function computeSessionCost(sessionId, project) { } catch {} } } + // In-memory fast layer, keyed by the SAME fingerprint as the disk cache + // (file+mtime+size) — NOT by sessionId — so a still-growing session's cost is + // recomputed on change instead of frozen at its first read. + const memKey = cacheKey || ('sid:' + sessionId); + if (_costMemCache[memKey] !== undefined) return _costMemCache[memKey]; if (cacheKey && _costDiskCache[cacheKey]) return _costDiskCache[cacheKey]; let totalCost = 0; @@ -5260,7 +5283,7 @@ function computeSessionCost(sessionId, project) { unavailable, }; if (cacheKey) _costDiskCache[cacheKey] = result; - _costMemCache[sessionId] = result; + _costMemCache[memKey] = result; return result; } @@ -5328,7 +5351,7 @@ function computeSessionCost(sessionId, project) { unavailable, }; if (cacheKey) _costDiskCache[cacheKey] = result; - _costMemCache[sessionId] = result; + _costMemCache[memKey] = result; return result; } diff --git a/src/frontend/app.js b/src/frontend/app.js index fbaeb04..2e2917c 100644 --- a/src/frontend/app.js +++ b/src/frontend/app.js @@ -1170,10 +1170,12 @@ async function pollActiveSessions() { } }); - // Check if anything changed — skip DOM work if not + // Check if anything changed — skip DOM work if not. Note: _prevActiveKey is + // set at the END of this function, not here, because render() (invoked below + // for the running view, and elsewhere on navigation) resets it to '' to force + // a badge re-apply; setting it last means that reset can't spin a re-render. var newKey = data.map(function(a) { return (a.sessionId || a.pid) + ':' + a.status; }).sort().join(','); if (newKey === _prevActiveKey) return; - _prevActiveKey = newKey; activeSessions = newActive; @@ -1181,6 +1183,9 @@ async function pollActiveSessions() { // the sidebar running-agents tree (which is driven by activeSessions now). if (typeof _ovRefreshIfCurrent === 'function') _ovRefreshIfCurrent(); if (typeof _wsRenderRunningTree === 'function') _wsRenderRunningTree(); + // The Running (Agent Board) view is built from activeSessions but isn't a + // per-card diff, so rebuild it when the active set changes. + if (currentView === 'running') render(); // Only touch cards that changed document.querySelectorAll('.card').forEach(function(card) { @@ -1232,6 +1237,10 @@ async function pollActiveSessions() { } } }); + + // Record the applied state last, so a render()-triggered reset above is + // superseded and the next unchanged poll can early-return. + _prevActiveKey = newKey; } catch {} } @@ -1655,6 +1664,11 @@ function render() { // AI Titles/Refresh + count) is meaningless in the Terminal and Overview views. document.body.setAttribute('data-view', currentView); + // Rebuilding content wipes the live/active badges the active-poll diff added. + // Reset the diff key so the next poll re-applies them (it otherwise early- + // returns on an unchanged active set and the badges never come back). + _prevActiveKey = ''; + // Detach (don't destroy) the live terminal when navigating away from the // Workspace view: its panes/ptys keep running in a hidden holder and are // re-attached intact on return. @@ -2391,16 +2405,20 @@ function closeConfirm() { async function confirmDelete() { if (!pendingDelete) return; + // Capture the target now: pendingDelete is a mutable global that Escape + // (closeConfirm) nulls, so reading it after the awaits below would throw and + // surface a false "Delete failed" if the user dismissed mid-request. + var target = pendingDelete; try { - var resp = await fetch('/api/session/' + pendingDelete.id, { + var resp = await fetch('/api/session/' + target.id, { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ project: pendingDelete.project }) + body: JSON.stringify({ project: target.project }) }); var data = await resp.json(); if (data.ok) { showToast('Session deleted'); - allSessions = allSessions.filter(function(s) { return s.id !== pendingDelete.id; }); + allSessions = allSessions.filter(function(s) { return s.id !== target.id; }); // Clear search if no more results if (searchQuery) { var remaining = allSessions.filter(function(s) { @@ -2908,7 +2926,7 @@ function isInput(e) { } function moveFocus(delta) { - var cards = document.querySelectorAll('.card'); + var cards = document.querySelectorAll('.card, .list-row'); if (cards.length === 0) return; focusedIndex = Math.max(0, Math.min(cards.length - 1, focusedIndex + delta)); cards.forEach(function(c, i) { @@ -2920,7 +2938,7 @@ function moveFocus(delta) { } function openFocusedCard() { - var cards = document.querySelectorAll('.card'); + var cards = document.querySelectorAll('.card, .list-row'); if (focusedIndex < 0 || focusedIndex >= cards.length) return; var id = cards[focusedIndex].getAttribute('data-id'); if (!id) return; @@ -2935,14 +2953,14 @@ function openFocusedCard() { } function toggleStarFocused() { - var cards = document.querySelectorAll('.card'); + var cards = document.querySelectorAll('.card, .list-row'); if (focusedIndex < 0 || focusedIndex >= cards.length) return; var id = cards[focusedIndex].getAttribute('data-id'); if (id) toggleStar(id); } function deleteFocused() { - var cards = document.querySelectorAll('.card'); + var cards = document.querySelectorAll('.card, .list-row'); if (focusedIndex < 0 || focusedIndex >= cards.length) return; var id = cards[focusedIndex].getAttribute('data-id'); if (!id) return; diff --git a/src/frontend/calendar.js b/src/frontend/calendar.js index 4bf602f..f6f5a46 100644 --- a/src/frontend/calendar.js +++ b/src/frontend/calendar.js @@ -236,9 +236,14 @@ function setView(view) { // Update sidebar active state document.querySelectorAll('.sidebar-item').forEach(function(el) { var itemView = el.getAttribute('data-view'); - var active = itemView === view; + var active; + // Tool-filter items are toggles: derive their highlight from the actual + // filter state, not `itemView === view` (which stays true after toggling + // the filter OFF, leaving the item wrongly highlighted). if (itemView === 'pi-original-only') active = toolFilter === 'pi' && piVariantFilter === 'pi'; else if (itemView === 'ohmypi-only') active = toolFilter === 'pi' && piVariantFilter === 'ohmypi'; + else if (itemView && itemView.slice(-5) === '-only') active = toolFilter === itemView.slice(0, -5); + else active = itemView === view; el.classList.toggle('active', active); }); diff --git a/src/frontend/workspace.js b/src/frontend/workspace.js index 1ca8730..37a91d1 100644 --- a/src/frontend/workspace.js +++ b/src/frontend/workspace.js @@ -113,7 +113,9 @@ function _wsUpdateStatusBar() { // The chip shows the (renamable) tab name only — clean and stable. The // command lands in the tooltip so it never makes the bar jitter. var tip = (x.pane.cwd || '') + (x.pane.cmd ? ' — ' + _wsMaskSecrets(x.pane.cmd) : ''); - sigParts.push(x.tab.id + ':' + x.pane.id + ':' + x.tab.name + ':' + st); + // Include `tip` in the signature: without it a cwd/cmd change with an + // unchanged tab name + status would skip the rebuild and leave a stale tooltip. + sigParts.push(x.tab.id + ':' + x.pane.id + ':' + x.tab.name + ':' + st + ':' + tip); return '