Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion desktop/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
15 changes: 15 additions & 0 deletions src/changelog.js
Original file line number Diff line number Diff line change
@@ -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',
Expand Down
4 changes: 3 additions & 1 deletion src/convert.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }],
},
}));
}
Expand Down
41 changes: 32 additions & 9 deletions src/data.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<number is always NaN-false,
// so normalize to ms first or the min/max never updates from the entries.
var entryTs = parseEntryTimestampMs(entry);
if (Number.isFinite(entryTs)) {
if (entryTs < firstTs) firstTs = entryTs;
if (entryTs > lastTs) lastTs = entryTs;
}
if (!projectPath && entry.type === 'user' && entry.cwd) {
projectPath = entry.cwd;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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; }

Expand All @@ -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;
Expand Down Expand Up @@ -5260,7 +5283,7 @@ function computeSessionCost(sessionId, project) {
unavailable,
};
if (cacheKey) _costDiskCache[cacheKey] = result;
_costMemCache[sessionId] = result;
_costMemCache[memKey] = result;
return result;
}

Expand Down Expand Up @@ -5328,7 +5351,7 @@ function computeSessionCost(sessionId, project) {
unavailable,
};
if (cacheKey) _costDiskCache[cacheKey] = result;
_costMemCache[sessionId] = result;
_costMemCache[memKey] = result;
return result;
}

Expand Down
36 changes: 27 additions & 9 deletions src/frontend/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -1170,17 +1170,22 @@ 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;

// Reflect the new active-agent count on the Overview landing and refresh
// 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) {
Expand Down Expand Up @@ -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 {}
}

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand All @@ -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;
Expand All @@ -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;
Expand Down
7 changes: 6 additions & 1 deletion src/frontend/calendar.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand Down
18 changes: 16 additions & 2 deletions src/frontend/workspace.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 '<button class="ws-chip ' + meta.cls + '" title="' + escHtml(tip) + '" ' +
'onclick="jumpToWorkspacePane(\'' + escHtml(x.tab.id) + '\',\'' + escHtml(x.pane.id) + '\')">' +
'<span class="ws-chip-dot"></span>' + escHtml(x.tab.name) +
Expand Down Expand Up @@ -689,14 +691,26 @@ function renameWorkspaceTab(id) {
input.value = tab.name;
el.replaceWith(input);
input.focus(); input.select();
// `done` guards against the blur handler re-firing after we remove the input:
// both Enter (commit → re-render removes input → blur) and Escape (cancel →
// re-render removes input → blur) would otherwise commit a second time. Escape
// must NOT commit at all.
var done = false;
function commit() {
if (done) return;
done = true;
var v = input.value.trim();
if (v) { tab.name = v; tab.userNamed = true; } // manual name wins over auto-naming
_wsRenderTabbar();
}
function cancel() {
if (done) return;
done = true;
_wsRenderTabbar();
}
input.addEventListener('keydown', function (e) {
if (e.key === 'Enter') { e.preventDefault(); commit(); }
else if (e.key === 'Escape') { _wsRenderTabbar(); }
else if (e.key === 'Escape') { e.preventDefault(); cancel(); }
});
input.addEventListener('blur', commit);
input.addEventListener('click', function (e) { e.stopPropagation(); });
Expand Down
12 changes: 8 additions & 4 deletions src/handoff.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,11 @@ function generateHandoff(sessionId, project, options) {
const session = sessions.find(s => s.id === sessionId || s.id.startsWith(sessionId));
if (!session) return { ok: false, error: 'Session not found' };

// Load messages
// Load messages. `allMessages` is the full session (used for the genuine
// first task); `messages` is the recent-window tail used for the transcript.
const detail = loadSessionDetail(session.id, session.project || project);
const messages = (detail.messages || []).slice(-msgLimit);
const allMessages = detail.messages || [];
const messages = allMessages.slice(-msgLimit);
const cost = computeSessionCost(session.id, session.project || project);
const costLabel = cost.unavailable
? `unavailable (${cost.model || 'unknown model'})`
Expand All @@ -42,8 +44,10 @@ function generateHandoff(sessionId, project, options) {

// Summary of what was being worked on
if (messages.length > 0) {
// First user message = original task
const firstUser = messages.find(m => m.role === 'user');
// Original task = the FIRST user message of the whole session, not merely
// the first within the truncated recent window (which mislabels the task
// for any session longer than msgLimit).
const firstUser = allMessages.find(m => m.role === 'user');
if (firstUser) {
lines.push('## Original Task');
lines.push('');
Expand Down
Loading
Loading