Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
5457a2b
docs(spec): design the background agents view
Sep 30, 2026
dac3ca6
docs(plan): implementation plan for the background agents view
Sep 30, 2026
11cf361
docs(spec): record the three deviations taken while planning the agen…
Sep 30, 2026
dcfa66f
(bg-agents): parse the daemon's job files and the CLI list into one r…
Sep 30, 2026
90c50e4
(cli-state): expose the session descriptors and their bg job id to th…
Sep 30, 2026
c7cea3d
(bg-agents): recognise the blocked job state the daemon reports
Sep 30, 2026
ee87545
(pty): detach an attach client with Ctrl+Z before ever killing it
Sep 30, 2026
43da67c
(bg-agents): keep a roster of the daemon's sessions from its files, r…
Sep 30, 2026
71d51c9
(bg-agents): drop a reconcile that outlives stop()
Sep 30, 2026
ad13234
(main): attach to a background session in a tab, detach on close, exp…
Sep 30, 2026
2abc7e6
(main): resubscribe the agents push on every fetch so a closed window…
Sep 30, 2026
c48eaa4
(sessions): attach to a session the daemon runs instead of asking to …
Sep 30, 2026
15329c1
(sessions): refuse a live bg session that has no job id instead of of…
Sep 30, 2026
e07ccca
(agents): a view of the daemon's background sessions, with attach, tr…
Sep 30, 2026
61d4cef
(agents): keep the view open across a restart and close it when anoth…
Sep 30, 2026
cc220c0
(sidebar): badge the sessions the daemon runs in the background
Sep 30, 2026
961f047
(agents): dispatch a new background agent from a dialog
Sep 30, 2026
0e4f61f
(docs): document the background agents view and its context
Sep 30, 2026
4f25358
(agents): escape quotes in the view's attribute values
Sep 30, 2026
04dba7b
(agents): tolerate login-shell noise around the CLI output
Sep 30, 2026
d576175
(sidebar): badge only the background jobs that are still running
Sep 30, 2026
34e96ed
(agents): keep the attach flag on a tab reattached after a reload
Sep 30, 2026
1bc8239
(agents): run claude rm from the home directory
Sep 30, 2026
06b5624
(agents): leave Enter on a focused button to that button in the dispa…
Sep 30, 2026
517ac1d
(docs): record the measured dispatch output and the review's fixes
Sep 30, 2026
f076620
(agents): parse the failed job state as a finished state
Oct 1, 2026
14167c9
(agents): group the agents list by state or by project
Oct 1, 2026
a2e28dc
(agents): group by state by default, with an emoji per state
Oct 1, 2026
9f76108
(agents): group the worktrees of one git project together, with workt…
Oct 1, 2026
e989608
(agents): fold a group from its header
Oct 1, 2026
e28c203
(docs): add a demo GIF of the Agents view
Oct 1, 2026
dc079a3
(agents): keep the New agent button reachable at narrow widths
Oct 1, 2026
e8e75f2
(agents): keep the Agents header clear of the window controls
Oct 1, 2026
164b711
(agents): let the New agent dialog scroll when the screen is short
Oct 1, 2026
b903305
(agents): attach on a double click of a live row
Oct 1, 2026
012ac35
Merge upstream/main into worktree-background-agents-view-impl
Oct 2, 2026
13bd51a
(agents): address review feedback
Oct 2, 2026
784e5b9
(agents): resolve claude.cmd on Windows, keep the daemon on a --bg ti…
Oct 2, 2026
e99a50f
Merge upstream/main into worktree-background-agents-view-impl
Oct 2, 2026
930a0e1
(agents): find the current npm claude shim, keep root resolution out …
Oct 2, 2026
b0fcdc8
Merge upstream/main into worktree-background-agents-view-impl
Oct 2, 2026
792481d
Merge remote-tracking branch 'upstream/main' into worktree-background…
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
1 change: 1 addition & 0 deletions .ai/contexts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ without re-reading `main.js`, now ~2600 LOC.
| Paths in terminal output becoming links: the matcher, the openability check, `path:line` | [terminal-path-links](terminal-path-links.md) |
| What the right mouse button does in a terminal: the four modes, and keeping the press from the application | [terminal-right-click](terminal-right-click.md) |
| The frameless window: the strip that replaces the title bar, its drag regions, the window controls, the menu's accelerators | [window-frame](window-frame.md) |
| The Agents view, the daemon's job files, attach/detach, dispatch | [bg-agents](bg-agents.md) |

