Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 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
747c830
Merge remote-tracking branch 'upstream/main' into worktree-background…
Oct 7, 2026
2150fd6
test(cli-session-state): stub the procStart probe in the liveElsewher…
Oct 7, 2026
c280cac
fix(bg-agents): follow the chosen project's settings, refuse unconfin…
Oct 7, 2026
3c279d6
docs(bg-agents): record the archive, fork and WSL limits
Oct 7, 2026
d61369a
refactor(bg-agents): use the shared control styles, move claude-binar…
Oct 7, 2026
966878e
fix(bg-agents): fail closed on delete, match session ids in any case,…
Oct 7, 2026
37ebdb4
fix(bg-agents): trust the job files over the cached roster, refuse un…
Oct 7, 2026
8b3e7ef
fix(bg-agents): honour a cached live job without a state file, refuse…
Oct 7, 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 @@ -23,6 +23,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
452 changes: 452 additions & 0 deletions .ai/contexts/bg-agents.md

Large diffs are not rendered by default.

20 changes: 20 additions & 0 deletions .ai/contexts/cli-session-state.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,11 @@ that session — a second Switchboard instance reading the same
session, both writing its transcript, and input meant for this instance lands
in a session the user is driving elsewhere.

Session ids are matched lowercased, `getStatus` included, and `liveElsewhereMany` answers under the
ids it was asked. `liveElsewhereChecked` returns `{known, live}` or `{known:
false, reason}` when the directory or a descriptor cannot be read (a missing
directory is known-empty); `delete-session` uses it to fail closed.

**The check** is main-side, on demand, over IPC `session-live-elsewhere`:
`liveElsewhere(sessionId, sessionHasPty, ptyPids)` returns `{pid, cwd,
startedAt}` or `null`. A working-set restore asks for its whole batch at once
Expand Down Expand Up @@ -308,6 +313,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
14 changes: 13 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`, `bg-agent-live-job`) and sends `bg-agents-changed` — `init()` is called from `main.js`, so a `main.js`-only search for `ipcMain.handle` misses these four. Audit both files. |

## Public surface (IPC inventory)

Expand Down Expand Up @@ -115,6 +116,17 @@ 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`. |
| `bg-agent-live-job` | `(sessionId)` | `{known, job} \| {known:false, reason}` | `liveJobCheck`; `stopBeforeArchive` refuses on a live job or an unknown answer. |

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

### Touched files (issue #309)

| IPC | Args | Returns | Notes |
Expand Down Expand Up @@ -147,7 +159,7 @@ through `read-file-for-panel` and its guards, never through this IPC.

### Events (main → renderer)

`terminal-data`, `session-detected`, `process-exited`, `remote-resize-allowed`, `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`, `remote-resize-allowed`, `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`

`remote-resize-allowed(sessionId)` is emitted once when a live remote attach
promotes from shared to solo. `preload.onRemoteResizeAllowed` forwards the id
Expand Down
2 changes: 2 additions & 0 deletions .ai/shared-guidelines.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,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` |
| Change screen refresh on return, the Refresh controls, or the PTY resize nudge | [contexts/terminal-refresh.md](contexts/terminal-refresh.md) |
| Write a test | `test/*.test.js` — node:test + jsdom for renderer files |
Expand Down Expand Up @@ -127,6 +128,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 @@ -5,6 +5,7 @@ What changes for you in each release of Switchboard. How to write an entry: [doc
## Unreleased

### 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)
- **Archive folder** on a project header opens a dialog that archives the folder's sessions and disables its enabled schedules, each optional, and hides the folder with its worktrees. The folder comes back, with its settings, when you add it again or a new session starts in it, and then offers to turn back on the schedules the archive disabled. (#473)
### Changed
- Markdown files open formatted in Touched, with a toggle back to the source that is remembered. (#472)
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) |
| The files a session's file tools touched | [Touched files](docs/touched-files.md) |
Expand Down
26 changes: 26 additions & 0 deletions bg-agents-ipc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// 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));
ipcMain.handle('bg-agent-live-job', (_event, sessionId) => bgAgents.liveJobCheck(String(sessionId || '')));
subscribe();
}

module.exports = { init };
230 changes: 230 additions & 0 deletions bg-agents-roster.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
// 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 isLiveJobState(state) {
return state === 'working' || state === 'blocked';
}

function dispatchRefusal({ sandbox, preLaunchCmd } = {}) {
if (sandbox) return 'this project runs its sessions sandboxed, and a background agent runs under the claude daemon, outside the sandbox: start it as a session instead';
if (typeof preLaunchCmd === 'string' && preLaunchCmd.trim()) return 'this project has a pre-launch command, and the claude daemon does not run it for a background agent: start it as a session instead';
return null;
}

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, dispatchRefusal, isLiveJobState,
sessionIdFromLinkScanPath, stripShellNoise, JOB_ID_RE, JOB_STATES,
};
Loading
Loading