Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
30502d8
fix: unbalanced <div> broke Projects launcher card layout
NovakPAai Aug 2, 2026
bca13b0
feat: unify local + external agents in the Running agents sidebar tree
NovakPAai Aug 2, 2026
9410084
fix: make the Running agents tree an actual 3-level hierarchy
NovakPAai Aug 2, 2026
3017efd
feat: make the Running agents tree an accordion, collapsed by default
NovakPAai Aug 2, 2026
de900eb
Merge remote-tracking branch 'pr/283' into rc/7.18.0
vakovalskii Aug 2, 2026
ca08be9
release: 7.18.0 — unified Running agents tree + Projects launcher lay…
vakovalskii Aug 2, 2026
06b5ecc
fix: accessibility pass — keyboard reachability and dialog semantics
NovakPAai Aug 2, 2026
12fc0e7
Merge remote-tracking branch 'pr/285' into rc/7.18.0
vakovalskii Aug 2, 2026
9909bad
chore(release): note the accessibility pass (#285) in 7.18.0
vakovalskii Aug 2, 2026
062e63e
fix(security): never vend the LLM API key to the browser
NovakPAai Aug 2, 2026
8cdd684
perf: stop the search index from freezing the event loop
NovakPAai Aug 2, 2026
921c870
fix: accessible GitHub auth dialog + argv-form exec at three sites
NovakPAai Aug 3, 2026
5e2bc69
Merge remote-tracking branch 'pr/286' into rc/7.18.0
vakovalskii Aug 3, 2026
50452e1
Merge remote-tracking branch 'pr/287' into rc/7.18.0
vakovalskii Aug 3, 2026
b744bde
Merge pr/288 into rc/7.18.0
vakovalskii Aug 3, 2026
8b6c74e
chore(release): note #285 #286 #287 #288 in 7.18.0
vakovalskii Aug 3, 2026
1643c84
perf: stop the 5s active-sessions poll from blocking the event loop
vakovalskii Aug 13, 2026
f45f90a
fix(desktop): rc-noise-proof node resolution + spawn error dialog
Aug 31, 2026
98dab17
fix(desktop): address review — reuse shell-path probe, honor nvm defa…
Aug 31, 2026
e7e7dda
fix(search): background index rebuild can't crash the server
vakovalskii Oct 2, 2026
68ce4cd
fix(a11y): no double-open on Enter, no orphaned focus trap, Escape cl…
vakovalskii Oct 2, 2026
b4550b6
fix(running-agents): jump local agents to their own pane by ptyPid
vakovalskii Oct 2, 2026
1017d47
Merge remote-tracking branch 'origin/claude/novak-search-index-nonblo…
vakovalskii Oct 2, 2026
9775a02
Merge remote-tracking branch 'origin/claude/novak-accessibility-pass'…
vakovalskii Oct 2, 2026
5633f8c
Merge remote-tracking branch 'origin/claude/novak-running-agents-unif…
vakovalskii Oct 2, 2026
66c997c
Merge remote-tracking branch 'origin/perf/unblock-active-poll' into r…
vakovalskii Oct 2, 2026
ea64cf3
Merge branch 'pr-290' into rc/7.18.0
vakovalskii Oct 2, 2026
f94232e
chore(release): fold #289 #290 + review fixes into 7.18.0
vakovalskii Oct 2, 2026
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 CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ docs/
- **Crash-safety** — every HTTP route dispatch is wrapped in try/catch → 500 (one bad session never takes down the server); `findSessionFile` looks up its index with `Object.prototype.hasOwnProperty.call(...)` to avoid prototype-pollution DoS; delete / bulk-delete validate `SAFE_SESSION_ID`
- **Desktop app is a thin shell** — `desktop/main.js` spawns the *unmodified* server as a Node child and points a `BrowserWindow` at it. Keep the server desktop-agnostic; desktop-only capabilities are exposed through `preload.js` (`window.codbashDesktop`) and detected at runtime in the frontend (e.g. the native folder picker is only wired up when `window.codbashDesktop.pickFolder` exists)
- **View-aware chrome** — `render()` stamps `document.body` with `data-view`; the session toolbar is hidden in Overview/Workspace via `body[data-view="workspace"|"overview"] .toolbar { display:none }`
- **Running-agents sidebar tree** (Workspace) is built from `activeSessions` grouped by real `cwd` and labeled by agent — do NOT reconstruct it from static project config. It lists agents in **external native terminals** only: `getActiveSessions()` tags each with `local` (true = descends from a codbash browser-pty pane, false = external), and the tree shows `!local` — codbash's own panes are already visible as tabs. Clicking a row raises that real terminal window via `POST /api/focus` (`focusTerminalByPid`); it must NEVER spawn a blank in-app terminal (an empty shell isn't the agent, and `claude --continue` on a live agent would fork a second instance). A still-running agent's PTY cannot be mirrored/attached from the browser terminal — focus the real window instead. See `docs/design/running-agents-external.md`.
- **Running-agents sidebar tree** (persistent, in the main sidebar — not just Workspace) is built from `activeSessions`, a live `ps`-scan-backed map (`GET /api/active`, polled every 5s), so a truly-exited agent simply isn't in it — no "ghost" filtering needed. It shows **every** currently-running agent, both inside codbash and in external native terminals, as a **3-level tree**: `_wsRunningTree(mode)` (`workspace.js`) nests project → agent → sessions (`mode:'project'`, default) or the mirror agent → project → sessions (`mode:'agent'`) — user-toggleable via the compact segmented control in the tree header, persisted to `localStorage['codedash-running-group']`. A project+agent pair with exactly one live session collapses to a single leaf row (no redundant 1-child nesting); 2+ sessions get one leaf row each, labeled via `_wsSessionLeafLabel` (pane name if local and matched, else session-id prefix, else pid) so same-project/same-agent sessions read as distinct. Top-level (L1) groups are an **accordion, collapsed by default** — `_wsRunExpanded` (in-memory, resets on reload) tracks open/closed per `mode|groupKey`; `_wsToggleRunGroup` flips a `collapsed` class directly on the group's wrapper DOM node rather than forcing a full rebuild, and a later rebuild re-reads `_wsRunExpanded` so open/closed choices survive it. Because the L1 header's click means "toggle", it does NOT jump to a session — that action lives entirely on leaf rows (`.ws-run-l2.ws-run-leaf` / `.ws-run-l3`). `getActiveSessions()` tags each agent with `local` (true = descends from a codbash browser-pty pane, false = external); rows are colored by where they run (blue = inside codbash, orange = external; dimmed = idle, not gone). Clicking a leaf dispatches on `local`: local → `jumpToWorkspacePane` (found by matching `cwd` against live panes); external → `POST /api/focus` (`focusTerminalByPid`) to raise the real window. It must NEVER spawn a blank in-app terminal as a stand-in (an empty shell isn't the agent, and `claude --continue` on a live agent would fork a second instance). See `docs/design/running-agents-external.md`.
- **Saved layouts round-trip the full pane** — `sanitizePane` preserves `cmd` + `prefill` + `cwd` (not just `cmd`); dropping any of these silently loses the user's launch command on restore
- **No `window.prompt` in Electron** — use `codbashPrompt()` (app.js) for any text input; the native prompt is a no-op in the desktop shell
- **Two update paths, mutually exclusive** — the npm CLI self-updates via `POST /api/update` (`npm i -g codbash-app@latest` + restart). The **desktop app updates in-place via `electron-updater`** (download-on-click → restart, driven by the frontend banner over `window.codbashDesktop.updater` IPC and `main.js`). `desktop/main.js` sets `CODBASH_DESKTOP=1` so the server **refuses `/api/update` (400)** — running `npm i -g` inside the signed, read-only app bundle would update an unrelated global copy and the restart would land back on the bundled old version. macOS in-place update needs the **`.zip` target + `latest-mac.yml`** (Squirrel.Mac can't apply a DMG) and a signed build; on failure the banner falls back to opening the releases page (`codbash:open-releases`). See `desktop/RELEASE.md` §4.
Expand Down
17 changes: 12 additions & 5 deletions bin/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -170,10 +170,14 @@ switch (command) {
process.exit(1);
}
const sessions = loadSessions();
const results = searchFullText(query, sessions);
if (results.length === 0) {
console.log(`\n No results for "${query}"\n`);
} else {
// searchFullText is async (the index build yields between chunks). This
// switch is top-level CJS, so no top-level await — run it in an IIFE.
(async () => {
const results = await searchFullText(query, sessions);
if (results.length === 0) {
console.log(`\n No results for "${query}"\n`);
return;
}
console.log(`\n \x1b[36m\x1b[1m${results.length} sessions\x1b[0m matching "${query}"\n`);
for (const r of results.slice(0, 15)) {
const s = sessions.find(x => x.id === r.sessionId);
Expand All @@ -188,7 +192,10 @@ switch (command) {
}
if (results.length > 15) console.log(`\n \x1b[2m... and ${results.length - 15} more\x1b[0m`);
console.log('');
}
})().catch(e => {
console.error(` Search failed: ${e.message}`);
process.exit(1);
});
break;
}

Expand Down
83 changes: 38 additions & 45 deletions desktop/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,10 @@ const { spawn } = require('child_process');
const http = require('http');
const net = require('net');
const path = require('path');
const fs = require('fs');
const os = require('os');
const { execFileSync } = require('child_process');
const { resolveNodeBin } = require('./node-resolve.js');

let serverProc = null;
let serverSpawnFailed = false; // set by the spawn 'error' handler; suppresses the later waitForServer dialog
let win = null;
let serverPort = 0;
const SMOKE = !!process.env.CODBASH_SMOKE; // launch, verify, auto-quit (CI/local test)
Expand Down Expand Up @@ -66,49 +65,17 @@ function resolveServerEntry() {
return app.isPackaged ? packaged : dev;
}

// The Node binary used to run the server. We deliberately avoid Electron's own
// Node (ELECTRON_RUN_AS_NODE) because its ABI differs from the prebuilt
// node-pty.
//
// A Finder/`open`-launched macOS app inherits only a minimal PATH
// (/usr/bin:/bin:/usr/sbin:/sbin), so bare "node" (installed via nvm, Homebrew,
// conda, etc.) usually isn't found. We therefore resolve an absolute path:
// explicit override → bundled node → common install locations → the user's
// login shell → bare "node" as a last resort.
function resolveNodeBin() {
if (process.env.CODBASH_NODE) return process.env.CODBASH_NODE;

const bundled = path.join(process.resourcesPath || '', process.platform === 'win32' ? 'node.exe' : 'node');
try { if (app.isPackaged && fs.existsSync(bundled)) return bundled; } catch (_e) {}

if (process.platform === 'win32') return 'node.exe';

const home = os.homedir();
const candidates = [
'/opt/homebrew/bin/node',
'/usr/local/bin/node',
'/usr/bin/node',
path.join(home, '.local/bin/node'),
path.join(home, '.volta/bin/node'),
];
for (const c of candidates) {
try { if (fs.existsSync(c)) return c; } catch (_e) {}
}

// Ask the user's login shell (picks up nvm/conda/asdf shims a plain env misses).
try {
const shell = process.env.SHELL || '/bin/zsh';
const out = execFileSync(shell, ['-lic', 'command -v node'], { encoding: 'utf8', timeout: 6000 });
const p = out.split('\n').map(function (s) { return s.trim(); }).filter(Boolean).pop();
if (p && fs.existsSync(p)) return p;
} catch (_e) {}

return 'node';
}

function startServer(port) {
const entry = resolveServerEntry();
const nodeBin = resolveNodeBin();
// We deliberately avoid Electron's own Node (ELECTRON_RUN_AS_NODE) because
// its ABI differs from the prebuilt node-pty. See node-resolve.js for the
// resolution order and the rc-noise-proof login-shell probe.
const nodeBin = resolveNodeBin({
env: process.env,
platform: process.platform,
resourcesPath: process.resourcesPath,
isPackaged: app.isPackaged,
});
serverProc = spawn(nodeBin, [entry, 'run', '--port=' + port, '--host=127.0.0.1', '--no-browser'], {
// CODBASH_DESKTOP=1 tells the server it runs inside the Electron shell, so the
// web self-update route (`POST /api/update` → `npm i -g`) refuses: it would
Expand All @@ -119,6 +86,27 @@ function startServer(port) {
});
serverProc.stdout.on('data', function (d) { process.stdout.write('[codbash] ' + d); });
serverProc.stderr.on('data', function (d) { process.stderr.write('[codbash] ' + d); });
// spawn failures (ENOENT when no node binary was found) emit 'error', not
// 'exit' — without this handler they surface as an Uncaught Exception dialog.
serverProc.on('error', function (err) {
serverProc = null;
serverSpawnFailed = true;
if (app.isQuitting) return;
if (SMOKE) {
// A silent green smoke run for a broken launch would be worse than the
// crash — fail loudly and nonzero.
process.stderr.write('[desktop] SMOKE FAIL — server spawn error: ' + err.message + '\n');
app.exit(1);
return;
}
dialog.showErrorBox(
'codbash could not start',
'Failed to launch the codbash server with "' + nodeBin + '": ' + err.message +
'\n\nInstall Node.js 18+ (https://nodejs.org), or point the CODBASH_NODE ' +
'environment variable at your node binary, then relaunch codbash.'
);
app.quit();
});
serverProc.on('exit', function (code) {
serverProc = null;
if (!app.isQuitting && !SMOKE) {
Expand Down Expand Up @@ -367,7 +355,12 @@ app.whenReady().then(async function () {
await createWindow();
initAutoUpdater();
} catch (e) {
dialog.showErrorBox('codbash failed to start', String((e && e.message) || e));
// The spawn 'error' handler already showed a specific dialog (missing node
// binary) — a second "did not become ready" dialog would point the user at
// a nonexistent server problem. Same for a quit already in progress.
if (!app.isQuitting && !serverSpawnFailed) {
dialog.showErrorBox('codbash failed to start', String((e && e.message) || e));
}
app.isQuitting = true;
app.quit();
return;
Expand Down
121 changes: 121 additions & 0 deletions desktop/node-resolve.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
// Locate the Node binary that runs the bundled codbash server.
//
// Extracted from main.js (which requires electron and can't be unit-tested
// under `node --test`). The resolution order is: explicit override → bundled
// node → common install locations → nvm-installed versions (the user's
// default alias first) → the user's login-shell PATH → bare "node" as a last
// resort.
//
// The login-shell step deliberately reuses src/shell-path.js rather than
// spawning its own probe: rc files freely print to stdout (oh-my-zsh
// warnings, iTerm2 OSC 1337 escape sequences without a trailing newline), so
// the old `$SHELL -lic 'command -v node'` + "take the last line" approach
// returned the node path glued to escape garbage and silently failed.
// shell-path.js already solves this with an \x01-sentinel probe, logs probe
// failures, and caches the ~1s interactive shell spawn on disk for a day —
// duplicating that machinery here would just drift.
'use strict';

const fs = require('fs');
const os = require('os');
const path = require('path');

// First PATH entry that holds a node binary ('' when none). Relative entries
// are skipped — a poisoned PATH must not make us spawn ./node.
function findNodeInPathString(pathString, exists) {
for (const dir of String(pathString || '').split(path.delimiter)) {
if (!dir || !path.isAbsolute(dir)) continue;
const p = path.join(dir, 'node');
try { if (exists(p)) return p; } catch (_e) {}
}
return '';
}

// Installed nvm nodes (~/.nvm/versions/node/vX.Y.Z/bin/node). The version the
// user actually runs — ~/.nvm/alias/default, exact ("v20.20.0") or prefix
// ("20", "20.3") form — comes first so we never silently override their
// default with a newer install (the prebuilt node-pty ABI may not match it);
// the rest follow highest-first (numeric compare — lexicographic would rank
// v8 above v20). Existence of bin/node is left to the caller's candidate
// loop, which owns the injected existsSync seam.
function listNvmNodes(home) {
const root = path.join(home, '.nvm', 'versions', 'node');
let entries;
try { entries = fs.readdirSync(root); } catch (_e) { return []; }

const versions = entries
.map((name) => ({ name, parts: /^v(\d+)\.(\d+)\.(\d+)$/.exec(name) }))
.filter((e) => e.parts)
.sort((a, b) =>
(b.parts[1] - a.parts[1]) || (b.parts[2] - a.parts[2]) || (b.parts[3] - a.parts[3]));

let alias = '';
try { alias = fs.readFileSync(path.join(home, '.nvm', 'alias', 'default'), 'utf8').trim(); } catch (_e) {}
if (alias) {
const want = alias.replace(/^v/, '');
// Highest install matching the alias exactly or by prefix ("20" → v20.20.0).
const i = versions.findIndex((e) => {
const have = e.name.slice(1);
return have === want || have.startsWith(want + '.');
});
if (i > 0) versions.unshift(versions.splice(i, 1)[0]);
}

return versions.map((e) => path.join(root, e.name, 'bin', 'node'));
}

// src/shell-path.js ships beside the server: repo layout in dev,
// extraResources (app/src) when packaged — mirroring resolveServerEntry in
// main.js. Electron can require() it from outside the asar.
function loadShellPathModule(o) {
const candidates = [
o.isPackaged && o.resourcesPath ? path.join(o.resourcesPath, 'app', 'src', 'shell-path.js') : null,
path.join(__dirname, '..', 'src', 'shell-path.js'),
].filter(Boolean);
for (const c of candidates) {
try { return require(c); } catch (_e) {}
}
return null;
}

// Options exist for tests only; production callers pass the real process/app
// values (see main.js). `existsSync` and `shellPathModule` default to the
// real thing.
function resolveNodeBin(o) {
o = o || {};
const env = o.env || process.env;
const platform = o.platform || process.platform;
const exists = o.existsSync || fs.existsSync;

if (env.CODBASH_NODE) return env.CODBASH_NODE;

const bundled = path.join(o.resourcesPath || '', platform === 'win32' ? 'node.exe' : 'node');
try { if (o.isPackaged && exists(bundled)) return bundled; } catch (_e) {}

if (platform === 'win32') return 'node.exe';

const home = o.home || os.homedir();
const candidates = [
'/opt/homebrew/bin/node',
'/usr/local/bin/node',
'/usr/bin/node',
path.join(home, '.local/bin/node'),
path.join(home, '.volta/bin/node'),
...listNvmNodes(home),
];
for (const c of candidates) {
try { if (exists(c)) return c; } catch (_e) {}
}

const shellPath = 'shellPathModule' in o ? o.shellPathModule : loadShellPathModule(o);
if (shellPath) {
try {
const found = findNodeInPathString(shellPath.captureLoginShellPath(), exists);
if (found) return found;
} catch (_e) {} // probe failures are logged inside shell-path.js
}

return 'node';
}

module.exports = { findNodeInPathString, listNvmNodes, resolveNodeBin };
3 changes: 2 additions & 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.17.0",
"version": "7.18.0",
"private": true,
"description": "Desktop shell (Electron) for codbash — wraps the codbash server in a native window.",
"main": "main.js",
Expand Down Expand Up @@ -32,6 +32,7 @@
"afterSign": "scripts/notarize.js",
"files": [
"main.js",
"node-resolve.js",
"preload.js"
],
"extraResources": [
Expand Down
Loading
Loading