## Reading order for a new contributor (~30 min)

Expand Down
419 changes: 419 additions & 0 deletions .ai/contexts/bg-agents.md

Large diffs are not rendered by default.

15 changes: 15 additions & 0 deletions .ai/contexts/cli-session-state.md
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,21 @@ The sidebar has no dedicated marker for such a session. Once the watcher has
seen its state file, `getStatus()` gives it the same state+age line as any live
session (see the section above).

## Descriptor hooks for the agents view

The Agents view (`.ai/contexts/bg-agents.md`) reads the same
`~/.claude/sessions/<pid>.json` files through three additions:

- `onDescriptorsChanged(listener)` fires once per flushed batch of descriptor
changes and returns an unsubscribe function. A throwing listener is logged
and does not stop the others.
- `readAllDescriptors()` returns the parsed descriptors whose pid is alive,
capped at `MAX_DESCRIPTOR_SCAN` files; `kind` (`'bg'` or `'interactive'`) and
`jobId` are part of the parsed shape.
- The results of `liveElsewhere` / `liveElsewhereMany` carry `kind` and
`jobId`, so `guardResume` can answer a `kind: 'bg'` session with an attach
instead of a resume confirmation.

## Canary tests

`test/canary-*.test.js` is a convention this module introduces. A canary
Expand Down
13 changes: 12 additions & 1 deletion .ai/contexts/ipc-bridge.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ This file is the **canonical inventory** of the IPC surface. When you add a new
| `preload.js` | ~150 | The `contextBridge.exposeInMainWorld('api', {...})` block. Every renderer-facing function. |
| `main.js` | ~2600 | The `ipcMain.handle('<name>', ...)` and `ipcMain.on('<name>', ...)` handlers, scattered throughout. |
| `schedule-ipc.js` | ~220 | **Also registers IPC handlers** (`get-schedule-creator-command`, `create-schedule-session`, `run-schedule-now`) — `init()` is called from `main.js`, but a `main.js`-only search for `ipcMain.handle` misses these three. Audit both files. |
| `bg-agents-ipc.js` | ~25 | **Also registers IPC handlers** (`get-bg-agents`, `bg-agent-verb`, `dispatch-bg-agent`) and sends `bg-agents-changed` — `init()` is called from `main.js`, so a `main.js`-only search for `ipcMain.handle` misses these three. Audit both files. |

## Public surface (IPC inventory)

Expand Down Expand Up @@ -115,6 +116,16 @@ design (parser, runner, quoting, cwd resolution, refresh triggers, editing):
boundary in either direction: the session's cwd is re-resolved main-side on
every call, and the absolute path built from it is used and discarded there.

### Background agents (see `.ai/contexts/bg-agents.md`)

| IPC | Args | Returns | Notes |
|---|---|---|---|
| `get-bg-agents` | — | `{roster, daemonReachable}` | Arms the watchers on first call, re-subscribes the `bg-agents-changed` push on every call, then reconciles through `claude agents --json --all`. Handler in `bg-agents-ipc.js`. |
| `bg-agent-verb` | `(verb, id)` | `{ok, error?}` | `stop` \| `respawn` \| `rm`; id validated against `JOB_ID_RE`; `respawn`/`rm` refused on a live (`working`/`blocked`) job. |
| `dispatch-bg-agent` | `(fields)` | `{ok, id?, error?}` | `claude --bg …` in `fields.cwd`. |

`open-terminal` accepts `sessionOptions = {type: 'attach', jobId, cwd}` and runs `claude attach <jobId>`; `stop-session` on such a session detaches (`{ok, detached: true}`).

