diff --git a/CLAUDE.md b/CLAUDE.md
index 8046a8f..156602e 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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.
diff --git a/bin/cli.js b/bin/cli.js
index 99c8971..33a0573 100755
--- a/bin/cli.js
+++ b/bin/cli.js
@@ -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);
@@ -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;
}
diff --git a/desktop/package.json b/desktop/package.json
index fa19b94..b5cbd2f 100644
--- a/desktop/package.json
+++ b/desktop/package.json
@@ -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",
diff --git a/docs/design/running-agents-external.md b/docs/design/running-agents-external.md
index abf63b5..a1a4004 100644
--- a/docs/design/running-agents-external.md
+++ b/docs/design/running-agents-external.md
@@ -1,5 +1,13 @@
# Running Agents = agents in external terminals (focus, don't spawn)
+> **Amendment (unified tree):** the sidebar tree now shows local (in-codbash)
+> AND external agents together, colored differently, with a project/agent
+> grouping toggle. The `local` tag and the "focus by PID, never spawn a blank
+> terminal" rule below are unchanged for external agents — only the "external
+> only" scoping was reversed. See "Unified local + external tree" at the
+> bottom of this doc for the current design; the sections above it describe
+> the external-focus mechanics, which still apply verbatim.
+
## Goal
The Workspace "Running agents" sidebar should list agents actually running in
@@ -127,3 +135,89 @@ regression; a full keyboard-navigable list is a follow-up, `deferred_to: issue`)
raises the native terminal window.
**Touch targets:** tree rows keep their existing height (unchanged).
+
+## Unified local + external tree
+
+### Goal
+
+Users report the "external-only" tree was confusing: it silently hid agents
+running in codbash's own panes, so a user watching a project with both an
+in-app Claude session and an iTerm one saw only half the picture. Merge them
+into one tree, colored by where each agent runs, and let the user pick whether
+the tree groups by project (default) or by agent kind.
+
+A first pass grouped project/agent as a flat 2-level list — one header, then
+every session underneath as a same-labeled row ("Claude", "Claude", …). Users
+found that read as noise/duplication rather than a real hierarchy, so it grew
+a third level: outer group → inner group → individual sessions.
+
+### Data model
+
+No server change: `getActiveSessions()` already tags every live agent with
+`local`. The frontend just stops dropping `local:true` entries.
+
+`_wsRunningTree(mode)` (`workspace.js`) builds a 3-level tree:
+- `mode: 'project'` (default) — project → agent kind → sessions.
+- `mode: 'agent'` — agent kind → project → sessions (the mirror nesting, not
+ just a relabel — switching the toggle re-parents the whole tree).
+
+Built via a small generic `_wsGroupBy(items, keyFn)` applied twice (outer key,
+then inner key within each outer group). Each item carries `{agent, cwd,
+projName, kind}` so either grouping direction can label its rows correctly.
+
+A subgroup (project+agent pair) holding exactly one live session collapses
+its leaf row into the subgroup row itself — no redundant single-child row —
+and takes that session's true color/idle state directly (`.ws-run-leaf`).
+Once a subgroup holds 2+ sessions, it renders as a real subheader (neutral
+green dot, session count) with one leaf row per session underneath, each
+labeled by `_wsSessionLeafLabel` (a live pane's user-given name if local and
+matched, else the session id prefix — same convention as the session cards'
+"Resume last session (id12345)" — else a bare pid) so same-project,
+same-agent sessions read as distinct instead of repeating the same label.
+
+### Click dispatch
+
+`jumpToRunningAgent(cwd, sessionId, kind, pid, local)`:
+- `local === true` → `_wsFindLivePaneForCwd(cwd)` looks up a live (connected,
+ not exited) pane whose shell cwd matches, then `jumpToWorkspacePane(tabId,
+ paneId)`. If no pane is found (a stale tag right after a tab closed), falls
+ back to `setView('workspace')` rather than doing nothing.
+- `local === false` — unchanged: `POST /api/focus` by PID.
+
+### Grouping preference
+
+Stored in `localStorage['codedash-running-group']` (`'project'` | `'agent'`),
+not a server setting — it's a per-browser display toggle, not something that
+needs to sync across machines. A compact 2-button segmented control sits in
+the tree header (`.ws-run-mode`).
+
+### Accordion (L1 collapsed by default)
+
+Top-level groups start collapsed; clicking a project (project-mode) or agent
+kind (agent-mode) header expands it to reveal its running sessions. Expand
+state is in-memory only (`_wsRunExpanded`, resets on reload), keyed
+`mode|groupKey` so project-mode and agent-mode expand choices don't collide.
+Toggling flips a `collapsed` class directly on the group's wrapper DOM node
+(`.ws-run-group`) rather than forcing a full tree rebuild — cheap, and a later
+rebuild triggered by a real `activeSessions` change re-reads `_wsRunExpanded`
+so the user's open/closed choices survive it. Because the header's click now
+means "toggle", the "jump to a session" action moved entirely to leaf rows
+(`.ws-run-l2.ws-run-leaf` / `.ws-run-l3`) — there is no single-click shortcut
+from an L1 header to a specific session anymore, by design (an accordion
+header disclosing multiple children has no unambiguous single default action).
+
+### "No ghost sessions"
+
+The tree was never actually showing ghosts in the sense of dead processes —
+`activeSessions` is a live `ps` scan re-polled every 5s (1s while Workspace is
+open), so an exited process drops out on the next poll. The dimmed rows in the
+old design were `status: 'waiting'` (idle — low CPU, sleeping — but still a
+live process), which reads as "maybe gone" without a legend. The new tree
+keeps that dimming for idle but adds an explicit color legend (blue/orange)
+for *where* an agent runs, so dimmed no longer doubles as an ambiguous signal.
+
+### Color legend
+
+- Blue dot — running inside a codbash browser-pty pane (`local: true`).
+- Orange dot — running in an external native terminal (`local: false`).
+- Either dimmed to muted gray — idle (waiting for input), not exited.
diff --git a/package.json b/package.json
index 89b60fe..7455c27 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "codbash-app",
- "version": "7.17.0",
+ "version": "7.18.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",
diff --git a/src/changelog.js b/src/changelog.js
index f802bc1..e807375 100644
--- a/src/changelog.js
+++ b/src/changelog.js
@@ -1,6 +1,19 @@
'use strict';
const CHANGELOG = [
+ {
+ version: '7.18.0',
+ date: '2026-08-02',
+ title: 'Running agents: one tree for every agent — plus a Projects layout fix',
+ changes: [
+ 'Running agents now lists every agent that is actually running — the ones inside codbash\'s own terminal panes as well as the ones in external terminals (iTerm, Terminal.app, Warp, cmux). Previously an agent running in an in-app pane vanished from the tree, so a project with both showed only half the picture. Rows are colored by where they run and grouped project → agent → session (switchable to agent → project → session), with top-level groups collapsed by default. Thanks @NovakPAai',
+ 'Fixed the Projects launcher cards: an unbalanced tag left every button and dropdown stretched to full card width on its own row instead of a compact action row. Thanks @NovakPAai',
+ 'Accessibility: calendar days, session cards and Add-project tabs are now reachable and operable from the keyboard, dialogs close on Escape and return focus where you left it, and screen readers announce the selected tab and day correctly. Thanks @NovakPAai',
+ 'Security: your LLM API key is no longer sent back to the browser when Settings loads — the dashboard now shows only a masked hint of the stored key. Saving other settings keeps the key; clearing it is now an explicit action. Thanks @NovakPAai',
+ 'Performance: searching no longer freezes the app. Building the search index re-read every session in one synchronous burst — a multi-second stall that also froze the terminal; it now works in small chunks and streams large transcripts instead of loading them whole. Thanks @NovakPAai',
+ 'The GitHub connect dialog is now keyboard-navigable and announces failures instead of silently waiting: network errors during authorization used to be swallowed, leaving "Waiting for authorization…" on screen with nothing happening. Thanks @NovakPAai',
+ ],
+ },
{
version: '7.17.0',
date: '2026-07-30',
diff --git a/src/data.js b/src/data.js
index a74166a..9c00872 100644
--- a/src/data.js
+++ b/src/data.js
@@ -4839,118 +4839,167 @@ let searchIndex = null;
let searchIndexBuiltAt = 0;
const INDEX_TTL = 60000; // rebuild every 60s
-function buildSearchIndex(sessions) {
- const startMs = Date.now();
- const index = [];
+const SEARCH_SNIPPET_LEN = 500;
+
+// Formats whose messages come from a bespoke loader rather than the generic
+// JSONL reader. Each entry takes (sessionId, file) — loaders that don't need
+// the file simply ignore it — and returns `{ messages: [{role, content}] }`.
+// A lookup table instead of six near-identical if/else branches: the branches
+// differed only by loader name, so a fix to one (snippet length, the
+// isSystemMessage filter) silently missed the other five.
+const SEARCH_DETAIL_LOADERS = {
+ qwen: (id, file) => loadQwenDetail(id, file),
+ kilo: (id) => loadKiloCliDetail(id),
+ opencode: (id) => loadOpenCodeDetail(id),
+ kiro: (id) => loadKiroDetail(id),
+ 'kiro-cli': (id) => loadKiroCliDetail(id),
+ cursor: (id) => loadCursorDetail(id),
+ pi: (id, file) => loadPiDetail(id, file),
+ // Copilot Chat (VS Code JSON) and Copilot CLI need their own loaders; the
+ // generic JSONL branch would mis-parse them and index nothing.
+ copilot: (id) => loadCopilotCliDetail(id),
+ 'copilot-chat': (id) => loadCopilotDetail(id),
+};
- for (const s of sessions) {
- if (!s.has_detail) continue;
+// Indexable {role, content} pairs from a loader's messages: drop empties and
+// system noise, cap each message at SEARCH_SNIPPET_LEN. (isSystemMessage only
+// tests short prefixes/exact strings, so filtering before or after the slice
+// is equivalent — the pre-refactor qwen branch did it in the other order.)
+function _searchTextsFromMessages(messages) {
+ const texts = [];
+ for (const msg of (messages || [])) {
+ if (msg.content && !isSystemMessage(msg.content)) {
+ texts.push({ role: msg.role, content: msg.content.slice(0, SEARCH_SNIPPET_LEN) });
+ }
+ }
+ return texts;
+}
- const found = findSessionFile(s.id, s.project);
- if (!found) continue;
+// One JSONL line → an indexable {role, content} pair, or null. Shared by the
+// sync and streaming readers so the two can't drift.
+function _searchTextFromJsonlLine(line, format) {
+ try {
+ const entry = JSON.parse(line);
+ let role, content;
- try {
- if (found.format === 'qwen') {
- const detail = loadQwenDetail(s.id, found.file);
- const texts = (detail.messages || []).map(function(m) {
- return { role: m.role, content: (m.content || '').slice(0, 500) };
- }).filter(function(m) {
- return m.content && !isSystemMessage(m.content);
- });
- if (texts.length > 0) {
- const fullText = texts.map(t => t.content).join(' ').toLowerCase();
- index.push({ sessionId: s.id, texts, fullText });
- }
- continue;
- }
+ if (format === 'claude') {
+ if (entry.type !== 'user' && entry.type !== 'assistant') return null;
+ role = entry.type;
+ content = extractContent((entry.message || {}).content);
+ } else {
+ if (entry.type !== 'response_item' || !entry.payload) return null;
+ role = entry.payload.role;
+ if (role !== 'user' && role !== 'assistant') return null;
+ content = extractContent(entry.payload.content);
+ }
- const texts = [];
+ if (content && !isSystemMessage(content)) {
+ return { role, content: content.slice(0, SEARCH_SNIPPET_LEN) };
+ }
+ } catch {}
+ return null;
+}
- if (found.format === 'kilo') {
- const detail = loadKiloCliDetail(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 if (found.format === 'opencode') {
- const detail = loadOpenCodeDetail(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 if (found.format === 'kiro') {
- const detail = loadKiroDetail(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 if (found.format === 'kiro-cli') {
- const detail = loadKiroCliDetail(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 if (found.format === 'cursor') {
- const detail = loadCursorDetail(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 if (found.format === 'pi') {
- const detail = loadPiDetail(s.id, found.file);
- for (const msg of detail.messages) {
- if (msg.content && !isSystemMessage(msg.content)) {
- 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);
+// Indexable pairs straight from a raw JSONL file (claude / codex formats).
+function _searchTextsFromJsonl(file, format) {
+ const texts = [];
+ for (const line of readLines(file)) {
+ const t = _searchTextFromJsonlLine(line, format);
+ if (t) texts.push(t);
+ }
+ return texts;
+}
+
+// Above which a JSONL session is read as a stream instead of slurped whole.
+// Chunking the index *per session* still leaves one stall as long as the
+// biggest single session takes: real histories have a heavy tail (a median
+// session is ~0.1MB but codex transcripts run to tens of MB), and slurping one
+// of those via readLines — whole file into a string, split, filter — blocks
+// for seconds no matter how small the outer chunk is.
+const SEARCH_STREAM_THRESHOLD = 4 * 1024 * 1024;
+
+// Same as _searchTextsFromJsonl but reads line-by-line off a stream and yields
+// to the event loop periodically, so indexing one huge transcript can't freeze
+// the terminal WebSocket. Nothing is truncated — this is purely about *when*
+// the work happens, so search results are identical either way.
+async function _searchTextsFromJsonlStreaming(file, format) {
+ const readline = require('readline');
+ const texts = [];
+ let sinceYield = 0;
+ const rl = readline.createInterface({
+ input: fs.createReadStream(file, { encoding: 'utf8' }),
+ crlfDelay: Infinity,
+ });
+ try {
+ for await (const raw of rl) {
+ const line = raw.replace(/\r$/, '');
+ if (!line) continue;
+ const t = _searchTextFromJsonlLine(line, format);
+ if (t) texts.push(t);
+ if (++sinceYield >= 2000) {
+ sinceYield = 0;
+ await new Promise(r => setImmediate(r));
+ }
+ }
+ } finally {
+ rl.close();
+ }
+ return texts;
+}
- for (const line of lines) {
- try {
- const entry = JSON.parse(line);
- let role, content;
-
- if (found.format === 'claude') {
- if (entry.type !== 'user' && entry.type !== 'assistant') continue;
- role = entry.type;
- content = extractContent((entry.message || {}).content);
- } else {
- if (entry.type !== 'response_item' || !entry.payload) continue;
- role = entry.payload.role;
- if (role !== 'user' && role !== 'assistant') continue;
- content = extractContent(entry.payload.content);
- }
+// One session's index entry, or null when it has nothing searchable. Async so
+// an oversized JSONL transcript can be streamed with yields rather than
+// slurped in one blocking read.
+async function _indexSession(s) {
+ const found = findSessionFile(s.id, s.project);
+ if (!found) return null;
+ try {
+ const loader = SEARCH_DETAIL_LOADERS[found.format];
+ let texts;
+ if (loader) {
+ texts = _searchTextsFromMessages(loader(s.id, found.file).messages);
+ } else if (_fileSize(found.file) > SEARCH_STREAM_THRESHOLD) {
+ texts = await _searchTextsFromJsonlStreaming(found.file, found.format);
+ } else {
+ texts = _searchTextsFromJsonl(found.file, found.format);
+ }
+ if (texts.length === 0) return null;
+ // Pre-compute lowercase full text for fast matching
+ return { sessionId: s.id, texts, fullText: texts.map(t => t.content).join(' ').toLowerCase() };
+ } catch {
+ return null;
+ }
+}
- if (content && !isSystemMessage(content)) {
- texts.push({ role, content: content.slice(0, 500) });
- }
- } catch {}
- }
- }
+function _fileSize(file) {
+ try { return fs.statSync(file).size; } catch { return 0; }
+}
- if (texts.length > 0) {
- // Pre-compute lowercase full text for fast matching
- const fullText = texts.map(t => t.content).join(' ').toLowerCase();
- index.push({ sessionId: s.id, texts, fullText });
- }
- } catch {}
+// Build the index in small chunks, yielding to the event loop between them.
+//
+// Each session here means a findSessionFile() lookup plus a full detail
+// load (sync fs reads + JSON.parse per line). Doing all of them in one
+// synchronous tick froze the loop for seconds on a large history — stalling
+// every other request AND the terminal WebSocket data pump. Same chunk+yield
+// shape as _scheduleAnalyticsRecompute, for the same reason.
+async function buildSearchIndex(sessions) {
+ const startMs = Date.now();
+ const index = [];
+ // Small chunk on purpose: each item is a whole detail load, an order of
+ // magnitude heavier than the computeSessionCost calls the analytics job
+ // batches 80-at-a-time. Oversized JSONL sessions additionally yield from
+ // *inside* _indexSession (see SEARCH_STREAM_THRESHOLD).
+ const CHUNK = 8;
+
+ for (let i = 0; i < sessions.length; i += CHUNK) {
+ const chunk = sessions.slice(i, i + CHUNK);
+ for (const s of chunk) {
+ if (!s.has_detail) continue;
+ const entry = await _indexSession(s);
+ if (entry) index.push(entry);
+ }
+ // Yield so terminal output and other API calls stay responsive.
+ await new Promise(r => setImmediate(r));
}
const elapsed = Date.now() - startMs;
@@ -4958,19 +5007,36 @@ function buildSearchIndex(sessions) {
return index;
}
-function getSearchIndex(sessions) {
- const now = Date.now();
- if (!searchIndex || (now - searchIndexBuiltAt) > INDEX_TTL) {
- searchIndex = buildSearchIndex(sessions);
- searchIndexBuiltAt = now;
- }
+// In-flight build, so a burst of searches during a rebuild shares one job
+// instead of queueing N duplicate full-history scans.
+let _searchIndexBuilding = null;
+
+function _rebuildSearchIndex(sessions) {
+ if (_searchIndexBuilding) return _searchIndexBuilding;
+ _searchIndexBuilding = buildSearchIndex(sessions)
+ .then(index => {
+ searchIndex = index;
+ searchIndexBuiltAt = Date.now();
+ return index;
+ })
+ .finally(() => { _searchIndexBuilding = null; });
+ return _searchIndexBuilding;
+}
+
+// Stale-while-revalidate, mirroring getCostAnalytics: a >60s-old index is
+// still overwhelmingly accurate for search, so serve it instantly and refresh
+// in the background. Only the very first build (nothing cached yet) awaits —
+// and even that now yields between chunks rather than blocking outright.
+async function getSearchIndex(sessions) {
+ if (!searchIndex) return await _rebuildSearchIndex(sessions);
+ if ((Date.now() - searchIndexBuiltAt) > INDEX_TTL) _rebuildSearchIndex(sessions);
return searchIndex;
}
-function searchFullText(query, sessions) {
+async function searchFullText(query, sessions) {
if (!query || query.length < 2) return [];
const q = query.toLowerCase();
- const index = getSearchIndex(sessions);
+ const index = await getSearchIndex(sessions);
const results = [];
for (const entry of index) {
@@ -5954,10 +6020,16 @@ function findQwenSessionByPid(pid, cwd, allSessions) {
const byCwd = [];
try {
- const lsofOut = execSync(`lsof -a -p ${pid} -Fn 2>/dev/null`, {
+ // argv form, not a shell string: `pid` reaches here from parsed `ps`
+ // output so it's numeric today, but the shell-interpolated version was
+ // one refactor away from being a real injection. (The sibling lsof call
+ // in getActiveSessions already uses execFileSync — this was the outlier.)
+ // stderr is ignored via stdio instead of a `2>/dev/null` redirect, which
+ // needed a shell in the first place.
+ const lsofOut = execFileSync('lsof', ['-a', '-p', String(pid), '-Fn'], {
encoding: 'utf8',
timeout: 2000,
- stdio: ['pipe', 'pipe', 'pipe'],
+ stdio: ['pipe', 'pipe', 'ignore'],
});
for (const line of lsofOut.split('\n')) {
const match = line.match(/(\/.*\.qwen\/projects\/.*\/(?:chats|sessions)\/([0-9a-f-]{36})\.jsonl)$/i);
diff --git a/src/frontend/app.js b/src/frontend/app.js
index 9c65d1d..ecd0384 100644
--- a/src/frontend/app.js
+++ b/src/frontend/app.js
@@ -1006,7 +1006,16 @@ function loadLLMSettings() {
var k = document.getElementById('llmApiKey');
var m = document.getElementById('llmModel');
if (u) u.value = c.url || '';
- if (k) k.value = c.apiKey || '';
+ // The server never returns the raw key (only hasKey + a ••••1234 hint) —
+ // show the hint as a placeholder so the user can see a key is stored
+ // without the secret ever landing in the DOM. Leaving the field empty on
+ // save keeps the stored key; typing replaces it.
+ if (k) {
+ k.value = '';
+ k.placeholder = c.hasKey
+ ? c.keyHint + ' (saved — type to replace)'
+ : 'API Key (sk-...)';
+ }
if (m) m.value = c.model || '';
});
}
@@ -1014,6 +1023,8 @@ function loadLLMSettings() {
function saveLLMSettings() {
var config = {
url: document.getElementById('llmUrl').value.trim(),
+ // Empty field = keep the key already stored server-side (the input is
+ // never pre-filled with the secret, so empty is the common case).
apiKey: document.getElementById('llmApiKey').value.trim(),
model: document.getElementById('llmModel').value.trim(),
};
@@ -1021,8 +1032,12 @@ function saveLLMSettings() {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config),
- }).then(function() {
+ }).then(function(r) { return r.json(); }).then(function(d) {
+ if (d && d.ok === false) { showToast('Save failed: ' + (d.error || 'unknown error')); return; }
showToast('LLM settings saved');
+ loadLLMSettings(); // refresh the ••••hint placeholder after a key change
+ }).catch(function() {
+ showToast('Save failed — is the server running?');
});
}
@@ -1416,7 +1431,8 @@ function renderCard(s, idx) {
return '' + escHtml(t) + ' ×';
}).join('');
- var html = '
';
+ var cardLabel = escHtml(projName + ': ' + getSessionDisplayName(s).slice(0, 80) + ' — ' + toolLabel + ', ' + timeAgo(s.last_ts));
+ var html = '
';
html += '
';
html += '';
html += renderToolBadges(s.tool, s);
@@ -1519,7 +1535,8 @@ function renderListCard(s, idx) {
if (isSelected) classes += ' selected';
if (isFocused) classes += ' focused';
- var html = '
';
+ var listLabel = escHtml(projName + ': ' + getSessionDisplayName(s).slice(0, 80) + ' — ' + getToolLabel(s.tool, true) + ', ' + timeAgo(s.last_ts));
+ var html = '
';
html += renderToolBadges(s.tool, s);
if (showBadges && s.mcp_servers && s.mcp_servers.length > 0) {
s.mcp_servers.forEach(function(m) {
@@ -1652,6 +1669,21 @@ function onCardClick(id, event) {
}
}
+// Session cards (.card / .list-row / .qa-item) are plain divs with nested
+// interactive controls (checkbox, star, tag, launch buttons) — not real
+//