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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,7 @@ node_modules/
hooks/
.tmp/
package-lock.json

# Local dev screenshots / MCP scratch (not shipped)
.playwright-mcp/
/*.png
16 changes: 16 additions & 0 deletions desktop/dev.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
#!/usr/bin/env bash
# Relaunch the codbash desktop app FROM SOURCE with live frontend reload.
#
# Use this (not the installed DMG) to see uncommitted changes:
# • The window shows an amber "DEV" badge next to the version.
# • Frontend edits (src/frontend/**) → just press Cmd+R in the window.
# • Backend edits (src/*.js, bin/**) → re-run this script.
#
# It kills any prior dev instances first so you never end up staring at a stale
# window on the wrong port.
set -e
pkill -f "codbash/desktop" 2>/dev/null || true
pkill -f "bin/cli.js run" 2>/dev/null || true
sleep 1
cd "$(dirname "$0")"
NODE_ENV=development exec npm start
44 changes: 43 additions & 1 deletion desktop/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,31 @@ function getFreePort() {
});
}

// A STABLE loopback port keeps the window's origin (http://127.0.0.1:<port>)
// constant across launches. This is REQUIRED for persistence: localStorage is
// scoped to the origin, so a random ephemeral port silently wiped EVERYTHING
// origin-scoped every launch — theme, starred sessions, tags, terminal prefs,
// and the saved Workspace session used to restore tabs/panes. We therefore
// prefer a fixed port and only fall back to an ephemeral one if it is genuinely
// occupied (rare — another instance or an unrelated listener).
const PREFERRED_PORT = 51763;
function isPortFree(port) {
return new Promise((resolve) => {
const srv = net.createServer();
srv.unref();
srv.once('error', () => resolve(false));
srv.listen(port, '127.0.0.1', () => srv.close(() => resolve(true)));
});
}
async function resolveStablePort() {
if (process.env.CODBASH_PORT) {
const p = parseInt(process.env.CODBASH_PORT, 10);
if (Number.isInteger(p) && p > 0 && await isPortFree(p)) return p;
}
if (await isPortFree(PREFERRED_PORT)) return PREFERRED_PORT;
return getFreePort();
}

// Where the codbash server lives: repo layout in dev, bundled resources when packaged.
function resolveServerEntry() {
const dev = path.join(__dirname, '..', 'bin', 'cli.js');
Expand Down Expand Up @@ -135,6 +160,9 @@ function registerIpc() {
if (res.canceled || !res.filePaths || !res.filePaths.length) return null;
return res.filePaths[0];
});
// The renderer decides a shortcut had no in-page meaning (e.g. Cmd+W outside
// the Workspace) and asks us to close the window instead.
ipcMain.on('codbash:close-window', function () { if (win) { try { win.close(); } catch (_e) {} } });
}

async function createWindow() {
Expand All @@ -160,6 +188,20 @@ async function createWindow() {
return { action: 'allow' };
});

// Cmd/Ctrl+W would hit the native menu's "Close Window" before the page ever
// sees the keystroke. Intercept it here so it can close the ACTIVE TAB instead
// (Chrome-like). We forward it to the renderer, which closes a tab or — if
// there's nothing to close — calls back to close the window. Cmd+T and
// Cmd+Shift+T are NOT default menu accelerators, so the page handles those.
win.webContents.on('before-input-event', function (event, input) {
if (input.type !== 'keyDown') return;
const mod = input.meta || input.control;
if (mod && !input.shift && !input.alt && (input.key === 'w' || input.key === 'W')) {
event.preventDefault();
try { win.webContents.send('codbash:shortcut', 'close-tab'); } catch (_e) {}
}
});

await win.loadURL('http://127.0.0.1:' + serverPort + '/');

if (SMOKE) {
Expand Down Expand Up @@ -232,7 +274,7 @@ function initAutoUpdater() {

app.whenReady().then(async function () {
try {
serverPort = await getFreePort();
serverPort = await resolveStablePort();
startServer(serverPort);
await waitForServer(serverPort);
await createWindow();
Expand Down
5 changes: 3 additions & 2 deletions 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.7",
"version": "7.15.0",
"private": true,
"description": "Desktop shell (Electron) for codbash — wraps the codbash server in a native window.",
"main": "main.js",
Expand All @@ -12,7 +12,8 @@
"smoke": "CODBASH_SMOKE=1 electron .",
"dist:mac": "electron-builder --mac dmg",
"release:mac": "electron-builder --mac dmg --publish always",
"pack": "electron-builder --dir"
"pack": "electron-builder --dir",
"dev": "bash dev.sh"
},
"devDependencies": {
"@electron/notarize": "^2.5.0",
Expand Down
6 changes: 6 additions & 0 deletions desktop/preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,10 @@ contextBridge.exposeInMainWorld('codbashDesktop', {
isDesktop: true,
// Resolves to the chosen absolute folder path, or null if the user cancels.
pickFolder: () => ipcRenderer.invoke('codbash:pick-folder'),
// Keyboard shortcuts the native menu would otherwise swallow (e.g. Cmd+W
// closing the whole window). Main intercepts them and forwards the name here
// so the page can act (close a tab instead). cb receives the shortcut name.
onShortcut: (cb) => ipcRenderer.on('codbash:shortcut', (_e, name) => cb(name)),
// Ask main to close the window (used when a shortcut has no in-page meaning).
closeWindow: () => ipcRenderer.send('codbash:close-window'),
});
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.7",
"version": "7.15.0",
"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
16 changes: 16 additions & 0 deletions src/changelog.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,22 @@
'use strict';

const CHANGELOG = [
{
version: '7.15.0',
date: '2026-07-23',
title: 'Browser-like terminal: session resume, bookmarks & groups, settings, resizable panes — plus input-freeze & lost-dialog fixes',
changes: [
'Fixed the terminal freezing input intermittently — the running-agents and pane-cwd polls ran ps/lsof synchronously on the event loop (every 1–4s), stalling terminal I/O; they now run off the loop so typing stays smooth',
'Fixed agents launched from codbash writing their conversation to the wrong place: panes no longer inherit the parent\'s agent-session vars (which made a nested "hidden folder" session) or npm\'s npm_config_prefix (which broke nvm), and a requested folder is never silently swapped for your home directory (which misfiled dialogs) — you\'re warned instead',
'Resume where you left off (Chrome-like): reopening the app offers to continue each pane\'s last agent conversation in that folder with one click (claude --continue), keeping your proxy prefix',
'Bookmarks bar with named groups/folders — drag a bookmark into a group; one click opens the folder and launches its agent',
'Terminal settings (⚙): font family & size, theme (iTerm, Monokai, Dracula, Solarized, Light…), cursor style and blink — applied live to every pane',
'Resizable split panes — drag the divider; sizes persist with the session and survive maximize / fullscreen',
'Tabs: Cmd+T new tab, Cmd+W close tab, Cmd+Shift+T reopen the last closed tab (full layout), and drag to reorder',
'Reclaimed vertical space by merging the tab bar and toolbar into one compact row',
'Running Agents now lists only agents actually launched from codbash',
],
},
{
version: '7.14.7',
date: '2026-07-21',
Expand Down
155 changes: 138 additions & 17 deletions src/data.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
const fs = require('fs');
const path = require('path');
const os = require('os');
const { execSync, execFileSync } = require('child_process');
const { execSync, execFileSync, exec, execFile } = require('child_process');
const { promisify } = require('util');
const _execAsync = promisify(exec);
const _execFileAsync = promisify(execFile);
const { atomicWriteJson } = require('./atomic');

// ── Constants ──────────────────────────────────────────────
Expand Down Expand Up @@ -5398,31 +5401,98 @@ function _analyticsKey(sessions) {
return sessions.length + ':' + newest;
}

function getCostAnalytics(sessions) {
// Fast cache check — if sessions haven't changed, return cached result
// Background recompute guard: the key we're currently (re)computing off the
// request path, so overlapping Overview polls don't queue N duplicate 4s jobs.
let _analyticsRecomputeKey = null;

function _persistAnalytics(key, result) {
try { fs.writeFileSync(ANALYTICS_CACHE_FILE, JSON.stringify({ _key: key, data: result })); } catch {}
}

// Recompute analytics off the request path and refresh the in-memory + disk
// cache when done. Deduped by key so a burst of stale hits schedules one job.
//
// The expensive part of a cold recompute is ~O(sessions) computeSessionCost()
// calls, each of which may read+parse a JSONL file. Running them all in one
// synchronous tick would freeze the event loop for seconds — stalling every
// other request AND the terminal WebSocket data pump. So we warm those caches
// in small chunks, yielding to the loop between chunks; the final aggregate is
// then cache-warm and returns in milliseconds.
function _scheduleAnalyticsRecompute(key, sessions) {
if (_analyticsRecomputeKey === key) return;
_analyticsRecomputeKey = key;
const finish = () => { if (_analyticsRecomputeKey === key) _analyticsRecomputeKey = null; };
(async () => {
try {
const CHUNK = 80;
for (let i = 0; i < sessions.length; i += CHUNK) {
const chunk = sessions.slice(i, i + CHUNK);
for (const s of chunk) {
// Skip formats that carry no token cost (cursor/kiro/copilot) — they
// short-circuit in computeSessionCost anyway, but avoid the call.
if (s.tool === 'cursor' || s.tool === 'kiro' || s.tool === 'copilot-chat') continue;
try { computeSessionCost(s.id, s.project); } catch {}
}
// Yield so terminal output and other API calls stay responsive.
await new Promise(r => setImmediate(r));
}
const result = _computeCostAnalytics(sessions); // caches warm → fast, sync
_analyticsCacheResult = result;
_analyticsCacheKey = key;
_persistAnalytics(key, result);
} catch {}
finally { finish(); }
})();
}

// Cost analytics with stale-while-revalidate. The cache key is
// count+newest-mtime, so it invalidates after ANY session activity — which,
// with ~1500 sessions, meant a ~4s synchronous recompute on nearly every cold
// start / Overview open. Instead we return the last known result INSTANTLY and
// refresh in the background (opts.allowStale, set by the unfiltered Overview
// path). Only the very first computation, when nothing is cached yet, blocks.
//
// Date-filtered requests (from/to → allowStale falsy) must be exact, so they
// compute synchronously and are kept out of the shared Overview cache to avoid
// a filtered snapshot briefly leaking onto the unfiltered Overview.
function getCostAnalytics(sessions, opts) {
opts = opts || {};
const key = _analyticsKey(sessions);

// Exact in-memory hit → fresh.
if (_analyticsCacheResult && _analyticsCacheKey === key) return _analyticsCacheResult;

// Try disk cache
// Warm the in-memory cache from disk once (survives restarts).
if (!_analyticsCacheResult) {
try {
if (fs.existsSync(ANALYTICS_CACHE_FILE)) {
const cached = JSON.parse(fs.readFileSync(ANALYTICS_CACHE_FILE, 'utf8'));
if (cached._key === key) {
if (cached && cached.data) {
_analyticsCacheResult = cached.data;
_analyticsCacheKey = key;
return cached.data;
_analyticsCacheKey = cached._key;
if (cached._key === key) return cached.data;
}
}
} catch {}
}

// Stale-while-revalidate for the unfiltered Overview/startup path: return the
// previous result now, refresh in the background. Never blocks after the very
// first ever computation.
if (opts.allowStale && _analyticsCacheResult) {
_scheduleAnalyticsRecompute(key, sessions);
return _analyticsCacheResult;
}

// Cold path: first-ever computation, or an exact (date-filtered) request.
const result = _computeCostAnalytics(sessions);

// Save to cache
_analyticsCacheResult = result;
_analyticsCacheKey = key;
try { fs.writeFileSync(ANALYTICS_CACHE_FILE, JSON.stringify({ _key: key, data: result })); } catch {}
// Only the unfiltered path owns the shared Overview cache.
if (opts.allowStale) {
_analyticsCacheResult = result;
_analyticsCacheKey = key;
_persistAnalytics(key, result);
}

return result;
}
Expand Down Expand Up @@ -5767,7 +5837,11 @@ function findQwenSessionByPid(pid, cwd, allSessions) {
return null;
}

function getActiveSessions() {
// ASYNC on purpose: this shells out to `ps aux` (and sometimes `lsof`) and is
// polled by /api/active every second. The old synchronous execSync blocked the
// single Node event loop each tick, which stalled the Workspace pty's I/O — the
// "input freezes for a moment" bug. Async execFile keeps it off the loop.
async function getActiveSessions() {
const active = [];
const seenPids = new Set();

Expand Down Expand Up @@ -5800,10 +5874,10 @@ function getActiveSessions() {
if (process.platform === 'win32') return active;

try {
const psOut = execSync(
const psOut = (await _execAsync(
'ps aux 2>/dev/null | grep -E "claude|codex|qwen|omp|omp.cmd|(^|[[:space:]/])pi([[:space:]]|$)|opencode|kiro-cli|cursor-agent|kilo" | grep -v grep || true',
{ encoding: 'utf8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'] }
);
{ encoding: 'utf8', timeout: 3000, maxBuffer: 4 * 1024 * 1024 }
)).stdout;

const allSessions = loadSessions();

Expand All @@ -5828,6 +5902,16 @@ function getActiveSessions() {

// Skip node/npm/shell wrappers, MCP servers, plugins — only main agent processes
if (cmd.includes('node bin/cli') || /(^|\s)npm(\s|$)/.test(cmd) || /(^|\s)grep(\s|$)/.test(cmd)) continue;
// Skip GUI desktop apps whose name/path contains an agent word but which
// are NOT CLI agents — most notably the Claude *Desktop* chat app, whose
// Electron helpers live under `/Applications/Claude.app/…/Claude Helper`
// and match the loose `\bclaude\b` pattern. Electron subprocesses are
// identified by their `--type=<renderer|utility|gpu-process|zygote|…>`
// flag; the bundle helpers by their `.app/Contents/{Frameworks,MacOS}/`
// path. Neither is ever a coding-agent session.
if (/--type=(renderer|utility|gpu-process|zygote|broker|crashpad|ppapi|sandbox)/.test(cmd)) continue;
if (/\.app\/Contents\/(Frameworks|MacOS)\//.test(cmd)) continue;
if (/(^|\/)(Claude|Cursor|Code) Helper/.test(cmd)) continue;
if (cmd.includes('mcp-server') || cmd.includes('mcp_server') || cmd.includes('/mcp/') || cmd.includes('/mcp-servers/')) continue;
if (cmd.includes('/plugins/') || cmd.includes('plugin-') || cmd.includes('app-server-broker')) continue;
if (cmd.includes('.claude/') && !cmd.includes('claude ') && tool === 'claude') continue;
Expand Down Expand Up @@ -5878,7 +5962,7 @@ function getActiveSessions() {
}
if (!cwd) {
try {
const lsofOut = execSync(`lsof -a -p ${pid} -d cwd -Fn 2>/dev/null`, { encoding: 'utf8', timeout: 2000, stdio: ['pipe', 'pipe', 'pipe'] });
const lsofOut = (await _execFileAsync('lsof', ['-a', '-p', String(pid), '-d', 'cwd', '-Fn'], { encoding: 'utf8', timeout: 2000 })).stdout;
// -F output is line-oriented; cwd path lives on a line starting with "n".
// Anchor to start-of-line (multiline flag) so we don't depend on a
// preceding newline, and tolerate non-path lines mixed in.
Expand Down Expand Up @@ -5972,7 +6056,44 @@ function getActiveSessions() {
if (entryScore > existingScore) deduped.set(key, entry);
}

return Array.from(deduped.values());
// Scope to agents launched from codbash — only those whose process tree
// descends from a codbash terminal pane (see pty-registry). Matches the
// "codbash-only" RUNNING AGENTS model: no panes open → nothing shown.
return await _scopeToCodbashAgents(Array.from(deduped.values()));
}

// Keep only active entries whose process ancestry reaches a live codbash pty.
// Empty pty registry → empty result (nothing is running *in* codbash). If the
// ppid scan itself fails we fail OPEN (return the unfiltered list) rather than
// hiding genuine sessions on a transient `ps` error.
async function _scopeToCodbashAgents(active) {
let ptyRegistry;
try { ptyRegistry = require('./pty-registry'); } catch { return active; }
const livePids = ptyRegistry.all();
if (!livePids.length) return [];
if (process.platform === 'win32') return active; // no ppid scan here
const live = new Set(livePids);

// Build a pid→ppid map in one cheap (async, off-loop) call so we can walk each
// agent's ancestry without blocking the event loop.
const ppidOf = new Map();
try {
const out = (await _execFileAsync('ps', ['-Ao', 'pid=,ppid='], { encoding: 'utf8', timeout: 3000, maxBuffer: 4 * 1024 * 1024 })).stdout;
for (const line of out.split('\n')) {
const m = line.trim().match(/^(\d+)\s+(\d+)$/);
if (m) ppidOf.set(parseInt(m[1], 10), parseInt(m[2], 10));
}
} catch { return active; } // fail open

return active.filter(a => {
let pid = a.pid, depth = 0;
while (pid && depth < 16) {
if (live.has(pid)) return true;
pid = ppidOf.get(pid);
depth++;
}
return false;
});
}

// ── Leaderboard stats ─────────────────────────────────────
Expand Down
Loading
Loading