### Misc

| IPC | Notes |
Expand All @@ -137,7 +148,7 @@ every call, and the absolute path built from it is used and discarded there.

### Events (main → renderer)

`terminal-data`, `session-detected`, `process-exited`, `terminal-notification`, `cli-busy-state`, `session-forked`, `subagent-spawned`, `subagent-completed`, `subagent-watch-event`, `projects-changed`, `status-update`, `indexing-progress`, `file-changed`, `mcp-open-diff`, `mcp-open-file`, `mcp-close-all-diffs`, `mcp-close-tab`, `updater-event`, `show-whats-new`, `session-transcript-activity`
`terminal-data`, `session-detected`, `process-exited`, `terminal-notification`, `cli-busy-state`, `session-forked`, `subagent-spawned`, `subagent-completed`, `subagent-watch-event`, `projects-changed`, `status-update`, `indexing-progress`, `file-changed`, `mcp-open-diff`, `mcp-open-file`, `mcp-close-all-diffs`, `mcp-close-tab`, `updater-event`, `show-whats-new`, `session-transcript-activity`, `bg-agents-changed`

`session-transcript-activity` and (not listed above; see
`.ai/contexts/session-cache.md`, "Remote hosts — busy spinner") `remote-activity`
Expand Down
2 changes: 2 additions & 0 deletions .ai/shared-guidelines.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ Switchboard is an **Electron desktop app**: renderer + main-process, no Domain/A
| Change what is reported to ActivityWatch (the two buckets, focus, the Settings section) | [contexts/activitywatch.md](contexts/activitywatch.md) |
| Change what a right-click does in the terminal (context menu, paste, mouse reports to the application) | [contexts/terminal-right-click.md](contexts/terminal-right-click.md) |
| Change the window frame, the strip that replaces the title bar, its drag regions or the menu's accelerators | [contexts/window-frame.md](contexts/window-frame.md) |
| Change the Agents view, the daemon's job files, attach/detach, dispatch | [contexts/bg-agents.md](contexts/bg-agents.md) |
| Change the renderer (sidebar, terminal, app.js) | `public/*.js` — entry is `app.js` |
| Write a test | `test/*.test.js` — node:test + jsdom for renderer files |
| Working practices for AI agents (HANDOFF format, shell pitfalls, review loop) | [agent-practices.md](agent-practices.md) |
Expand Down Expand Up @@ -124,6 +125,7 @@ These exist on `devsuitup/switchboard` main but not on `doctly/switchboard` main
- **Frameless window** — the app-drawn strip, the ☰ menu, the zoom keys; see [contexts/window-frame.md](contexts/window-frame.md)
- **Terminal right-click modes** — the press is kept from the application outside Native mode; see [contexts/terminal-right-click.md](contexts/terminal-right-click.md)
- **No automatic resume of a session live elsewhere** — restore and reload skip it, a click asks; see [contexts/cli-session-state.md](contexts/cli-session-state.md), "Live elsewhere"
- **Background agents view** — the daemon's `--bg` sessions listed, attached, stopped, dispatched; see [contexts/bg-agents.md](contexts/bg-agents.md)

