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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion 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
- **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

Expand Down
129 changes: 129 additions & 0 deletions docs/design/running-agents-external.md
Original file line number Diff line number Diff line change
@@ -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).
58 changes: 58 additions & 0 deletions specs/running-agents-external.feature
Original file line number Diff line number Diff line change
@@ -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
58 changes: 34 additions & 24 deletions src/data.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<number>); 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.
Expand All @@ -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 ─────────────────────────────────────
Expand Down Expand Up @@ -6476,6 +6485,7 @@ module.exports = {
HISTORY_FILE,
PROJECTS_DIR,
__test: {
_tagLocalAgents,
parseWslDistroList,
getWslDistroList,
getRunningWslDistroSet,
Expand Down
Loading
Loading