diff --git a/CLAUDE.md b/CLAUDE.md index 653d6b4..7bf0ace 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 +- **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`. - **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 diff --git a/docs/design/running-agents-external.md b/docs/design/running-agents-external.md new file mode 100644 index 0000000..abf63b5 --- /dev/null +++ b/docs/design/running-agents-external.md @@ -0,0 +1,129 @@ +# Running Agents = agents in external terminals (focus, don't spawn) + +## Goal + +The Workspace "Running agents" sidebar should list agents actually running in +**external** native terminals (iTerm/Terminal.app/Warp/cmux…), and a click on a +row should **raise that real window** — not open a blank terminal in the folder. +codbash's own browser-pty panes are removed from this list — they are already +visible as Workspace tabs and in Overview → Terminals. + +## Motivation + +Release 7.15.0 introduced the rule "Running Agents lists only agents launched +from codbash" via `_scopeToCodbashAgents` (`src/data.js`) + `pty-registry`: the +list is scoped to agents whose process tree descends from a codbash pty. Side +effects: + +- An agent that **codbash itself** launched in iTerm via `POST /api/launch` + descends from iTerm, not from a codbash pty → it drops out of the list. + codbash opened the window and then pretends nothing is running. +- An agent launched **by hand** in iTerm in the project folder is invisible too. +- And clicking a running-agent row in the current code (`jumpToRunningAgent`), + when no live codbash pane exists for that cwd, calls `openInWorkspace(...)` — + opening a **blank shell** in the folder. A blank shell is not that agent. + +External agents are exactly the ones with no other representation in the UI — +those are the ones to show. + +## Data inventory + +- `getActiveSessions()` (`src/data.js`) scans `ps`, builds an array of live + agents `{pid, sessionId, cwd, kind, status, cpu, memoryMB, _sessionSource}` + and finally passes it through `_scopeToCodbashAgents(...)`, which **drops** the + external ones. +- `pty-registry.js` — a Set of live pty pids codbash itself spawned (one per + Workspace pane). Populated in `terminal.js`. +- Frontend: `activeSessions` (global, from `GET /api/active`) → used by + `_wsRunningByProject()` / `_wsRenderRunningTree()` (the tree), by Overview + (the "Active agents" count), and by the active-badge on session cards. + +## Component map (consumers of /api/active) + +| Consumer | File | Effect of the change | +|---|---|---| +| Running-agents tree | `workspace.js` | Shows external agents only (`!a.local`) | +| Overview "Active agents" stat | `overview.js` | Counts all live agents (external ones visible again) | +| Session-card active badge | `app.js` | More sessions may light up as active — more correct | + +## Data model (contract) + +`_scopeToCodbashAgents` → renamed to `_tagCodbashAgents`. Instead of a filter, a +**tag**: each entry gets a boolean field. + +``` +local: boolean // true if the process descends from a codbash pty (browser pane) + // false — an agent in an external native terminal +``` + +- **All** detected agents are returned (external ones are in the list again). +- Fail-open: if `ps` for the ppid map is unavailable, tag everyone as + `local:false` (showing "something is running" is more honest than hiding it). +- If the pty-registry is empty (no panes open), every live agent is external → + `local:false` for all. This is the desired behavior (previously the list was + empty). + +## Click behavior (state machine) + +A "Running agents" row is always an external agent (`!a.local`), so: + +``` +click(pid, sessionId, cwd) + → POST /api/focus { pid, sessionId } // raise the real window by PID + ok → window brought to front (reuses focusTerminalByPid) + focus failed → toast "Couldn't focus its terminal window" (NOT a blank shell) +``` + +Forbidden transition: opening a new blank terminal/pane as a "stand-in" for the +agent. + +## Touch points (files to change) + +- `src/data.js` — `_scopeToCodbashAgents` → `_tagCodbashAgents` (tag instead of + filter), call site at line 6062. +- `src/frontend/workspace.js` — `_wsRunningByProject` (filter `!a.local`), + `_wsRenderRunningTree` (forward `pid`, "native terminal" heading/tooltip), + `jumpToRunningAgent` (focus by PID instead of openInWorkspace). +- `src/frontend/overview.js` — no changes (the count simply sees external agents + again). + +## Risks + +| Risk | Handling | +|---|---| +| Noise: "every claude on the machine" back in the list | Grouped by project + tagged; the user explicitly wants this. This reverses 7.15.0 (owner-approved PR) | +| `focusTerminalByPid` doesn't know the agent's app (not iTerm/Terminal/Warp/cmux) | Returns an error → toast, not a blank shell. `addressed_in: jumpToRunningAgent focus-then-toast` | +| Immutability: don't mutate input objects | `map` → new objects `{...a, local}` | +| Prototype pollution via pid/ppid | pids coerced to Number; ppid map built from `ps`; keys are not user input | +| Duplicate external+codbash entry for one agent | Dedup already happens in `getActiveSessions` before tagging; the tag creates no new entries | + +## Review findings — triage (code + security review) + +| Finding | Severity | Resolution | +|---|---|---| +| `jumpToRunningAgent`: `cwd`/`kind` now unused | LOW | Fixed — comment noting the vestigial params | +| `_wsPidArg` would round a float via `parseInt` | LOW | Fixed — `Number.isInteger` (parity with the server) | +| Windows: no ppid scan → everyone `local:false`, so codbash panes also land in the external tree (redundant with their tab) | LOW | `accepted_because:` pre-existing win32 limitation (the old code also didn't filter on win32); macOS/Linux is codbash's primary platform; cosmetic redundancy, not a bug | +| Dropping the codbash-only scope → `/api/active` again returns cwd/pid/sessionId for **all** agents of all users on the host; wider over LAN with `--host=0.0.0.0` | LOW | `addressed_in:` warning in the LAN banner (`server.js` listen). `accepted_because:` default is loopback (same-machine); LAN-bind is a deliberate opt-in advanced flag; `ps` was always system-wide. Narrowing the scope by bind address is a follow-up, not bundled into this PR | +| `/api/focus` (and other POSTs) have no Origin/CSRF check; `JSON.parse` regardless of Content-Type | INFO | `deferred_to:` a separate PR — pre-existing (route unchanged), cross-cutting (all state-changing POSTs). Mirror the WS-upgrade same-origin pattern | +| INFO log of pid/sessionId/cwd in `/api/focus`/`ACTIVE` | INFO | No action — no secrets | + +## UX & Accessibility + +The list is not a form; it is a micro-interaction (clicks on list rows). + +**Required UI states:** +- [x] Empty — no external agents → the "Running agents" block is hidden (as today). +- [x] Success — click raised the window; the OS app switch is the feedback (the + window coming forward). No extra toast needed on success. +- [x] Error — focus failed → `showToast(...)` with a clear message. +- [ ] Loading — N/A: focus is instant (osascript), a spinner would be noise. + +**Keyboard:** rows are clickable `div`s. The existing markup is not +keyboard-focusable; this change does not make it worse (scope: introduce no +regression; a full keyboard-navigable list is a follow-up, `deferred_to: issue`). + +**Screen reader:** keep the `title` on rows; add a clear label that a click +raises the native terminal window. + +**Touch targets:** tree rows keep their existing height (unchanged). diff --git a/specs/running-agents-external.feature b/specs/running-agents-external.feature new file mode 100644 index 0000000..49cb04e --- /dev/null +++ b/specs/running-agents-external.feature @@ -0,0 +1,58 @@ +Feature: Running agents lists external terminals and focuses their real window + + The Workspace "Running agents" tree shows agents that are actually running in + external native terminals (iTerm, Terminal.app, Warp, cmux). Codbash's own + browser-pty panes are excluded — they are already visible as tabs. Clicking a + running agent raises its real terminal window; it never spawns a blank shell. + + Background: + Given codbash is running with the Workspace view available + + # 1. Happy path — external agent shows and focuses its window + Scenario: An agent launched in iTerm appears and clicking focuses the window + Given "claude" is running in iTerm in "/Users/me/proj-a" + And no codbash browser-pty pane descends from that process + When /api/active is polled + Then the agent is returned with local=false + And it appears under project "proj-a" in the Running agents tree + When the user clicks that running-agent row + Then a POST /api/focus is sent with the agent's pid + And no new terminal pane is opened + + # 2. Empty state — nothing external running + Scenario: No external agents running + Given the only live agents descend from codbash browser-pty panes + When /api/active is polled + Then every returned agent has local=true + And the Running agents tree is hidden + + # 3. Negative / error — focus fails + Scenario: Focusing an external agent's window fails + Given an external agent whose host terminal app cannot be focused by pid + When the user clicks its running-agent row + And the /api/focus call returns an error + Then a toast explains the window could not be focused + And no blank terminal is spawned as a fallback + + # 4. Edge — codbash-launched-into-native-terminal is still external + Scenario: Agent launched by codbash into a native terminal counts as external + Given codbash launched "codex" into Terminal.app via /api/launch + And that process descends from Terminal.app, not from a codbash pty + When /api/active is polled + Then the agent is returned with local=false + And it appears in the Running agents tree + + # 5. Edge — codbash browser-pane agent is excluded from the tree + Scenario: An agent running inside a codbash Workspace pane is not in the tree + Given "claude" is running inside a codbash browser-pty pane in "/Users/me/proj-b" + When /api/active is polled + Then that agent is returned with local=true + And it does NOT appear in the Running agents tree + But it remains visible as its Workspace pane + + # 6. Edge — fail-open when ancestry cannot be resolved + Scenario: ps ancestry lookup is unavailable + Given the ppid process listing cannot be read + When /api/active is tagged + Then every agent is returned with local=false + And all live agents remain visible rather than being hidden diff --git a/src/data.js b/src/data.js index 987f452..5e984e9 100644 --- a/src/data.js +++ b/src/data.js @@ -6056,23 +6056,40 @@ async function getActiveSessions() { if (entryScore > existingScore) deduped.set(key, entry); } - // 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())); + // Tag each live agent with `local`: true when its process tree descends from + // a codbash browser-pty pane, false when it runs in an EXTERNAL native + // terminal (iTerm/Terminal.app/Warp/cmux…). Nothing is dropped — the Workspace + // "Running agents" tree shows the external ones (they have no other UI home), + // while codbash panes are already visible as tabs. See + // docs/design/running-agents-external.md. + return await _tagCodbashAgents(Array.from(deduped.values())); +} + +// Pure ancestry tagging: return a new array where each agent carries +// `local=true` iff walking its pid→ppid chain reaches a live codbash-pty pid +// in `live` (Set); otherwise `local=false` (external terminal). Never +// mutates inputs; the walk is depth-bounded so a ppid cycle can't hang. +function _tagLocalAgents(active, live, ppidOf) { + return active.map(a => { + let pid = a.pid, depth = 0, local = false; + while (pid && depth < 16) { + if (live.has(pid)) { local = true; break; } + pid = ppidOf.get(pid); + depth++; + } + return { ...a, local }; + }); } -// 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) { +// Async wrapper: builds the live-pty set + pid→ppid map (one off-loop `ps`), +// then tags. Fails OPEN by marking every agent external (local=false) so a +// transient `ps` error surfaces agents rather than hiding them. +async function _tagCodbashAgents(active) { + const asExternal = () => active.map(a => ({ ...a, local: false })); 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); + try { ptyRegistry = require('./pty-registry'); } catch { return asExternal(); } + if (process.platform === 'win32') return asExternal(); // no ppid scan here + const live = new Set(ptyRegistry.all()); // 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. @@ -6083,17 +6100,9 @@ async function _scopeToCodbashAgents(active) { 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 + } catch { return asExternal(); } // 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; - }); + return _tagLocalAgents(active, live, ppidOf); } // ── Leaderboard stats ───────────────────────────────────── @@ -6476,6 +6485,7 @@ module.exports = { HISTORY_FILE, PROJECTS_DIR, __test: { + _tagLocalAgents, parseWslDistroList, getWslDistroList, getRunningWslDistroSet, diff --git a/src/frontend/workspace.js b/src/frontend/workspace.js index 8ab516c..af6e03a 100644 --- a/src/frontend/workspace.js +++ b/src/frontend/workspace.js @@ -271,19 +271,22 @@ function _wsToolLabel(kind) { return _WS_TOOL_LABELS[kind] || (kind.charAt(0).toUpperCase() + kind.slice(1)); } -// Group *running agents* (from the live /api/active map, not Workspace panes) -// by their real project folder — so agents launched here, in another terminal, -// or that cd'd elsewhere are all detected correctly. Skips the home dir and -// entries with no cwd. Used by the sidebar running-agents tree. +// Group *running agents in EXTERNAL native terminals* (from the live /api/active +// map, not Workspace panes) by their real project folder. Agents running inside +// a codbash browser-pty pane (tagged `local` server-side) are excluded — they +// are already visible as Workspace tabs, so surfacing them here too is just +// noise. See docs/design/running-agents-external.md. Used by the sidebar tree. function _wsRunningByProject() { var map = (typeof activeSessions === 'object' && activeSessions) || {}; var groups = {}, order = []; Object.keys(map).forEach(function (k) { var a = map[k]; + // Skip codbash's own panes: this tree is for agents in external terminals, + // the ones that have no other UI home. (Undefined `local` — an older server + // payload — is treated as external so nothing silently disappears.) + if (a && a.local === true) return; var cwd = (a && a.cwd) || ''; - // Only skip entries with no cwd (can't group or jump). Home-dir agents used - // to be skipped as noise, but RUNNING AGENTS is now scoped server-side to - // codbash-launched agents, so a home-dir agent is legitimate — show it. + // Only skip entries with no cwd (can't group or focus a window). if (!cwd) return; var isHome = /^(\/Users\/[^/]+|\/home\/[^/]+|\/root)\/?$/.test(cwd); var key = cwd; // group by full path so two same-named folders don't merge @@ -296,29 +299,45 @@ function _wsRunningByProject() { return order.map(function (k) { return groups[k]; }); } -// Find a live Workspace pane sitting in `cwd` (so clicking a running agent can -// jump straight to its terminal when we have one). Returns {tab,pane} or null. -function _wsPaneForCwd(cwd) { - var hit = null; - _wsAllPanes().forEach(function (x) { - if (hit) return; - var p = x.pane; - if (!p.sock || p.exited || p.sock.readyState !== 1) return; - if ((p.cwd || p.wantCwd) === cwd) hit = x; - }); - return hit; -} - -// Click a running-agent row: jump to its Workspace pane if one is open here, -// otherwise open a plain terminal in the project folder. The agent is ALREADY -// running, so we must NOT prefill a resume command — that would spawn a second -// instance. (sessionId/kind are accepted for signature stability but unused.) -function jumpToRunningAgent(cwd, sessionId, kind) { - var hit = _wsPaneForCwd(cwd); - if (hit) { jumpToWorkspacePane(hit.tab.id, hit.pane.id); return; } - if (cwd && typeof openInWorkspace === 'function') { - openInWorkspace({ name: _wsProjectBasename(cwd), cwd: cwd }); +// Sanitize a pid for an inline onclick arg: a positive integer, else 0. Mirrors +// the server's /api/focus check (Number.isInteger(pid) > 0) — a truncated float +// would be rejected there anyway, so we reject it here too rather than round it. +function _wsPidArg(pid) { + var n = typeof pid === 'number' ? pid : parseInt(pid, 10); + return (Number.isInteger(n) && n > 0) ? String(n) : '0'; +} + +// Click a running-agent row. These rows are agents in EXTERNAL native terminals +// (codbash's own panes are filtered out of the tree), so the honest action is to +// raise that real terminal window by PID — reusing /api/focus (focusTerminalByPid, +// same path the session cards' "Focus Terminal" uses). We deliberately do NOT +// open a blank in-app terminal as a stand-in: an empty shell is not the agent, +// and resuming (claude --continue) would spawn a SECOND instance of a live agent. +// `cwd`/`kind` are kept in the signature for call-site stability but unused here +// (focus is keyed purely by pid); `sessionId` is forwarded for the server log. +function jumpToRunningAgent(cwd, sessionId, kind, pid) { + var n = typeof pid === 'number' ? pid : parseInt(pid, 10); + if (!Number.isInteger(n) || n <= 0) { + if (typeof showToast === 'function') showToast('No terminal window to focus for this agent.'); + return; } + fetch('/api/focus', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ pid: n, sessionId: sessionId || '' }) + }) + .then(function (r) { + return r.json().then(function (d) { return { ok: r.ok, d: d }; }, + function () { return { ok: r.ok, d: {} }; }); + }) + .then(function (res) { + if (!res.ok || !res.d || res.d.ok === false) { + if (typeof showToast === 'function') showToast('Couldn’t focus its terminal window.'); + } + }) + .catch(function () { + if (typeof showToast === 'function') showToast('Couldn’t focus its terminal window.'); + }); } // Render a compact tree at the bottom of the sidebar: each project folder with a @@ -337,17 +356,20 @@ function _wsRenderRunningTree() { _wsRunTreeSig = sig; if (!groups.length) { el.style.display = 'none'; el.innerHTML = ''; return; } - var html = '
Running agents
'; + var html = '
Running agents
'; groups.forEach(function (g) { + // The project header focuses the first agent's window (a reasonable default + // when a folder hosts several). + var headPid = _wsPidArg(g.items[0] && g.items[0].pid); html += '
' + + 'onclick="jumpToRunningAgent(' + _wsJsStr(g.cwd) + ',null,null,' + headPid + ')">' + '' + escHtml(g.name) + '' + '' + g.items.length + '
'; g.items.forEach(function (a) { var waiting = a.status === 'waiting'; html += '
' + + 'title="' + escHtml(_wsToolLabel(a.kind) + (waiting ? ' — idle' : ' — active') + ' — click to focus its terminal window') + '" ' + + 'onclick="jumpToRunningAgent(' + _wsJsStr(g.cwd) + ',' + _wsJsStr(a.sessionId || '') + ',' + _wsJsStr(a.kind || '') + ',' + _wsPidArg(a.pid) + ')">' + escHtml(_wsToolLabel(a.kind)) + '
'; }); }); diff --git a/src/server.js b/src/server.js index f8710db..6e07d65 100644 --- a/src/server.js +++ b/src/server.js @@ -1061,6 +1061,7 @@ function startServer(host, port, openBrowser = true) { console.log(` \x1b[2m${browserUrl}\x1b[0m`); if (host === '0.0.0.0' || host === '::' || host === '[::]') { console.log(' \x1b[2mListening on all interfaces\x1b[0m'); + console.log(' \x1b[33mNote: Running Agents / /api/active lists every agent process on this host (all users) — reachable from your LAN in this mode.\x1b[0m'); } console.log(' \x1b[2mPress Ctrl+C to stop\x1b[0m'); console.log(''); diff --git a/test/running-agents-external.test.js b/test/running-agents-external.test.js new file mode 100644 index 0000000..dd6befe --- /dev/null +++ b/test/running-agents-external.test.js @@ -0,0 +1,119 @@ +'use strict'; + +// Running-agents = external terminals. See docs/design/running-agents-external.md +// and specs/running-agents-external.feature. +// +// Core logic under test: _tagLocalAgents — pure ancestry tagging that marks each +// live agent local=true when its process tree reaches a codbash-pty pid, else +// local=false (an agent running in an external native terminal). The Running +// agents tree shows only the external ones and clicking focuses their real +// window (never spawns a blank terminal). + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +const data = require('../src/data.js').__test; + +// ── _tagLocalAgents (pure) ──────────────────────────────────────────────── + +test('external agent (no codbash-pty ancestor) is tagged local=false', () => { + const live = new Set([100]); // codbash pane shell pid + const ppidOf = new Map([ + [200, 150], // agent 200 → iTerm 150 + [150, 1], // iTerm 150 → launchd + ]); + const out = data._tagLocalAgents([{ pid: 200, cwd: '/p/a' }], live, ppidOf); + assert.equal(out.length, 1); + assert.equal(out[0].local, false); +}); + +test('agent descending from a codbash pty is tagged local=true', () => { + const live = new Set([100]); + const ppidOf = new Map([ + [300, 100], // agent 300 → codbash pane shell 100 (live) + ]); + const out = data._tagLocalAgents([{ pid: 300, cwd: '/p/b' }], live, ppidOf); + assert.equal(out[0].local, true); +}); + +test('deep ancestry (grandchild of a codbash pty) is still local=true', () => { + const live = new Set([100]); + const ppidOf = new Map([ + [400, 350], + [350, 100], // → codbash pane shell + ]); + const out = data._tagLocalAgents([{ pid: 400 }], live, ppidOf); + assert.equal(out[0].local, true); +}); + +test('empty codbash-pty registry → every agent is external (local=false)', () => { + const live = new Set(); + const ppidOf = new Map([[500, 1]]); + const out = data._tagLocalAgents([{ pid: 500 }, { pid: 600 }], live, ppidOf); + assert.deepEqual(out.map(a => a.local), [false, false]); + assert.equal(out.length, 2, 'all agents are returned, none dropped'); +}); + +test('_tagLocalAgents does not mutate its input objects', () => { + const input = [{ pid: 700, cwd: '/x' }]; + const out = data._tagLocalAgents(input, new Set([700]), new Map()); + assert.equal(Object.prototype.hasOwnProperty.call(input[0], 'local'), false, + 'input object must stay untouched (immutability)'); + assert.equal(out[0].local, true); + assert.notEqual(out[0], input[0], 'a new object is returned'); +}); + +test('ancestry walk is bounded (a ppid cycle cannot hang)', () => { + const live = new Set([100]); + const ppidOf = new Map([[800, 900], [900, 800]]); // cycle, never reaches 100 + const out = data._tagLocalAgents([{ pid: 800 }], live, ppidOf); + assert.equal(out[0].local, false); +}); + +test('all live agents are preserved (external + local together)', () => { + const live = new Set([100]); + const ppidOf = new Map([[300, 100], [200, 150], [150, 1]]); + const out = data._tagLocalAgents( + [{ pid: 300 }, { pid: 200 }], live, ppidOf); + assert.equal(out.length, 2); + assert.deepEqual(out.map(a => a.local), [true, false]); +}); + +// ── Frontend wiring: focus real window, never a blank terminal ───────────── + +function wsSource() { + return fs.readFileSync(path.join(__dirname, '..', 'src', 'frontend', 'workspace.js'), 'utf8'); +} + +test('running-agents tree excludes codbash-pane agents (local=true)', () => { + const src = wsSource(); + const fn = src.match(/function _wsRunningByProject\(\)[\s\S]*?\n\}/); + assert.ok(fn, '_wsRunningByProject should exist'); + assert.match(fn[0], /a\.local/, 'must filter out local (codbash-pane) agents'); +}); + +test('clicking a running agent focuses its window via /api/focus', () => { + const src = wsSource(); + const fn = src.match(/function jumpToRunningAgent\([\s\S]*?\n\}/); + assert.ok(fn, 'jumpToRunningAgent should exist'); + assert.match(fn[0], /\/api\/focus/, 'must POST to /api/focus'); +}); + +test('clicking a running agent never opens a blank terminal', () => { + const src = wsSource(); + const fn = src.match(/function jumpToRunningAgent\([\s\S]*?\n\}/); + assert.ok(fn, 'jumpToRunningAgent should exist'); + assert.doesNotMatch(fn[0], /openInWorkspace/, + 'must NOT spawn a blank terminal as a stand-in for the running agent'); +}); + +test('the pid is passed to jumpToRunningAgent as a numeric argument', () => { + const src = wsSource(); + // The tree rows must forward a validated numeric pid (server /api/focus + // requires Number.isInteger(pid)). We assert jumpToRunningAgent accepts pid. + const fn = src.match(/function jumpToRunningAgent\(([^)]*)\)/); + assert.ok(fn, 'jumpToRunningAgent should exist'); + assert.match(fn[1], /pid/, 'signature should accept a pid parameter'); +});