(Not exhaustive — `git log --oneline upstream/main..main` is the ground truth.)

Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ What changes for you in each release of Switchboard. How to write an entry: [doc
- The IDE Emulation label in a session's terminal header now says whether the CLI is connected: it reads "IDE Emulation" only while it is, "IDE Emulation: waiting for CLI" when Switchboard is listening but the CLI has not connected, and "IDE Emulation: failed" when it could not start for that session, with the reason in its tooltip. A session whose IDE Emulation port was already taken no longer shows the label as if it worked. (#320)
- On Windows, the file panel no longer opens or saves a credential file (such as one under `.ssh`) through its 8.3 short name or a `\\?\` path. (#390)
### New
- An Agents view lists the sessions the Claude daemon runs in the background (`claude --bg`) and the interactive sessions running outside Switchboard, grouped by state or by project and worktree. Open it from the people icon in the sidebar or with Ctrl+Shift+A (Cmd+Shift+A on macOS); attach to a live one by double click, stop, respawn or delete one, and start a new one with New agent. A background session that is running shows a `bg` badge in the sidebar and is attached instead of resumed. (#374)
- A session's Changes panel also lists the changes in the worktrees its subagents are working in, under a header naming the agent and its branch. Those rows open as read-only diffs; a subagent that works in the session's own directory adds nothing. (#303)
- A live session on a remote host that is not open in a terminal has a Send a prompt… button on its row: type a text and it is written to the running session as a new prompt, without attaching. It needs `ncat` or an OpenBSD `nc` on the host, and is refused for a Windows host. The dialog says "Sent": the session's own status shows whether it picked the prompt up. (#219)
- A remote session that is not open in a tab and waits on a dialog on its host, such as a permission prompt or a question, shows the orange attention state, and its status line says what it waits for. It appears and clears with the next refresh of the host. (#394)
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ its sessions from `~/.claude/projects`. Installed builds update themselves; see
| Every open session as a live card | [Grid overview](docs/grid-overview.md) |
| Busy, waiting and attention indicators; the status bar | [Status indicators](docs/notifications.md) |
| Subagent hierarchy, live status, transcripts | [Subagents](docs/subagents.md) |
| The sessions the claude daemon runs in the background: list, attach, stop, dispatch | [Background agents](docs/background-agents.md) |
| Claude's file opens and proposed edits in a side panel | [IDE emulation](docs/ide-emulation.md) |
| A session's git changes, with an editor | [Changes view](docs/changes-view.md) |
| `CLAUDE.md`, memory files and `.work-files/` | [Agent Files and Work Files](docs/memory-workfiles.md) |
Expand Down
25 changes: 25 additions & 0 deletions bg-agents-ipc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// bg-agents-ipc.js — see .ai/contexts/bg-agents.md and .ai/contexts/ipc-bridge.md
'use strict';

function init({ ipcMain, bgAgents, getMainWindow, log }) {
const push = (snapshot) => {
const win = getMainWindow();
if (!win || win.isDestroyed()) return;
try { win.webContents.send('bg-agents-changed', snapshot); } catch (err) { log.warn(`[bg-agents] push failed: ${err.message}`); }
};
let unsubscribe = null;
const subscribe = () => {
if (unsubscribe) unsubscribe();
unsubscribe = bgAgents.onChange(push);
};
ipcMain.handle('get-bg-agents', async () => {
subscribe();
bgAgents.start();
return bgAgents.reconcile();
});
ipcMain.handle('bg-agent-verb', (_event, verb, id) => bgAgents.runVerb(verb, id));
ipcMain.handle('dispatch-bg-agent', (_event, fields) => bgAgents.dispatch(fields));
subscribe();
}

module.exports = { init };
220 changes: 220 additions & 0 deletions bg-agents-roster.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
// bg-agents-roster.js — see .ai/contexts/bg-agents.md
'use strict';

const JOB_STATES = new Set(['working', 'blocked', 'done', 'stopped', 'failed']);
const SESSION_STATUSES = new Set(['busy', 'idle', 'waiting', 'shell']);
const JOB_ID_RE = /^[0-9a-f]{8}$/;
const JOB_ID_IN_TEXT_RE = /(?:^|[^0-9a-f])([0-9a-f]{8})(?![0-9a-f])/i;
const TRANSCRIPT_ID_RE = /([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i;

const str = (v) => (typeof v === 'string' && v ? v : null);
const num = (v) => (Number.isFinite(v) ? v : null);

function sessionIdFromLinkScanPath(p) {
if (typeof p !== 'string') return null;
const m = TRANSCRIPT_ID_RE.exec(p);
return m ? m[1].toLowerCase() : null;
}

function flagValue(flags, name) {
if (!Array.isArray(flags)) return null;
const i = flags.indexOf(name);
return i >= 0 && i + 1 < flags.length ? str(flags[i + 1]) : null;
}

function parseJobState(text) {
let raw;
try { raw = JSON.parse(text); } catch { return null; }
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
const fan = Array.isArray(raw.fan)
? raw.fan.filter(f => f && typeof f === 'object').map(f => ({
id: str(f.id), kind: str(f.kind), label: str(f.label), startedAt: num(f.startedAt), doneAt: num(f.doneAt),
}))
: [];
const children = Array.isArray(raw.children)
? raw.children.filter(c => c && typeof c === 'object').map(c => ({
id: c.id == null ? null : String(c.id), href: str(c.href), kind: str(c.kind),
}))
: [];
return {
state: JOB_STATES.has(raw.state) ? raw.state : null,
detail: str(raw.detail),
tempo: str(raw.tempo),
tokens: num(raw.tokens),
fan,
children,
result: raw.output && typeof raw.output === 'object' ? str(raw.output.result) : null,
template: str(raw.template),
agent: flagValue(raw.respawnFlags, '--agent'),
model: flagValue(raw.respawnFlags, '--model'),
name: flagValue(raw.respawnFlags, '--name'),
sessionId: sessionIdFromLinkScanPath(raw.linkScanPath),
};
}

const MAX_NOISY_CANDIDATES = 32;

function arrayStarts(text) {
const out = [];
for (let i = text.indexOf('['); i !== -1 && out.length < MAX_NOISY_CANDIDATES; i = text.indexOf('[', i + 1)) {
if (!text.slice(text.lastIndexOf('\n', i - 1) + 1, i).trim()) out.push(i);
}
return out;
}

function arrayEnds(text) {
const out = [];
for (let i = text.lastIndexOf(']'); i !== -1 && out.length < MAX_NOISY_CANDIDATES; i = i > 0 ? text.lastIndexOf(']', i - 1) : -1) {
const eol = text.indexOf('\n', i);
if (!text.slice(i + 1, eol === -1 ? text.length : eol).trim()) out.push(i);
}
return out;
}

function parseJsonArray(text) {
const s = String(text == null ? '' : text);
try { return JSON.parse(s); } catch { /* fall through to the noisy parse */ }
const starts = arrayStarts(s);
const ends = arrayEnds(s);
for (const a of starts) {
for (const b of ends) {
if (b <= a) continue;
try {
const v = JSON.parse(s.slice(a, b + 1));
if (Array.isArray(v)) return v;
} catch { /* next candidate */ }
}
}
return null;
}

const SHELL_NOISE_RE = /^(?:[\w./-]*sh): (?:no job control in this shell|cannot set terminal process group\b.*|initialize_job_control: .*)$/;

function stripShellNoise(stderr) {
const lines = String(stderr == null ? '' : stderr).split('\n');
let i = 0;
while (i < lines.length && SHELL_NOISE_RE.test(lines[i].trim())) i++;
return lines.slice(i).join('\n').trim();
}

function parseCliList(text) {
const raw = parseJsonArray(text);
if (!Array.isArray(raw)) return null;
const out = [];
for (const s of raw) {
if (!s || typeof s !== 'object') continue;
const kind = s.kind === 'background' || s.kind === 'interactive' ? s.kind : null;
if (!kind || typeof s.sessionId !== 'string' || !s.sessionId) continue;
out.push({
id: str(s.id),
sessionId: s.sessionId,
name: str(s.name),
cwd: str(s.cwd),
kind,
state: JOB_STATES.has(s.state) ? s.state : null,
status: SESSION_STATUSES.has(s.status) ? s.status : null,
pid: Number.isInteger(s.pid) && s.pid > 0 ? s.pid : null,
startedAt: num(s.startedAt),
});
}
return out;
}

function emptyEntry() {
return {
id: null, sessionId: null, name: null, cwd: null, kind: 'background',
state: null, status: null, pid: null, startedAt: null,
agent: null, model: null, detail: null, tempo: null, tokens: null,
fan: [], children: [], result: null, attachedHere: false,
};
}

function backgroundEntry(id, cliEntry, job, descriptor) {
const e = emptyEntry();
e.id = id;
if (job) {
Object.assign(e, {
sessionId: job.sessionId, name: job.name, state: job.state, agent: job.agent, model: job.model,
detail: job.detail, tempo: job.tempo, tokens: job.tokens, fan: job.fan, children: job.children, result: job.result,
});
}
if (cliEntry) {
e.sessionId = cliEntry.sessionId || e.sessionId;
e.name = cliEntry.name || e.name;
e.cwd = cliEntry.cwd || e.cwd;
e.state = (job && job.state) || cliEntry.state || e.state;
e.status = cliEntry.status || e.status;
e.pid = cliEntry.pid || e.pid;
e.startedAt = cliEntry.startedAt ?? e.startedAt;
}
if (descriptor) {
e.sessionId = e.sessionId || descriptor.sessionId;
e.name = e.name || descriptor.name;
e.cwd = e.cwd || descriptor.cwd;
e.agent = e.agent || descriptor.agent;
e.status = descriptor.status || e.status;
e.pid = descriptor.pid || e.pid;
e.startedAt = e.startedAt ?? descriptor.startedAt;
}
return e;
}

function mergeRoster({ cli, jobs, descriptors, isOwnPid, isAttachedHere }) {
const own = typeof isOwnPid === 'function' ? isOwnPid : () => false;
const attached = typeof isAttachedHere === 'function' ? isAttachedHere : () => false;
const byJobId = new Map();
for (const d of descriptors || []) {
if (d && d.kind === 'bg' && typeof d.jobId === 'string') byJobId.set(d.jobId, d);
}
const roster = [];
if (Array.isArray(cli)) {
for (const s of cli) {
if (s.kind !== 'background' || !s.id) continue;
roster.push(backgroundEntry(s.id, s, jobs ? jobs.get(s.id) : null, byJobId.get(s.id)));
}
} else if (jobs) {
for (const [id, job] of jobs) roster.push(backgroundEntry(id, null, job, byJobId.get(id)));
}
for (const d of descriptors || []) {
if (!d || d.kind !== 'interactive' || !d.sessionId || own(d.pid)) continue;
roster.push({
...emptyEntry(), kind: 'interactive', sessionId: d.sessionId, name: d.name, cwd: d.cwd,
status: d.status, pid: d.pid, startedAt: d.startedAt,
});
}
for (const e of roster) e.attachedHere = e.kind === 'background' && !!attached(e.id);
return roster;
}

function splitAddDirs(value) {
if (typeof value !== 'string') return [];
return value.split(',').map(s => s.trim()).filter(Boolean);
}

function dispatchArgs(fields) {
const f = fields && typeof fields === 'object' ? fields : {};
const prompt = typeof f.prompt === 'string' ? f.prompt.trim() : '';
if (!prompt) return { ok: false, error: 'a prompt is required' };
if (prompt.startsWith('-')) return { ok: false, error: 'the prompt cannot start with "-": the CLI would read it as a flag' };
if (typeof f.cwd !== 'string' || !f.cwd) return { ok: false, error: 'a project directory is required' };
const args = ['--bg'];
const name = typeof f.name === 'string' ? f.name.trim() : '';
if (name) args.push('--name', name);
const agent = typeof f.agent === 'string' ? f.agent.trim() : '';
if (agent) args.push('--agent', agent);
if (f.dangerouslySkipPermissions) args.push('--dangerously-skip-permissions');
else if (typeof f.permissionMode === 'string' && f.permissionMode) args.push('--permission-mode', f.permissionMode);
for (const dir of splitAddDirs(f.addDirs)) args.push('--add-dir', dir);
args.push('--', prompt);
return { ok: true, args, cwd: f.cwd };
}

function parseDispatchOutput(stdout) {
const m = JOB_ID_IN_TEXT_RE.exec(String(stdout || ''));
return m ? m[1].toLowerCase() : null;
}

module.exports = {
parseJobState, parseCliList, mergeRoster, dispatchArgs, parseDispatchOutput,
sessionIdFromLinkScanPath, stripShellNoise, JOB_ID_RE, JOB_STATES,
};
Loading
Loading