From 5457a2bbbee36d7ab9d2bf72a60af99f3f8fcaea Mon Sep 17 00:00:00 2001 From: pjay Date: Wed, 30 Sep 2026 16:40:00 +0200 Subject: [PATCH 01/38] docs(spec): design the background agents view A graphical replacement for the claude agents TUI: a dedicated view fed by the daemon's job files and the session descriptors, reconciled by claude agents --json, with attach/stop/rm/respawn/dispatch through the CLI. --- ...026-09-30-background-agents-view-design.md | 304 ++++++++++++++++++ 1 file changed, 304 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-30-background-agents-view-design.md diff --git a/docs/superpowers/specs/2026-09-30-background-agents-view-design.md b/docs/superpowers/specs/2026-09-30-background-agents-view-design.md new file mode 100644 index 00000000..d1a1ced7 --- /dev/null +++ b/docs/superpowers/specs/2026-09-30-background-agents-view-design.md @@ -0,0 +1,304 @@ +# Background agents view — design + +Date: 2026-09-30. Status: approved in conversation, awaiting implementation plan. + +## Purpose + +Give Switchboard a graphical replacement for the `claude agents` TUI: one +place to see every session the Claude CLI daemon runs in the background +(`claude --bg`), read what each one is doing, and act on it — attach to it in +a terminal tab, stop it, delete it, respawn it, or dispatch a new one — without +opening a terminal and the TUI. + +The user's fleet of agents (the fleet plugin's EM/PM/developer roles) runs +entirely as `--bg` sessions, so this view is their control room. + +## What the CLI provides (measured, CLI 2.1.285, Linux, 2026-09-30) + +None of this is a documented interface. Every use below is best-effort and +must degrade to silence, exactly as `.ai/contexts/cli-session-state.md` +prescribes for the session descriptors. + +- `claude --bg [--name n] [--agent a] [--permission-mode m] [--add-dir d] ` + starts a session under the daemon and prints its short id. +- `claude agents --json [--all]` prints a JSON array, no TTY needed, in + ~0.15 s CPU. Without `--all`: live sessions only (background `working` plus + every live interactive session, including Switchboard's own). With `--all`: + also `done` and `stopped` background sessions. Entry shape: + `{id, sessionId, name, cwd, kind: 'background'|'interactive', startedAt, + pid?, state?: 'working'|'done'|'stopped', status?: 'busy'|'idle'|'waiting'}`. +- `claude attach ` opens the session in the current terminal. Verified in + a pty: Ctrl+Z detaches, the attach client exits 0, the session stays + `working`. `claude stop `, `claude rm ` (also deletes the worktree + when safe), `claude respawn `, `claude logs ` (raw screen ANSI, not + used here). +- `~/.claude/jobs//state.json`, written by the daemon per job: + `state`, `detail` (one human-readable line), `tempo`, `tokens`, `inFlight`, + `fan[]` (`{id, kind: 'agent'|'shell', label, startedAt, doneAt}`), + `children[]` (links the job produced, e.g. merge requests: `{id, href, + kind}`), `output.result`, `template`, `respawnFlags[]` (the original + `--agent`, `--model`, `--name`, `--permission-mode`), `intent`, + `linkScanPath` (the transcript path, which carries the session id). + `timeline.jsonl` beside it is not read. +- `~/.claude/sessions/.json` for a background worker carries + `kind: "bg"`, `jobId` (the short id), `agent`, `name`, `cwd`, `status`, + `procStart`, `startedAt`. Interactive sessions carry `kind: "interactive"`. + Switchboard already watches this directory (`cli-session-state.js`). +- A stopped or done background session has no live pid; `claude --resume` + on it is legitimate (the CLI documents it). A `working` one must never be + resumed: two CLIs would write one transcript. + +## Scope + +In: + +- A dedicated **Agents view**, a sibling of the grid, listing background + sessions (all states, with a filter for finished ones) and interactive + sessions that run outside this Switchboard instance. +- Per session: attach, read the transcript, stop, respawn, delete. +- A dispatch dialog to start a new background session. +- The sidebar's click on a live background session attaches instead of + asking "Resume anyway?". + +Out (deliberately): + +- Live terminals inside the view (attach opens a real tab). +- A `claude logs` tail (screen ANSI; the JSONL transcript covers reading). +- Restoring attach tabs across restarts. +- Pre-launch command and sandbox in the dispatch dialog (they wrap a process + Switchboard holds; here the daemon holds it). +- Configurable sort or grouping; interactive sessions of this instance + (the sidebar already shows them). +- Any use of the daemon's control socket or `control.key`. + +## Architecture + +### Main process: `bg-agents.js` + +One new module beside `cli-session-state.js`, with one responsibility: keep a +roster of daemon jobs and external interactive sessions, and push it to the +renderer. + +Sources: + +1. `~/.claude/jobs/`: one `fs.watch` on the directory (new job directories) + and one per job directory (rewrites of `state.json`). Each read goes + through a pure `parseJobState(text)` that keeps only what the view shows: + `state, detail, tempo, tokens, fan, children, result, template, agent, + model, name` (the last three derived from `respawnFlags`), and `sessionId` + extracted from `linkScanPath`. An unreadable or truncated file leaves the + previous value in place and logs at debug. +2. `~/.claude/sessions/.json`: `cli-session-state.js` gains an + `onDescriptor(listener)` hook that emits every parsed descriptor it reads + (`pid, sessionId, kind, jobId, agent, name, cwd, status, startedAt, + procStart`), without changing its existing matching or transitions. + `bg-agents.js` keeps `kind: "bg"` descriptors (joined to a job by `jobId`) + and `kind: "interactive"` descriptors that `ownProcessFilter()` does not + claim for this instance. + +Reconciliation: `claude agents --json --all` via `execFile` (no shell, +5 s timeout), run by every `get-bg-agents` call and after every verb. The +renderer calls `get-bg-agents` when the view opens and every 30 s while it +is visible, so that is the reconciliation cadence. Its list is the authority for which jobs exist and their +`state`; the files supply everything else. A job directory absent from the +CLI's list is not shown. If the CLI fails (missing, no `agents` subcommand, +timeout), the roster is built from files alone and carries +`daemonReachable: false`. + +Roster entry: + +``` +{ id, sessionId, name, cwd, kind: 'background'|'interactive', + state: 'working'|'done'|'stopped'|null, status: 'busy'|'idle'|'waiting'|null, + pid, startedAt, agent, model, detail, tempo, tokens, fan, children, result, + attachedHere: boolean } +``` + +`mergeRoster(cliList, jobs, descriptors, ownPids)` is pure and unit-tested. + +IPC (add to `.ai/contexts/ipc-bridge.md`): + +| IPC | Args | Returns | +|---|---|---| +| `get-bg-agents` | — | `{roster: Entry[], daemonReachable}` — snapshot; arms the watchers on first call | +| `bg-agent-verb` | `(verb: 'stop'\|'respawn'\|'rm', id)` | `{ok, error?}` | +| `dispatch-bg-agent` | `({prompt, name, agent, cwd, permissionMode, dangerouslySkipPermissions, addDirs})` | `{ok, id?, error?}` | +| event `bg-agents-changed` | `{roster, daemonReachable}` | coalesced at 250 ms | + +Guards: liveness by `process.kill(pid, 0)` and `procStart` reuse from +`cli-session-state`; `MAX_JOBS` (200) bounds the initial scan; watchers are +armed on the first `get-bg-agents` and released in the window's `closed` +handler with the other watchers. Nothing runs before the view is first +opened (ADR 0002: no added steady-state cost). + +### Renderer: `agents-view.js` + +A plain script like the others. Depends on `escapeHtml`, the roster from +IPC, and two callbacks from `app.js`: open a terminal tab, open the JSONL +viewer. Renders with `morphdom` from an in-memory model so a roster update +keeps the selection and the scroll. + +Container `#agents-viewer` inside `#terminal-area`, a sibling of +`#grid-viewer`, shown and hidden the way the grid is (hide the active +terminal, refit on return). Toggle button in the sidebar filter row next to +the grid button; shortcut `agentsToggle` (default Ctrl+Shift+A, Cmd on macOS) +registered in `shortcuts.js` and listed in `docs/keyboard-shortcuts.md`. Open +state persists in `localStorage.agentsViewActive`. Closing the view does not +release the watchers. + +Layout: a master list and a detail pane. + +``` +┌ Agents ──────────────────── 3 running · 2 done ─── [New agent] [Finished ☑] ┐ +│ ● em-platform-2026… fleet:em working·idle lvds/…/em-platform 2d 6h ⋯ │ +│ ● fleet-0f — working·busy lvds/internal/fleet 12 min ⋯ │ +│ ○ spike-target — done lvds/internal/fleet 1 h ⋯ │ +│ ◌ lvds-1b external busy lvds/.claude/worktr… 3 h │ +├───────────────────────────────────────────────────────────────────────────┤ +│ em-platform-20260928075800-49fd [Attach] [Transcript] │ +│ backlog reviewed; awaiting !196 merge or apiClient.ts diff │ +│ 173k tokens · sonnet-5 · started 28/09 07:58 · pid 346590 │ +│ Subagents: Spawn developer for platform squad (26 s, done) │ +│ Produced: !195 platform-admin-dossiers-nav · !196 … │ +│ Last result: no new action needed; session idle pending !196 merge… │ +└───────────────────────────────────────────────────────────────────────────┘ +``` + +- List row: state glyph reusing the rungs of `session-state.js` (busy + spinner, waiting orange, idle green, done/stopped grey, external + interactive as a hollow circle), name, `--agent`, `state·status`, + abbreviated project path, age, a `⋯` menu. Sort: `working` first, then + `startedAt` descending. The "Finished" filter (on by default) shows or + hides `done`/`stopped`; persisted in `localStorage.agentsShowFinished`. +- Detail pane for the selected row: `detail`, tokens, model, start time, + pid, `fan[]` with duration and state, `children[]` as clickable links + (`shell.openExternal`, already exposed), `output.result`. For an external + interactive session: name, cwd, status, and only the Transcript action. +- `⋯` menu and detail buttons: Attach, Transcript, Stop, Respawn, Delete, + disabled by state (Stop only when `working`; Delete never when `working`; + Respawn and Attach never on an interactive session). A verb in flight greys + the row; its error shows in the detail pane, never in a modal. +- "New agent" opens the dispatch dialog. +- Banner under the header when `daemonReachable` is false: "The daemon is + not answering; state comes from files only." Verbs other than Transcript + are disabled then. Empty state: "No background agents. `claude --bg` + starts one, or New agent." + +### Verbs + +**Attach.** An ordinary terminal tab whose pty runs `claude attach ` in +the session's cwd, through `open-terminal` with `sessionOptions.type = +'attach'` and the `jobId`. The tab is keyed by the session's real +`sessionId`, so the sidebar row (already indexed from the transcript) and +the tab coincide, and `cli-session-state` feeds its busy/idle state from the +daemon worker's descriptor with no change. No `--resume`, no fork, ever. + +- Detach: closing the tab writes `\x1a` (Ctrl+Z) to the pty, waits up to + 2 s for the attach client to exit, and kills the pty only as a last + resort. The terminal header's Stop button reads "Detach" on an attach tab + and does exactly this; stopping the background session is only offered in + the Agents view. This is the detach/stop pair `.ai/contexts/session-state.md` + already defines. +- An attach tab is not part of the restore working set: after a restart it + does not come back; the Agents view is the way to reopen it. +- If `claude attach` exits at once (the job stopped between the click and the + spawn), the tab shows the CLI's output and the header goes to "exited", + like any pty. + +**Stop, Respawn, Delete.** `execFile('claude', [verb, id])` in the session's +cwd, 15 s timeout, no shell. Each returns `{ok, error}` (stderr verbatim) +and triggers a reconciliation. Delete asks for confirmation with the CLI's +own wording: the conversation and its worktree go, when that is safe. Stop +or Delete on a session attached here detaches first. + +**Dispatch.** `showDispatchAgentDialog()` in `dialogs.js`, built from the +same pieces as the New Session dialog: + +| Field | Passed as | +|---|---| +| Prompt (textarea, required) | last positional argument | +| Name | `--name `; empty = the CLI picks one | +| Project (select over the sidebar's projects, preselected to the active session's) | the `cwd` of the `execFile` | +| Agent (free text) | `--agent `; empty = none | +| Permission mode / Dangerous Skip (as in New Session) | `--permission-mode ` or `--dangerously-skip-permissions` | +| Additional directories | one `--add-dir` per entry, through the existing `parseAddDirs` | + +Command: `claude --bg [options] ` via `execFile`, never a shell. The +printed id is parsed; on success the roster is reconciled and the new row +selected. If the id does not parse, the result is `{ok: true, id: null}`; +the row appears through the files. + +### Sidebar + +No roster in the sidebar. The existing resume guard is extended by two +fields: `session-live-elsewhere` and `sessions-live-elsewhere` also return +the descriptor's `kind` and `jobId`. In `guardResume`, `kind === 'bg'` with a +live pid no longer asks "Resume anyway?": the click attaches. A `done` or +`stopped` background session has no live pid, the guard says nothing, and +`--resume` proceeds as today. External interactive sessions keep today's +confirmation. Once the Agents view has been opened at least once, the +`bg-agents-changed` event also reaches the sidebar, which puts a small "bg" +badge on the rows whose session id is in the roster; before that there is no +badge and no cost, and the guard's protection does not depend on it. + +## Failure handling + +- CLI missing, without `agents`, or timing out: file-only roster, banner, + verbs disabled except Transcript. Nothing else in the app is affected. +- `state.json` unreadable or mid-rewrite: previous value kept, debug log. +- Dead descriptor pid: same liveness as `cli-session-state`; the entry falls + back to the CLI's state alone. +- A failing verb: `{ok: false, error}` in the detail pane; the roster is + reconciled regardless. +- Dispatch whose id does not parse: see above. + +## Invariants (to be written to `.ai/contexts/bg-agents.md`, with a row in +`.ai/shared-guidelines.md` and `.ai/contexts/README.md`) + +1. Never `--resume` or `--fork-session` a session whose job is `working`. + `claude attach` is the only path to a live job. +2. Every write to the daemon goes through the CLI with `execFile` and no + shell. The control socket and `control.key` are never touched. +3. Closing an attach tab detaches; it never kills the session. `claude stop` + is the only stop. +4. No steady-state cost before the view is first opened (ADR 0002). +5. `jobs/` and the `kind: "bg"` descriptor are undocumented interfaces: + failure is silence, and a canary test pins their observed shape. + +## Testing + +`node:test`, as the rest of the suite; renderer tests through +`test/dom-setup.js` and `vm.runInContext`. + +- `test/bg-agents-parse.test.js`: `parseJobState()` and `mergeRoster()`. + Cases: a job with `fan` and `children`; a `done` job without a pid; a bg + descriptor without a job (ignored); an interactive descriptor owned by this + instance (excluded); the CLI's `state` winning over the file's; the session + id extracted from `linkScanPath`. +- `test/bg-agents-watch.test.js`: a temporary `jobs/` directory; creating a + job directory and rewriting `state.json` yields one coalesced event; `stop()` + releases the watchers. +- `test/canary-bg-agents-files.test.js`: pins the observed shape of + `state.json` and of the bg descriptor (fields, `state` and `status` + vocabularies), with the CLI version and date in the test name. +- `test/dom-agents-view.test.js`: rendering a roster; sort; the Finished + filter; selection kept across a morphdom update; buttons disabled by state; + the daemon banner; the empty state. +- `test/resume-guard.test.js`, extended: `kind: 'bg'` with a live pid + attaches without confirmation; without a live pid the resume path is + taken. +- `test/dom-dispatch-dialog.test.js`: the fields produce exactly the expected + argument list (no shell, prompt last, absent options when empty). +- Main-side: `open-terminal` with `type: 'attach'` builds `claude attach + ` in the given cwd, and closing writes `\x1a` before any kill. + +Live verification runs against the isolated test instance +(`task test-pr`, see `docs/testing-a-pr.md`) with a `claude --bg` started by +hand. + +## Documentation to ship with the change + +- `docs/background-agents.md` (new page) and a row in the README's feature + table and `docs/README.md`. +- `docs/keyboard-shortcuts.md`: the new shortcut. +- `.ai/contexts/bg-agents.md`, and the IPC rows in `.ai/contexts/ipc-bridge.md`. From dac3ca626b6628bb7fc6a1c6b98b4aaef754d021 Mon Sep 17 00:00:00 2001 From: pjay Date: Wed, 30 Sep 2026 17:13:16 +0200 Subject: [PATCH 02/38] docs(plan): implementation plan for the background agents view --- .../2026-09-30-background-agents-view.md | 3209 +++++++++++++++++ 1 file changed, 3209 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-30-background-agents-view.md diff --git a/docs/superpowers/plans/2026-09-30-background-agents-view.md b/docs/superpowers/plans/2026-09-30-background-agents-view.md new file mode 100644 index 00000000..d86fe633 --- /dev/null +++ b/docs/superpowers/plans/2026-09-30-background-agents-view.md @@ -0,0 +1,3209 @@ +# Background Agents View Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Give Switchboard a graphical replacement for the `claude agents` TUI: a dedicated view that lists the daemon's background sessions, reads what each one does, and attaches to, stops, respawns, deletes or dispatches them. + +**Architecture:** A main-process module (`bg-agents.js`, with its pure half in `bg-agents-roster.js`) watches `~/.claude/jobs/*/state.json` and the CLI's session descriptors, reconciles them against `claude agents --json --all`, and pushes a roster to the renderer. The renderer's `agents-view.js` renders a master list and a detail pane and calls the verbs over IPC; attach is an ordinary terminal tab running `claude attach `, keyed by the session's real id so the sidebar and the tab coincide. + +**Tech Stack:** Electron main (Node, `fs.watch`, `child_process.spawn` through the user's login shell), plain-script renderer (`morphdom`, `xterm`), `node:test` + jsdom. + +**Spec:** `docs/superpowers/specs/2026-09-30-background-agents-view-design.md` + +## Global Constraints + +- Never `--resume` or `--fork-session` a session whose job is `working`; `claude attach` is the only path to a live job. +- Every call to the CLI goes through the login shell with an argv quoted by `quoteArgvForShell` (the scheduler's path, `main.js` `runScheduleCommand`), never a string built by hand. The daemon's control socket and `control.key` are never touched. +- Closing an attach tab detaches (`\x1a`, then 2 s grace, then kill); `claude stop` is the only stop. +- No steady-state cost before the view is first opened: watchers arm on the first `get-bg-agents`. +- `~/.claude/jobs/` and the `kind: "bg"` descriptor are undocumented interfaces: failure is silence, and a canary test pins their observed shape (CLI 2.1.285, 2026-09-30). +- Timeouts: list 5 s, verbs 15 s. Coalescing 250 ms. Reconcile cadence 30 s while the view is visible. `MAX_JOBS` 200. +- Shortcut `agentsToggle`, default Primary+Shift+A. `localStorage` keys `agentsViewActive`, `agentsShowFinished`. +- Renderer files are classic scripts sharing one global scope: every new cross-file name goes into `eslint.config.js`'s `rendererCrossFileGlobals`, or `task check` fails on `no-undef`. Keep names distinct across files (see `.ai/contexts/subagent-observability.md`, "Grid view keeps its own parallel tracking"). +- Commit style `(area): imperative subject`, no `Co-Authored-By`. The pre-commit hook runs `task check` (lint + full suite, ~2 min); run single files with `node --test test/` while iterating. +- Comment sweep before the PR: rationale goes to `.ai/contexts/bg-agents.md`, at most a one-line `// see .ai/contexts/bg-agents.md` pointer stays in code. + +## Review Focus + +1. `claude --bg` prints its id in a format nobody measured; `parseDispatchOutput` must return `null` on anything unrecognised and the dispatch must still report `ok: true`. Test in Task 1. +2. A prompt starting with `-` would be read by the CLI as a flag; `dispatchArgs` refuses it with a clear error instead of shipping it. Test in Task 1. +3. A job id from the renderer is untrusted; a verb whose id is not exactly eight hex characters is refused before any process is spawned. Test in Task 4. +4. `state.json` caught mid-rewrite (empty or truncated) must keep the previous parsed value, not blank the row. Test in Task 4. +5. `hideAllViewers()` runs from `showSession`/`showJsonlViewer` while the Agents view is open; it must close the view without restoring the terminal area (no recursion, no flicker). Test in Task 7. + +## Deviations from the spec, recorded here and amended in Task 0 + +- The view's container is a sibling of `#jsonl-viewer` (outside `#terminal-area`), not of `#grid-viewer`: the grid is a layout of the terminals, and hiding `#terminal-area` the way the Stats tab does leaves the grid state intact for the return trip. +- The CLI is invoked through the login shell with a quoted argv (the scheduler's existing path), not `execFile('claude', …)`: the packaged app's `PATH` does not know version managers, and the argv-quoting keeps the no-injection property. +- The row's `⋯` menu is dropped: a row click selects it and the detail pane carries the verbs. One surface for five verbs is enough. + +## File structure + +| File | Responsibility | +|---|---| +| `bg-agents-roster.js` (new) | Pure: parse `state.json`, parse the CLI list, merge into roster entries, build dispatch argv, parse the dispatch output | +| `bg-agents.js` (new) | Stateful: watchers over `jobs/`, descriptor subscription, reconcile through the CLI, verbs, dispatch, change events | +| `bg-agents-ipc.js` (new) | The three `ipcMain.handle` and the `bg-agents-changed` push | +| `cli-session-state.js` | `onDescriptorsChanged`, `readAllDescriptors`, `parseDescriptor`, `kind`/`jobId` on live-elsewhere results, export `ownProcessFilter` | +| `pty-ops.js` | `detachPty` | +| `main.js` | `runClaudeCommand`, the attach branch of `open-terminal`, detach in `stop-session`, wiring, `bgAgents.stop()` on close | +| `preload.js` | `getBgAgents`, `bgAgentVerb`, `dispatchBgAgent`, `onBgAgentsChanged` | +| `public/agents-view.js` (new) | The view: state, pure row/verb helpers, render, verbs, attach, show/hide/toggle | +| `public/shortcuts.js` | `agentsToggle` | +| `public/resume-guard.js` | A live `bg` descriptor answers "attach", not "resume anyway?" | +| `public/stop-session-ui.js` | Detach wording for an attach tab | +| `public/app.js`, `public/terminal-manager.js`, `public/grid-view.js`, `public/memory-workfiles-view.js`, `public/sidebar.js`, `public/dialogs.js`, `public/index.html`, `public/style.css`, `eslint.config.js` | Wiring, badge, dialog, markup, styles, globals | +| `test/*.test.js` | One file per unit, listed per task | +| Docs | `docs/background-agents.md`, `.ai/contexts/bg-agents.md`, rows in the README, `docs/README.md`, `docs/keyboard-shortcuts.md`, `docs/settings.md`, `.ai/contexts/ipc-bridge.md`, `.ai/contexts/README.md`, `.ai/shared-guidelines.md`, `.ai/contexts/cli-session-state.md` | + +--- + +### Task 0: Amend the spec with the three deviations + +**Files:** +- Modify: `docs/superpowers/specs/2026-09-30-background-agents-view-design.md` + +- [ ] **Step 1: Edit the three sentences** + +In the "Architecture → Renderer" section, replace + +``` +Container `#agents-viewer` inside `#terminal-area`, a sibling of +`#grid-viewer`, shown and hidden the way the grid is (hide the active +terminal, refit on return). +``` + +with + +``` +Container `#agents-viewer`, a sibling of `#jsonl-viewer` outside +`#terminal-area`, shown the way the Stats tab shows its viewer (hide +`#terminal-area`, which keeps the grid's state intact) and hidden by +restoring whichever of grid, active session or placeholder was there. +``` + +In "Scope → Out", the "Verbs" paragraph and invariant 2, replace every +`execFile('claude', …)` / "with `execFile` and no shell" wording with: +"through the user's login shell with an argv quoted by `quoteArgvForShell`, +the scheduler's existing path in `main.js`; never a command string built by +hand". In the list-row bullet, delete "a `⋯` menu" and the "`⋯` menu and +detail buttons" sentence; write "A row click selects it; the detail pane +carries the verbs." + +- [ ] **Step 2: Commit** + +```bash +/usr/bin/git add docs/superpowers/specs/2026-09-30-background-agents-view-design.md +/usr/bin/git commit -m "docs(spec): record the three deviations taken while planning the agents view" +``` + +--- + +### Task 1: Pure roster parsing (`bg-agents-roster.js`) + +**Files:** +- Create: `bg-agents-roster.js` +- Test: `test/bg-agents-roster.test.js` + +**Interfaces:** +- Produces: + - `parseJobState(text) → null | { state, detail, tempo, tokens, fan[], children[], result, template, agent, model, name, sessionId }` + - `parseCliList(text) → null | [{ id, sessionId, name, cwd, kind, state, status, pid, startedAt }]` + - `mergeRoster({ cli, jobs, descriptors, isOwnPid, isAttachedHere }) → Entry[]` where `Entry = { id, sessionId, name, cwd, kind: 'background'|'interactive', state, status, pid, startedAt, agent, model, detail, tempo, tokens, fan, children, result, attachedHere }` + - `dispatchArgs(fields) → { ok: true, args, cwd } | { ok: false, error }` + - `parseDispatchOutput(stdout) → string | null` + - `JOB_ID_RE = /^[0-9a-f]{8}$/` + +- [ ] **Step 1: Write the failing tests** + +```js +// test/bg-agents-roster.test.js — pure parsing and merging for the agents view. +// See .ai/contexts/bg-agents.md. +'use strict'; +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { + parseJobState, parseCliList, mergeRoster, dispatchArgs, parseDispatchOutput, JOB_ID_RE, +} = require('../bg-agents-roster'); + +const STATE = JSON.stringify({ + state: 'done', + detail: 'backlog reviewed; awaiting !196 merge', + tempo: 'idle', + tokens: 172999, + fan: [{ id: 'a4a8', kind: 'agent', label: 'Spawn developer', startedAt: 1790590786510, doneAt: 1790590813354 }, 'junk'], + children: [{ id: '195', href: 'https://gitlab.com/x/-/merge_requests/195', kind: 'merge_request' }], + output: { result: 'no new action needed' }, + template: 'fleet:em', + respawnFlags: ['--plugin-dir', '/x', '--agent', 'fleet:em', '--permission-mode', 'auto', '--name', 'em-platform', '--model', 'claude-sonnet-5'], + linkScanPath: '/home/u/.claude/projects/-home-u-p/bc3fd129-60bb-4bd2-8f38-63fecd1256e5.jsonl', +}); + +test('parseJobState keeps the fields the view shows and derives agent/model/name/sessionId', () => { + const job = parseJobState(STATE); + assert.equal(job.state, 'done'); + assert.equal(job.detail, 'backlog reviewed; awaiting !196 merge'); + assert.equal(job.tokens, 172999); + assert.deepEqual(job.fan, [{ id: 'a4a8', kind: 'agent', label: 'Spawn developer', startedAt: 1790590786510, doneAt: 1790590813354 }]); + assert.deepEqual(job.children, [{ id: '195', href: 'https://gitlab.com/x/-/merge_requests/195', kind: 'merge_request' }]); + assert.equal(job.result, 'no new action needed'); + assert.equal(job.agent, 'fleet:em'); + assert.equal(job.model, 'claude-sonnet-5'); + assert.equal(job.name, 'em-platform'); + assert.equal(job.sessionId, 'bc3fd129-60bb-4bd2-8f38-63fecd1256e5'); +}); + +test('parseJobState: unknown state, missing output and garbage are tolerated', () => { + assert.equal(parseJobState(''), null); + assert.equal(parseJobState('[]'), null); + const job = parseJobState('{"state":"weird","fan":null}'); + assert.equal(job.state, null); + assert.deepEqual(job.fan, []); + assert.equal(job.result, null); + assert.equal(job.sessionId, null); +}); + +test('parseCliList keeps background and interactive entries and drops the rest', () => { + const list = parseCliList(JSON.stringify([ + { id: 'bc3fd129', pid: 346590, cwd: '/w', kind: 'background', startedAt: 1, sessionId: 's-bg', name: 'em', status: 'idle', state: 'working' }, + { pid: 5, cwd: '/w', kind: 'interactive', startedAt: 2, sessionId: 's-int', name: 'n', status: 'busy' }, + { kind: 'background', sessionId: '' }, + 'junk', + ])); + assert.equal(list.length, 2); + assert.deepEqual(list[0], { id: 'bc3fd129', sessionId: 's-bg', name: 'em', cwd: '/w', kind: 'background', state: 'working', status: 'idle', pid: 346590, startedAt: 1 }); + assert.equal(list[1].kind, 'interactive'); + assert.equal(list[1].id, null); + assert.equal(parseCliList('not json'), null); + assert.equal(parseCliList('{}'), null); +}); + +function fixture() { + const cli = [ + { id: 'aaaaaaaa', sessionId: 's-a', name: 'a', cwd: '/a', kind: 'background', state: 'working', status: 'idle', pid: 10, startedAt: 100 }, + { id: 'bbbbbbbb', sessionId: 's-b', name: 'b', cwd: '/b', kind: 'background', state: 'done', status: null, pid: null, startedAt: 50 }, + { id: null, sessionId: 's-own', name: 'own', cwd: '/o', kind: 'interactive', state: null, status: 'busy', pid: 20, startedAt: 70 }, + ]; + const jobs = new Map([ + ['aaaaaaaa', parseJobState(JSON.stringify({ state: 'done', detail: 'stale detail', tokens: 5, respawnFlags: ['--agent', 'fleet:em'] }))], + ['cccccccc', parseJobState(JSON.stringify({ state: 'stopped', detail: 'orphan' }))], + ]); + const descriptors = [ + { pid: 10, sessionId: 's-a', kind: 'bg', jobId: 'aaaaaaaa', agent: 'fleet:em', name: 'a', cwd: '/a', status: 'busy', startedAt: 100 }, + { pid: 20, sessionId: 's-own', kind: 'interactive', jobId: null, agent: null, name: 'own', cwd: '/o', status: 'busy', startedAt: 70 }, + { pid: 30, sessionId: 's-ext', kind: 'interactive', jobId: null, agent: null, name: 'ext', cwd: '/e', status: 'waiting', startedAt: 80 }, + { pid: 40, sessionId: 's-nojob', kind: 'bg', jobId: 'dddddddd', agent: null, name: 'x', cwd: '/x', status: 'idle', startedAt: 90 }, + ]; + return { cli, jobs, descriptors, isOwnPid: (pid) => pid === 20, isAttachedHere: (id) => id === 'aaaaaaaa' }; +} + +test('mergeRoster: the CLI list decides which jobs exist and their state; the file and the descriptor enrich', () => { + const roster = mergeRoster(fixture()); + const ids = roster.map(e => e.kind === 'background' ? e.id : e.sessionId); + assert.deepEqual(ids, ['aaaaaaaa', 'bbbbbbbb', 's-ext']); + const a = roster[0]; + assert.equal(a.state, 'working', 'the CLI state wins over the file'); + assert.equal(a.status, 'busy', 'the descriptor status wins over the CLI snapshot'); + assert.equal(a.detail, 'stale detail'); + assert.equal(a.tokens, 5); + assert.equal(a.agent, 'fleet:em'); + assert.equal(a.attachedHere, true); + assert.equal(roster[1].attachedHere, false); + assert.equal(roster[1].detail, null, 'a job without a file still lists'); + const ext = roster[2]; + assert.equal(ext.kind, 'interactive'); + assert.equal(ext.id, null); + assert.equal(ext.status, 'waiting'); +}); + +test('mergeRoster without the CLI lists the jobs on disk instead', () => { + const f = fixture(); + const roster = mergeRoster({ ...f, cli: null }); + assert.deepEqual(roster.filter(e => e.kind === 'background').map(e => e.id).sort(), ['aaaaaaaa', 'cccccccc']); + const a = roster.find(e => e.id === 'aaaaaaaa'); + assert.equal(a.state, 'done', 'file state stands when the CLI is unreachable'); + assert.equal(a.sessionId, 's-a', 'the descriptor supplies the session id'); + assert.equal(a.pid, 10); +}); + +test('dispatchArgs builds the argv in a fixed order and omits empty options', () => { + const r = dispatchArgs({ prompt: ' do the thing ', name: 'n1', agent: 'fleet:em', permissionMode: 'auto', addDirs: '/a, /b', cwd: '/proj' }); + assert.deepEqual(r, { ok: true, cwd: '/proj', args: ['--bg', '--name', 'n1', '--agent', 'fleet:em', '--permission-mode', 'auto', '--add-dir', '/a', '--add-dir', '/b', 'do the thing'] }); + const bare = dispatchArgs({ prompt: 'p', cwd: '/proj', name: '', agent: ' ', dangerouslySkipPermissions: true, permissionMode: 'auto' }); + assert.deepEqual(bare.args, ['--bg', '--dangerously-skip-permissions', 'p']); +}); + +test('dispatchArgs refuses an empty prompt, a missing cwd, and a prompt that looks like a flag', () => { + assert.equal(dispatchArgs({ prompt: '', cwd: '/p' }).ok, false); + assert.equal(dispatchArgs({ prompt: 'p' }).ok, false); + const flag = dispatchArgs({ prompt: '--help', cwd: '/p' }); + assert.equal(flag.ok, false); + assert.match(flag.error, /cannot start with/); +}); + +test('parseDispatchOutput finds an eight-hex id anywhere in the output, or returns null', () => { + assert.equal(parseDispatchOutput('Started background session de3dfd18\nattach with claude attach de3dfd18\n'), 'de3dfd18'); + assert.equal(parseDispatchOutput('de3dfd18'), 'de3dfd18'); + assert.equal(parseDispatchOutput('deadbeefcafe is not an id, nor is 12345'), null); + assert.equal(parseDispatchOutput(''), null); + assert.ok(JOB_ID_RE.test('de3dfd18')); + assert.ok(!JOB_ID_RE.test('DE3DFD18')); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `node --test test/bg-agents-roster.test.js` +Expected: FAIL with `Cannot find module '../bg-agents-roster'` + +- [ ] **Step 3: Write the module** + +```js +// bg-agents-roster.js — see .ai/contexts/bg-agents.md +'use strict'; + +const JOB_STATES = new Set(['working', 'done', 'stopped']); +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), + }; +} + +function parseCliList(text) { + let raw; + try { raw = JSON.parse(text); } catch { return null; } + 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 = 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, JOB_ID_RE, JOB_STATES, +}; +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `node --test test/bg-agents-roster.test.js` +Expected: PASS, 8 tests. If `parseDispatchOutput('deadbeefcafe …')` returns an id, the lookbehind/lookahead in `JOB_ID_IN_TEXT_RE` is wrong: it must reject a hex run longer than eight. + +- [ ] **Step 5: Commit** + +```bash +/usr/bin/git add bg-agents-roster.js test/bg-agents-roster.test.js +/usr/bin/git commit -m "(bg-agents): parse the daemon's job files and the CLI list into one roster" +``` + +--- + +### Task 2: Descriptor hooks in `cli-session-state.js`, and the canaries + +**Files:** +- Modify: `cli-session-state.js` +- Modify: `test/cli-session-state.test.js` (append) +- Modify: `test/canary-cli-session-state.test.js` (append) +- Create: `test/canary-bg-agents-files.test.js` + +**Interfaces:** +- Produces (new exports): `onDescriptorsChanged(listener) → unsubscribe`, `readAllDescriptors() → Descriptor[]`, `parseDescriptor(text) → Descriptor | null`, `ownProcessFilter(ptyPids) → (pid) => boolean`, where `Descriptor = { pid, sessionId, kind, jobId, agent, name, cwd, status, startedAt }`. +- Changes: `liveElsewhere` / `liveElsewhereMany` results gain `kind` and `jobId` (strings or null). + +- [ ] **Step 1: Append the failing tests to `test/cli-session-state.test.js`** + +```js +// --- Descriptor hooks for the agents view (see .ai/contexts/bg-agents.md) --- + +test('onDescriptorsChanged fires once per flushed batch, and the unsubscribe stops it', async () => { + const dir = mkTmp(); + try { + boot(dir, oneSession()); + let fired = 0; + const off = cliSessionState.onDescriptorsChanged(() => { fired++; }); + writeState(dir, 4242, { status: 'busy', kind: 'bg', jobId: 'aaaaaaaa' }); + writeState(dir, 4243, { status: 'idle', sessionId: 'sess-2' }); + await waitFor(() => fired >= 1); + await delay(SETTLE_MS); + assert.equal(fired, 1, 'two writes inside one FLUSH_MS window are one notification'); + off(); + writeState(dir, 4242, { status: 'idle', kind: 'bg', jobId: 'aaaaaaaa' }); + await delay(SETTLE_MS); + assert.equal(fired, 1); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } +}); + +test('readAllDescriptors returns the live descriptors with their kind, jobId and agent', () => { + const dir = mkTmp(); + try { + writeState(dir, 10, { status: 'idle', kind: 'bg', jobId: 'bc3fd129', agent: 'fleet:em', name: 'em', startedAt: 5 }); + writeState(dir, 11, { status: 'busy', kind: 'interactive', sessionId: 'sess-2' }); + writeState(dir, 12, { status: 'busy', kind: 'interactive', sessionId: 'sess-dead' }); + fs.writeFileSync(path.join(dir, '13.json'), '{not json', 'utf8'); + boot(dir, oneSession(), { isProcessAlive: (pid) => pid !== 12 }); + const all = cliSessionState.readAllDescriptors().sort((a, b) => a.pid - b.pid); + assert.deepEqual(all.map(d => d.pid), [10, 11]); + assert.deepEqual(all[0], { pid: 10, sessionId: 'sess-1', kind: 'bg', jobId: 'bc3fd129', agent: 'fleet:em', name: 'em', cwd: dir, status: 'idle', startedAt: 5 }); + assert.equal(all[1].kind, 'interactive'); + assert.equal(all[1].jobId, null); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } +}); + +test('liveElsewhere reports the descriptor kind and jobId, so a bg session can be attached instead of resumed', async () => { + const dir = mkTmp(); + try { + writeState(dir, 4242, { status: 'idle', kind: 'bg', jobId: 'bc3fd129' }); + boot(dir, new Map()); + const live = await cliSessionState.liveElsewhere('sess-1', () => false, () => []); + assert.equal(live.pid, 4242); + assert.equal(live.kind, 'bg'); + assert.equal(live.jobId, 'bc3fd129'); + writeState(dir, 4242, { status: 'idle' }); + const plain = await cliSessionState.liveElsewhere('sess-1', () => false, () => []); + assert.equal(plain.kind, null); + assert.equal(plain.jobId, null); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } +}); + +test('ownProcessFilter is exported and claims our own PTY pids', () => { + const dir = mkTmp(); + try { + boot(dir, new Map()); + const isOwn = cliSessionState.ownProcessFilter(() => [77]); + assert.equal(isOwn(77), true); + assert.equal(isOwn(78), false); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } +}); +``` + +- [ ] **Step 2: Run them to verify they fail** + +Run: `node --test test/cli-session-state.test.js` +Expected: the four new tests FAIL (`onDescriptorsChanged is not a function`, etc.); the existing ones pass. + +- [ ] **Step 3: Implement in `cli-session-state.js`** + +After `const lastProbeAt = new Map();` add: + +```js +const MAX_DESCRIPTOR_SCAN = 1000; +// Listeners told "the directory changed" after each flushed batch -- see .ai/contexts/bg-agents.md +const descriptorListeners = new Set(); +``` + +After `parseState` add: + +```js +// The descriptor subset the agents view reads -- see .ai/contexts/bg-agents.md +function parseDescriptor(text) { + let raw; + try { raw = JSON.parse(text); } catch { return null; } + if (!raw || typeof raw !== 'object') return null; + if (!Number.isInteger(raw.pid) || raw.pid <= 0) return null; + if (typeof raw.sessionId !== 'string' || !raw.sessionId) return null; + const s = (v) => (typeof v === 'string' && v ? v : null); + return { + pid: raw.pid, + sessionId: raw.sessionId, + kind: s(raw.kind), + jobId: s(raw.jobId), + agent: s(raw.agent), + name: s(raw.name), + cwd: s(raw.cwd), + status: KNOWN_STATUSES.has(raw.status) ? raw.status : null, + startedAt: Number.isFinite(raw.startedAt) ? raw.startedAt : null, + }; +} + +function readAllDescriptors() { + let names; + try { names = fs.readdirSync(dir); } catch { return []; } + const out = []; + let seen = 0; + for (const name of names) { + if (!STATE_FILE_RE.test(name)) continue; + if (++seen > MAX_DESCRIPTOR_SCAN) break; + let text; + try { text = fs.readFileSync(path.join(dir, name), 'utf8'); } catch { continue; } + const d = parseDescriptor(text); + if (d && isProcessAlive(d.pid)) out.push(d); + } + return out; +} + +function onDescriptorsChanged(listener) { + descriptorListeners.add(listener); + return () => { descriptorListeners.delete(listener); }; +} + +function notifyDescriptorsChanged() { + for (const listener of descriptorListeners) { + try { listener(); } catch (err) { log.warn(`[cli-state] descriptor listener failed: ${err.message}`); } + } +} +``` + +In `flush()`, after the `for` loop: `if (batch.length > 0) notifyDescriptorsChanged();` + +In `scanLiveProcesses`, the `found.set(raw.sessionId, {...})` object gains two fields: + +```js + kind: typeof raw.kind === 'string' && raw.kind ? raw.kind : null, + jobId: typeof raw.jobId === 'string' && raw.jobId ? raw.jobId : null, +``` + +Add to `module.exports`: `onDescriptorsChanged, readAllDescriptors, parseDescriptor, ownProcessFilter,`. + +`stop()` does not clear `descriptorListeners`: `init()` calls `stop()` once at startup before anyone subscribes, and the quit path has no subscriber to protect. + +- [ ] **Step 4: Run the tests** + +Run: `node --test test/cli-session-state.test.js test/resume-guard.test.js` +Expected: PASS. + +- [ ] **Step 5: Extend the descriptor canary** + +Append to `test/canary-cli-session-state.test.js`: + +```js +test('CANARY: a background worker descriptor still carries kind "bg" and its short job id (CLI 2.1.285, 2026-09-30)', (t) => { + const bg = []; + for (const name of listStateFiles()) { + try { + const raw = JSON.parse(fs.readFileSync(path.join(SESSIONS_DIR, name), 'utf8')); + if (raw && raw.kind === 'bg') bg.push({ name, raw }); + } catch {} + } + if (bg.length === 0) { + t.skip('no kind:"bg" descriptor on this machine — start one with `claude --bg` to pin the shape'); + return; + } + for (const { name, raw } of bg) { + const seen = `(${name}, CLI version ${raw.version || 'unknown'})`; + assert.match(String(raw.jobId), /^[0-9a-f]{8}$/, + `PINNED ASSUMPTION BROKEN: a bg descriptor used to carry "jobId", the eight-hex id that joins it to ~/.claude/jobs/ and to \`claude attach \` ${seen}`); + assert.ok(raw.agent === undefined || typeof raw.agent === 'string', + `PINNED ASSUMPTION BROKEN: "agent" used to be a string when present ${seen}`); + } +}); +``` + +- [ ] **Step 6: Write the jobs canary** + +```js +// test/canary-bg-agents-files.test.js — canary over an external dependency. +// +// Pins the observed shape of ~/.claude/jobs//state.json, written by the +// Claude CLI's daemon for every `claude --bg` session (CLI 2.1.285, Linux, +// 2026-09-30). bg-agents-roster.js reads it for the agents view. Not a +// documented interface: this test going red means the CLI changed, not that +// Switchboard broke. Skips wherever the directory is absent. +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { JOB_STATES } = require('../bg-agents-roster'); + +const JOBS_DIR = path.join(os.homedir(), '.claude', 'jobs'); + +function listStateFiles() { + try { + return fs.readdirSync(JOBS_DIR) + .filter(n => /^[0-9a-f]{8}$/.test(n)) + .map(n => path.join(JOBS_DIR, n, 'state.json')) + .filter(p => fs.existsSync(p)); + } catch { + return []; + } +} + +test('CANARY: the Claude CLI daemon still writes jobs//state.json in the shape the agents view reads', (t) => { + const files = listStateFiles(); + if (files.length === 0) { + t.skip(`no ${JOBS_DIR}//state.json on this machine — nothing to pin`); + return; + } + for (const file of files) { + let raw; + try { raw = JSON.parse(fs.readFileSync(file, 'utf8')); } catch { continue; } + const seen = `(${file})`; + assert.ok(JOB_STATES.has(raw.state), + `PINNED ASSUMPTION BROKEN: "state" used to be one of ${[...JOB_STATES].join(', ')} ${seen}`); + assert.ok(raw.detail === undefined || raw.detail === null || typeof raw.detail === 'string', + `PINNED ASSUMPTION BROKEN: "detail" used to be a string, the one-line status the view shows ${seen}`); + assert.ok(raw.respawnFlags === undefined || Array.isArray(raw.respawnFlags), + `PINNED ASSUMPTION BROKEN: "respawnFlags" used to be the original argv (--agent, --model, --name) ${seen}`); + assert.ok(raw.linkScanPath === undefined || /\.jsonl$/.test(String(raw.linkScanPath)), + `PINNED ASSUMPTION BROKEN: "linkScanPath" used to end in the session's .jsonl ${seen}`); + assert.ok(raw.fan === undefined || raw.fan === null || Array.isArray(raw.fan), + `PINNED ASSUMPTION BROKEN: "fan" used to be an array of {id, kind, label, startedAt, doneAt} ${seen}`); + } +}); +``` + +- [ ] **Step 7: Run both canaries** + +Run: `node --test test/canary-cli-session-state.test.js test/canary-bg-agents-files.test.js` +Expected: PASS (or skip on a machine without the files). + +- [ ] **Step 8: Commit** + +```bash +/usr/bin/git add cli-session-state.js test/cli-session-state.test.js test/canary-cli-session-state.test.js test/canary-bg-agents-files.test.js +/usr/bin/git commit -m "(cli-state): expose the session descriptors and their bg job id to the agents view" +``` + +--- + +### Task 3: `detachPty` in `pty-ops.js` + +**Files:** +- Modify: `pty-ops.js` +- Create: `test/pty-ops-detach.test.js` + +**Interfaces:** +- Produces: `detachPty(session, sessionId, { graceMs = 2000, schedule = setTimeout } = {}) → boolean` — writes `\x1a`, schedules a kill after `graceMs` unless `session.exited` became true; falls back to `killPty` when the write itself fails. + +- [ ] **Step 1: Write the failing test** + +```js +// test/pty-ops-detach.test.js — detaching an attach tab must send Ctrl+Z and +// only kill the client if it does not leave by itself. See .ai/contexts/bg-agents.md. +'use strict'; +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { detachPty } = require('../pty-ops'); + +function fakeSession() { + const calls = { writes: [], kills: 0 }; + const session = { exited: false, pty: { write: (d) => calls.writes.push(d), kill: () => { calls.kills++; } } }; + return { session, calls }; +} + +test('detach writes Ctrl+Z and, when the client exits in time, never kills', () => { + const { session, calls } = fakeSession(); + const timers = []; + const ok = detachPty(session, 's1', { graceMs: 2000, schedule: (fn, ms) => { timers.push({ fn, ms }); return { unref() {} }; } }); + assert.equal(ok, true); + assert.deepEqual(calls.writes, ['\x1a']); + assert.equal(timers.length, 1); + assert.equal(timers[0].ms, 2000); + session.exited = true; + timers[0].fn(); + assert.equal(calls.kills, 0); +}); + +test('detach kills once the grace period passes with the client still attached', () => { + const { session, calls } = fakeSession(); + const timers = []; + detachPty(session, 's1', { schedule: (fn) => { timers.push(fn); return {}; } }); + timers[0](); + assert.equal(calls.kills, 1); +}); + +test('detach on a pty that refuses the write falls back to a kill', () => { + const calls = { kills: 0 }; + const session = { exited: false, pty: { write: () => { throw new Error('closed'); }, kill: () => { calls.kills++; } } }; + const ok = detachPty(session, 's1', { schedule: () => { throw new Error('must not schedule'); } }); + assert.equal(ok, true); + assert.equal(calls.kills, 1); +}); +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `node --test test/pty-ops-detach.test.js` +Expected: FAIL, `detachPty is not a function`. + +- [ ] **Step 3: Implement** + +In `pty-ops.js`, after `writePty`: + +```js +// Ctrl+Z asks `claude attach` to leave; the session it showed keeps running -- see .ai/contexts/bg-agents.md +function detachPty(session, sessionId, { graceMs = 2000, schedule = setTimeout } = {}) { + const wrote = withPty(session, 'detach', (pty) => pty.write('\x1a'), sessionId); + if (!wrote) return killPty(session, sessionId); + const timer = schedule(() => { + if (!session.exited) killPty(session, sessionId); + }, graceMs); + if (timer && typeof timer.unref === 'function') timer.unref(); + return true; +} +``` + +Add `detachPty` to `module.exports`. + +- [ ] **Step 4: Run it** + +Run: `node --test test/pty-ops-detach.test.js` +Expected: PASS, 3 tests. + +- [ ] **Step 5: Commit** + +```bash +/usr/bin/git add pty-ops.js test/pty-ops-detach.test.js +/usr/bin/git commit -m "(pty): detach an attach client with Ctrl+Z before ever killing it" +``` + +--- + +### Task 4: The stateful roster (`bg-agents.js`) + +**Files:** +- Create: `bg-agents.js` +- Test: `test/bg-agents.test.js` + +**Interfaces:** +- Consumes: Task 1's roster functions; Task 2's `cliSessionState.{onDescriptorsChanged, readAllDescriptors, ensureWatching}`. +- Produces: + - `init({ jobsDir?, log, runClaude, cliSessionState, makeIsOwnPid, isAttachedHere, homeDir? })` where `runClaude(argv, { cwd, timeout }) → Promise<{ code, stdout, stderr }>` + - `start() → boolean`, `stop()`, `onChange(listener) → unsubscribe` (listener gets `{ roster, daemonReachable }`), `getSnapshot() → { roster, daemonReachable }`, `reconcile() → Promise`, `runVerb(verb, id) → Promise<{ ok, error? }>`, `dispatch(fields) → Promise<{ ok, id?, error? }>` + - Constants `DEFAULT_JOBS_DIR`, `FLUSH_MS = 250`, `MAX_JOBS = 200`, `LIST_TIMEOUT_MS = 5000`, `VERB_TIMEOUT_MS = 15000` + +- [ ] **Step 1: Write the failing tests** + +```js +// test/bg-agents.test.js — the stateful half of the agents view: watchers over +// ~/.claude/jobs, reconciliation through the CLI, verbs. See .ai/contexts/bg-agents.md. +'use strict'; +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const bgAgents = require('../bg-agents'); + +function mkTmp() { + return fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'sw-bg-agents-'))); +} +const silentLog = { info() {}, warn() {}, error() {}, debug() {} }; +const delay = (ms) => new Promise(r => setTimeout(r, ms)); +function waitFor(fn, maxMs = 4000) { + return new Promise((resolve, reject) => { + const start = Date.now(); + (function poll() { + if (fn()) return resolve(); + if (Date.now() - start > maxMs) return reject(new Error('timed out')); + setTimeout(poll, 20); + })(); + }); +} + +function writeJob(dir, id, state) { + fs.mkdirSync(path.join(dir, id), { recursive: true }); + fs.writeFileSync(path.join(dir, id, 'state.json'), JSON.stringify(state), 'utf8'); +} + +const CLI_LIST = [ + { id: 'aaaaaaaa', sessionId: 's-a', name: 'a', cwd: '/a', kind: 'background', startedAt: 1, state: 'working', status: 'idle', pid: 10 }, + { id: 'bbbbbbbb', sessionId: 's-b', name: 'b', cwd: '/b', kind: 'background', startedAt: 2, state: 'done' }, +]; + +function fakeCli(overrides = {}) { + const calls = []; + const runClaude = async (argv, opts) => { + calls.push({ argv, opts }); + if (overrides.fail) return { code: 1, stdout: '', stderr: 'boom' }; + if (argv[0] === 'agents') return { code: 0, stdout: JSON.stringify(overrides.list || CLI_LIST), stderr: '' }; + if (argv[0] === '--bg') return { code: 0, stdout: 'Started background session cccccccc\n', stderr: '' }; + return { code: 0, stdout: '', stderr: '' }; + }; + return { calls, runClaude }; +} + +function fakeSessionState(descriptors = []) { + const listeners = new Set(); + return { + listeners, + onDescriptorsChanged: (l) => { listeners.add(l); return () => listeners.delete(l); }, + readAllDescriptors: () => descriptors, + ensureWatching: () => true, + fire() { for (const l of listeners) l(); }, + }; +} + +function boot(dir, { cli = fakeCli(), sessionState = fakeSessionState(), attached = () => false } = {}) { + bgAgents.init({ + jobsDir: dir, log: silentLog, runClaude: cli.runClaude, cliSessionState: sessionState, + makeIsOwnPid: () => () => false, isAttachedHere: attached, + }); + return { cli, sessionState }; +} + +test.afterEach(() => bgAgents.stop()); + +test('reconcile runs `claude agents --json --all`, merges the job files, and reports the daemon reachable', async () => { + const dir = mkTmp(); + try { + writeJob(dir, 'aaaaaaaa', { state: 'working', detail: 'reading rules', tokens: 42 }); + const { cli } = boot(dir); + assert.equal(bgAgents.start(), true); + const snap = await bgAgents.reconcile(); + assert.deepEqual(cli.calls[0].argv, ['agents', '--json', '--all']); + assert.equal(cli.calls[0].opts.timeout, bgAgents.LIST_TIMEOUT_MS); + assert.equal(snap.daemonReachable, true); + assert.deepEqual(snap.roster.map(e => e.id), ['aaaaaaaa', 'bbbbbbbb']); + assert.equal(snap.roster[0].detail, 'reading rules'); + assert.equal(snap.roster[0].tokens, 42); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } +}); + +test('a state.json rewrite reaches listeners once, coalesced, without another CLI call', async () => { + const dir = mkTmp(); + try { + writeJob(dir, 'aaaaaaaa', { state: 'working', detail: 'one' }); + const { cli } = boot(dir); + bgAgents.start(); + await bgAgents.reconcile(); + const seen = []; + bgAgents.onChange((snap) => seen.push(snap.roster.find(e => e.id === 'aaaaaaaa').detail)); + const callsBefore = cli.calls.length; + writeJob(dir, 'aaaaaaaa', { state: 'working', detail: 'two' }); + await waitFor(() => seen.includes('two')); + await delay(bgAgents.FLUSH_MS * 2); + assert.deepEqual(seen, ['two']); + assert.equal(cli.calls.length, callsBefore, 'a file change never spawns the CLI'); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } +}); + +test('a job directory that appears after start is watched too', async () => { + const dir = mkTmp(); + try { + boot(dir); + bgAgents.start(); + await bgAgents.reconcile(); + const seen = []; + bgAgents.onChange((snap) => seen.push((snap.roster.find(e => e.id === 'bbbbbbbb') || {}).detail)); + writeJob(dir, 'bbbbbbbb', { state: 'done', detail: 'late' }); + await waitFor(() => seen.includes('late')); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } +}); + +test('an empty state.json (mid-rewrite) keeps the previous value', async () => { + const dir = mkTmp(); + try { + writeJob(dir, 'aaaaaaaa', { state: 'working', detail: 'kept' }); + boot(dir); + bgAgents.start(); + await bgAgents.reconcile(); + fs.writeFileSync(path.join(dir, 'aaaaaaaa', 'state.json'), '', 'utf8'); + await delay(bgAgents.FLUSH_MS * 3); + assert.equal(bgAgents.getSnapshot().roster[0].detail, 'kept'); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } +}); + +test('a descriptor change rebuilds the roster from readAllDescriptors', async () => { + const dir = mkTmp(); + try { + const descriptors = []; + const sessionState = fakeSessionState(descriptors); + boot(dir, { sessionState }); + bgAgents.start(); + await bgAgents.reconcile(); + const seen = []; + bgAgents.onChange((snap) => seen.push(snap.roster.map(e => e.sessionId).join(','))); + descriptors.push({ pid: 30, sessionId: 's-ext', kind: 'interactive', jobId: null, agent: null, name: 'ext', cwd: '/e', status: 'busy', startedAt: 3 }); + sessionState.fire(); + await waitFor(() => seen.some(s => s.includes('s-ext'))); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } +}); + +test('when the CLI fails the roster comes from the files and the daemon is reported unreachable', async () => { + const dir = mkTmp(); + try { + writeJob(dir, 'cccccccc', { state: 'stopped', detail: 'from disk' }); + boot(dir, { cli: fakeCli({ fail: true }) }); + bgAgents.start(); + const snap = await bgAgents.reconcile(); + assert.equal(snap.daemonReachable, false); + assert.deepEqual(snap.roster.map(e => e.id), ['cccccccc']); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } +}); + +test('runVerb spawns `claude ` in the session cwd when it exists, then reconciles', async () => { + const dir = mkTmp(); + try { + const list = [{ ...CLI_LIST[0], cwd: dir }]; + const { cli } = boot(dir, { cli: fakeCli({ list }) }); + bgAgents.start(); + await bgAgents.reconcile(); + const r = await bgAgents.runVerb('stop', 'aaaaaaaa'); + assert.deepEqual(r, { ok: true }); + const verbCall = cli.calls.find(c => c.argv[0] === 'stop'); + assert.deepEqual(verbCall.argv, ['stop', 'aaaaaaaa']); + assert.equal(verbCall.opts.cwd, dir); + assert.equal(verbCall.opts.timeout, bgAgents.VERB_TIMEOUT_MS); + assert.equal(cli.calls[cli.calls.length - 1].argv[0], 'agents', 'a verb is followed by a reconcile'); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } +}); + +test('runVerb refuses an unknown verb or a malformed id before spawning anything', async () => { + const dir = mkTmp(); + try { + const { cli } = boot(dir); + bgAgents.start(); + assert.equal((await bgAgents.runVerb('kill', 'aaaaaaaa')).ok, false); + assert.equal((await bgAgents.runVerb('stop', '--all')).ok, false); + assert.equal((await bgAgents.runVerb('rm', 'AAAAAAAA')).ok, false); + assert.equal(cli.calls.length, 0); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } +}); + +test('runVerb reports the CLI stderr when it fails', async () => { + const dir = mkTmp(); + try { + boot(dir, { cli: fakeCli({ fail: true }) }); + bgAgents.start(); + const r = await bgAgents.runVerb('rm', 'aaaaaaaa'); + assert.equal(r.ok, false); + assert.equal(r.error, 'boom'); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } +}); + +test('dispatch runs `claude --bg …` in the project directory and returns the printed id', async () => { + const dir = mkTmp(); + try { + const { cli } = boot(dir); + bgAgents.start(); + const r = await bgAgents.dispatch({ prompt: 'hello', name: 'n', cwd: dir }); + assert.deepEqual(r, { ok: true, id: 'cccccccc' }); + const call = cli.calls.find(c => c.argv[0] === '--bg'); + assert.deepEqual(call.argv, ['--bg', '--name', 'n', 'hello']); + assert.equal(call.opts.cwd, dir); + const missing = await bgAgents.dispatch({ prompt: 'hello', cwd: path.join(dir, 'nope') }); + assert.equal(missing.ok, false); + assert.match(missing.error, /no longer exists/); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } +}); + +test('stop releases the watchers: a later write reaches nobody', async () => { + const dir = mkTmp(); + try { + writeJob(dir, 'aaaaaaaa', { state: 'working' }); + boot(dir); + bgAgents.start(); + let fired = 0; + bgAgents.onChange(() => fired++); + bgAgents.stop(); + writeJob(dir, 'aaaaaaaa', { state: 'done' }); + await delay(bgAgents.FLUSH_MS * 3); + assert.equal(fired, 0); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } +}); +``` + +- [ ] **Step 2: Run them to verify they fail** + +Run: `node --test test/bg-agents.test.js` +Expected: FAIL, `Cannot find module '../bg-agents'`. + +- [ ] **Step 3: Write the module** + +```js +// bg-agents.js — see .ai/contexts/bg-agents.md +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { + parseJobState, parseCliList, mergeRoster, dispatchArgs, parseDispatchOutput, JOB_ID_RE, +} = require('./bg-agents-roster'); + +const DEFAULT_JOBS_DIR = path.join(os.homedir(), '.claude', 'jobs'); +const FLUSH_MS = 250; +const MAX_JOBS = 200; +const LIST_TIMEOUT_MS = 5000; +const VERB_TIMEOUT_MS = 15000; +const VERBS = new Set(['stop', 'respawn', 'rm']); + +let jobsDir = DEFAULT_JOBS_DIR; +let homeDir = os.homedir(); +let log = { info() {}, warn() {}, error() {}, debug() {} }; +let runClaude = null; +let cliSessionState = null; +let makeIsOwnPid = () => () => false; +let isAttachedHere = () => false; + +let started = false; +let dirWatcher = null; +const jobWatchers = new Map(); +const jobs = new Map(); +let cliList = null; +let daemonReachable = false; +let roster = []; +let flushTimer = null; +let unsubscribeDescriptors = null; +const listeners = new Set(); + +function init(ctx) { + stop(); + jobsDir = ctx.jobsDir || DEFAULT_JOBS_DIR; + homeDir = ctx.homeDir || os.homedir(); + log = ctx.log || log; + runClaude = ctx.runClaude; + cliSessionState = ctx.cliSessionState; + makeIsOwnPid = ctx.makeIsOwnPid || (() => () => false); + isAttachedHere = ctx.isAttachedHere || (() => false); +} + +function onChange(listener) { + listeners.add(listener); + return () => { listeners.delete(listener); }; +} + +function getSnapshot() { + return { roster, daemonReachable }; +} + +function emit() { + const snapshot = getSnapshot(); + for (const listener of listeners) { + try { listener(snapshot); } catch (err) { log.warn(`[bg-agents] listener failed: ${err.message}`); } + } +} + +function rebuild() { + let descriptors = []; + try { descriptors = cliSessionState ? cliSessionState.readAllDescriptors() : []; } catch {} + roster = mergeRoster({ + cli: daemonReachable ? cliList : null, + jobs, + descriptors, + isOwnPid: makeIsOwnPid(), + isAttachedHere, + }); + emit(); +} + +function scheduleRebuild() { + if (!started || flushTimer) return; + flushTimer = setTimeout(() => { flushTimer = null; rebuild(); }, FLUSH_MS); + if (typeof flushTimer.unref === 'function') flushTimer.unref(); +} + +function readJob(id) { + let text; + try { text = fs.readFileSync(path.join(jobsDir, id, 'state.json'), 'utf8'); } catch { return; } + const job = parseJobState(text); + if (job) jobs.set(id, job); + else log.debug(`[bg-agents] ${id}/state.json unreadable, keeping the previous value`); +} + +function watchJob(id) { + if (jobWatchers.has(id)) return; + readJob(id); + try { + const watcher = fs.watch(path.join(jobsDir, id), (_eventType, filename) => { + if (filename && filename !== 'state.json') return; + readJob(id); + scheduleRebuild(); + }); + watcher.on('error', () => { try { watcher.close(); } catch {} jobWatchers.delete(id); }); + jobWatchers.set(id, watcher); + } catch (err) { + log.debug(`[bg-agents] cannot watch ${id}: ${err.message}`); + } +} + +function syncJobWatchers() { + let names; + try { names = fs.readdirSync(jobsDir); } catch { names = []; } + const ids = names.filter(n => JOB_ID_RE.test(n)).sort().slice(0, MAX_JOBS); + const wanted = new Set(ids); + for (const [id, watcher] of jobWatchers) { + if (wanted.has(id)) continue; + try { watcher.close(); } catch {} + jobWatchers.delete(id); + jobs.delete(id); + } + for (const id of ids) watchJob(id); + scheduleRebuild(); +} + +function start() { + if (started) return true; + started = true; + try { + dirWatcher = fs.watch(jobsDir, () => syncJobWatchers()); + dirWatcher.on('error', (err) => { log.warn(`[bg-agents] jobs watcher error: ${err.message}`); }); + } catch (err) { + dirWatcher = null; + log.debug(`[bg-agents] cannot watch ${jobsDir}: ${err.message}`); + } + syncJobWatchers(); + if (cliSessionState) { + unsubscribeDescriptors = cliSessionState.onDescriptorsChanged(scheduleRebuild); + try { cliSessionState.ensureWatching(); } catch {} + } + return true; +} + +function stop() { + started = false; + if (dirWatcher) { try { dirWatcher.close(); } catch {} dirWatcher = null; } + for (const watcher of jobWatchers.values()) { try { watcher.close(); } catch {} } + jobWatchers.clear(); + jobs.clear(); + if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; } + if (unsubscribeDescriptors) { unsubscribeDescriptors(); unsubscribeDescriptors = null; } + listeners.clear(); + cliList = null; + daemonReachable = false; + roster = []; +} + +async function run(argv, opts) { + if (typeof runClaude !== 'function') return { code: null, stdout: '', stderr: 'claude runner not configured' }; + try { + return await runClaude(argv, opts); + } catch (err) { + return { code: null, stdout: '', stderr: err && err.message ? err.message : String(err) }; + } +} + +async function reconcile() { + const result = await run(['agents', '--json', '--all'], { cwd: homeDir, timeout: LIST_TIMEOUT_MS }); + const list = result.code === 0 ? parseCliList(result.stdout) : null; + if (list) { + cliList = list; + daemonReachable = true; + syncJobWatchers(); + } else { + cliList = null; + daemonReachable = false; + log.debug(`[bg-agents] claude agents --json failed: code=${result.code} ${String(result.stderr).trim().slice(0, 200)}`); + } + rebuild(); + return getSnapshot(); +} + +function cwdFor(id) { + const entry = roster.find(e => e.kind === 'background' && e.id === id); + if (entry && entry.cwd && fs.existsSync(entry.cwd)) return entry.cwd; + return homeDir; +} + +async function runVerb(verb, id) { + if (!VERBS.has(verb)) return { ok: false, error: `unknown verb: ${String(verb)}` }; + if (typeof id !== 'string' || !JOB_ID_RE.test(id)) return { ok: false, error: 'invalid background session id' }; + const result = await run([verb, id], { cwd: cwdFor(id), timeout: VERB_TIMEOUT_MS }); + const ok = result.code === 0; + const error = ok ? null : (String(result.stderr).trim() || `claude ${verb} exited with ${result.code}`); + await reconcile(); + return ok ? { ok: true } : { ok: false, error }; +} + +async function dispatch(fields) { + const built = dispatchArgs(fields); + if (!built.ok) return { ok: false, error: built.error }; + if (!fs.existsSync(built.cwd)) return { ok: false, error: `project directory no longer exists: ${built.cwd}` }; + const result = await run(built.args, { cwd: built.cwd, timeout: VERB_TIMEOUT_MS }); + if (result.code !== 0) { + return { ok: false, error: String(result.stderr).trim() || `claude --bg exited with ${result.code}` }; + } + const id = parseDispatchOutput(result.stdout); + await reconcile(); + return { ok: true, id }; +} + +module.exports = { + init, start, stop, onChange, getSnapshot, reconcile, runVerb, dispatch, + DEFAULT_JOBS_DIR, FLUSH_MS, MAX_JOBS, LIST_TIMEOUT_MS, VERB_TIMEOUT_MS, +}; +``` + +- [ ] **Step 4: Run the tests** + +Run: `node --test test/bg-agents.test.js` +Expected: PASS, 11 tests. If "a job directory that appears after start" is flaky, the directory watcher fired before `state.json` existed: `watchJob` reads nothing then, and the per-directory watcher catches the file's creation — check `readJob` is called from that watcher for a `filename` of `null` too (it is: only a non-null, different name returns early). + +- [ ] **Step 5: Commit** + +```bash +/usr/bin/git add bg-agents.js test/bg-agents.test.js +/usr/bin/git commit -m "(bg-agents): keep a roster of the daemon's sessions from its files, reconciled by the CLI" +``` + +--- + +### Task 5: Main-process wiring: IPC, attach, detach, preload + +**Files:** +- Create: `bg-agents-ipc.js` +- Modify: `main.js` (imports at the top; a `runClaudeCommand` helper before `open-terminal`; the attach branch inside `open-terminal`; `stop-session`; the `closed` handler; module wiring after `sessions-live-elsewhere`) +- Modify: `preload.js` +- Create: `test/bg-agents-ipc.test.js` +- Create: `test/open-terminal-attach.test.js` + +**Interfaces:** +- Consumes: `bgAgents` (Task 4), `detachPty` (Task 3). +- Produces: + - IPC `get-bg-agents` → `{ roster, daemonReachable }` (arms the watchers, reconciles); `bg-agent-verb (verb, id)` → `{ ok, error? }`; `dispatch-bg-agent (fields)` → `{ ok, id?, error? }`; event `bg-agents-changed` with `{ roster, daemonReachable }`. + - `open-terminal` accepts `sessionOptions = { type: 'attach', jobId, cwd }` and runs `claude attach `; the session record carries `isAttach: true, attachJobId`. + - `stop-session` on an attach session detaches and returns `{ ok: true, detached: true }`. + - `preload.js`: `getBgAgents()`, `bgAgentVerb(verb, id)`, `dispatchBgAgent(fields)`, `onBgAgentsChanged(cb)`. + +- [ ] **Step 1: Write the failing IPC test** + +```js +// test/bg-agents-ipc.test.js — the three handlers and the push. See .ai/contexts/bg-agents.md. +'use strict'; +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { init } = require('../bg-agents-ipc'); + +function fakeIpc() { + const handlers = new Map(); + return { handlers, ipcMain: { handle: (name, fn) => handlers.set(name, fn) } }; +} + +function fakeBgAgents() { + const calls = []; + let listener = null; + return { + calls, + start: () => { calls.push('start'); return true; }, + reconcile: async () => { calls.push('reconcile'); return { roster: [], daemonReachable: true }; }, + runVerb: async (verb, id) => { calls.push(['verb', verb, id]); return { ok: true }; }, + dispatch: async (fields) => { calls.push(['dispatch', fields]); return { ok: true, id: 'aaaaaaaa' }; }, + onChange: (l) => { listener = l; return () => { listener = null; }; }, + fire: (snap) => listener && listener(snap), + }; +} + +test('get-bg-agents arms the watchers then reconciles; the verbs pass straight through', async () => { + const { handlers, ipcMain } = fakeIpc(); + const bg = fakeBgAgents(); + init({ ipcMain, bgAgents: bg, getMainWindow: () => null, log: { warn() {} } }); + assert.deepEqual(await handlers.get('get-bg-agents')({}), { roster: [], daemonReachable: true }); + assert.deepEqual(bg.calls, ['start', 'reconcile']); + assert.deepEqual(await handlers.get('bg-agent-verb')({}, 'stop', 'aaaaaaaa'), { ok: true }); + assert.deepEqual(await handlers.get('dispatch-bg-agent')({}, { prompt: 'p', cwd: '/x' }), { ok: true, id: 'aaaaaaaa' }); + assert.deepEqual(bg.calls.slice(2), [['verb', 'stop', 'aaaaaaaa'], ['dispatch', { prompt: 'p', cwd: '/x' }]]); +}); + +test('a roster change is pushed to the window on bg-agents-changed, and skipped when the window is gone', () => { + const { ipcMain } = fakeIpc(); + const bg = fakeBgAgents(); + const sent = []; + let window = { isDestroyed: () => false, webContents: { send: (ch, payload) => sent.push([ch, payload]) } }; + init({ ipcMain, bgAgents: bg, getMainWindow: () => window, log: { warn() {} } }); + bg.fire({ roster: [{ id: 'aaaaaaaa' }], daemonReachable: true }); + assert.deepEqual(sent, [['bg-agents-changed', { roster: [{ id: 'aaaaaaaa' }], daemonReachable: true }]]); + window = null; + bg.fire({ roster: [], daemonReachable: false }); + assert.equal(sent.length, 1); +}); +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `node --test test/bg-agents-ipc.test.js` +Expected: FAIL, `Cannot find module '../bg-agents-ipc'`. + +- [ ] **Step 3: Write `bg-agents-ipc.js`** + +```js +// bg-agents-ipc.js — see .ai/contexts/bg-agents.md and .ai/contexts/ipc-bridge.md +'use strict'; + +function init({ ipcMain, bgAgents, getMainWindow, log }) { + ipcMain.handle('get-bg-agents', async () => { + 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)); + bgAgents.onChange((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}`); } + }); +} + +module.exports = { init }; +``` + +- [ ] **Step 4: Run it** + +Run: `node --test test/bg-agents-ipc.test.js` +Expected: PASS, 2 tests. + +- [ ] **Step 5: Write the failing source-level pins for `main.js`** + +```js +// test/open-terminal-attach.test.js — main.js cannot be required in a test +// (it boots Electron), so the attach branch of open-terminal, the detach in +// stop-session and the teardown are pinned at source level, the technique of +// test/open-session-terminal.test.js. See .ai/contexts/bg-agents.md. +'use strict'; +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const MAIN = fs.readFileSync(path.join(__dirname, '..', 'main.js'), 'utf8'); +const PRELOAD = fs.readFileSync(path.join(__dirname, '..', 'preload.js'), 'utf8'); + +test('open-terminal builds `claude attach ` for an attach session and never a --resume', () => { + assert.match(MAIN, /const isAttach = sessionOptions\?\.type === 'attach';/); + assert.match(MAIN, /claudeArgs\.push\('attach', attachJobId\);/); + assert.match(MAIN, /if \(!isAttach && sessionOptions\?\.sandbox\)/); + assert.match(MAIN, /if \(!isAttach && sessionOptions\?\.preLaunchCmd\)/); + assert.match(MAIN, /if \(!isAttach && sessionOptions\?\.mcpEmulation !== false\)/); + assert.match(MAIN, /isAttach, attachJobId,/, 'the session record must carry both fields'); +}); + +test('an attach job id is validated against the eight-hex shape before anything is spawned', () => { + assert.match(MAIN, /JOB_ID_RE\.test\(String\(sessionOptions\.jobId\)\)/); +}); + +test('stop-session detaches an attach session instead of killing it', () => { + const idx = MAIN.indexOf("ipcMain.handle('stop-session'"); + const body = MAIN.slice(idx, idx + 600); + assert.match(body, /if \(session\.isAttach\)/); + assert.match(body, /detachPty\(session, sessionId\)/); + assert.match(body, /detached: true/); +}); + +test('the window closing releases the agents watchers', () => { + const idx = MAIN.indexOf("mainWindow.on('closed'"); + assert.match(MAIN.slice(idx, idx + 900), /bgAgents\.stop\(\);/); +}); + +test('preload exposes the four agents-view entries', () => { + for (const name of ['getBgAgents', 'bgAgentVerb', 'dispatchBgAgent', 'onBgAgentsChanged']) { + assert.ok(PRELOAD.includes(name + ':'), `${name} missing from preload.js`); + } + assert.match(PRELOAD, /ipcRenderer\.on\('bg-agents-changed'/); +}); +``` + +- [ ] **Step 6: Run it to verify it fails** + +Run: `node --test test/open-terminal-attach.test.js` +Expected: 5 FAIL. + +- [ ] **Step 7: Edit `main.js`** + +Line 3, the `child_process` import becomes: + +```js +const { execFile, spawn: spawnChild } = require('child_process'); +``` + +Line 83, the pty-ops import gains `detachPty`: + +```js +const { setPtyOpLogger, resizePty, killPty, detachPty, ptyExitSignalName } = require('./pty-ops'); +``` + +Next to the other top-level requires (after line 83), add: + +```js +const { JOB_ID_RE } = require('./bg-agents-roster'); +``` + +`cleanPtyEnv` is a top-level const (`main.js:46`) and `getSetting` a top-level import (`main.js:149`), so the helper below can use them. Immediately before `ipcMain.handle('open-terminal'` (line 2272), add the runner the agents module uses. It is the scheduler's spawn path (login shell, quoted argv) with captured output and a timeout: + +```js +// Run `claude ` to completion through the login shell -- see .ai/contexts/bg-agents.md +function runClaudeCommand(claudeArgv, { cwd, timeout }) { + return new Promise((resolve) => { + const globalSettings = getSetting('global') || {}; + const profile = resolveShell(globalSettings.shellProfile || SETTING_DEFAULTS.shellProfile); + const shell = profile.path; + const args = shellArgs(shell, 'claude ' + quoteArgvForShell(shell, claudeArgv), profile.args || []); + let stdout = ''; + let stderr = ''; + let settled = false; + const finish = (code, err) => { + if (settled) return; + settled = true; + resolve({ code, stdout, stderr: err ? `${stderr}${err.message}` : stderr }); + }; + let child; + try { + child = spawnChild(shell, args, { + cwd, stdio: ['ignore', 'pipe', 'pipe'], env: { ...cleanPtyEnv, FORCE_COLOR: '0' }, windowsHide: true, + }); + } catch (err) { + finish(null, err); + return; + } + const timer = setTimeout(() => { + try { child.kill('SIGKILL'); } catch {} + finish(null, new Error(`claude ${claudeArgv[0]} timed out after ${timeout} ms`)); + }, timeout); + child.stdout.on('data', (d) => { stdout += d.toString(); }); + child.stderr.on('data', (d) => { stderr += d.toString(); }); + child.on('error', (err) => { clearTimeout(timer); finish(null, err); }); + child.on('exit', (code) => { clearTimeout(timer); finish(code); }); + }); +} +``` + +Inside `open-terminal`: + +(a) The resume-cwd block at line 2347 must not run for an attach: + +```js + if (resumeSourceId && sessionOptions?.type !== 'terminal' && sessionOptions?.type !== 'attach') { +``` + +(b) Right after that block (before the `panelOwnerId` comment) add: + +```js + // see .ai/contexts/bg-agents.md ("Attach") + const isAttach = sessionOptions?.type === 'attach'; + let attachJobId = null; + if (isAttach) { + if (!JOB_ID_RE.test(String(sessionOptions.jobId))) return { ok: false, error: 'invalid background session id' }; + attachJobId = String(sessionOptions.jobId); + if (typeof sessionOptions.cwd === 'string' && sessionOptions.cwd) spawnCwd = sessionOptions.cwd; + } +``` + +(c) In the `else` branch that builds the claude command, wrap the existing args logic: + +```js + const claudeArgs = []; + if (isAttach) { + claudeArgs.push('attach', attachJobId); + } else { + // (the existing block from `const startsFresh = …` through the + // `--append-system-prompt` push moves here, unchanged) + } +``` + +and guard the three post-processing blocks: + +```js + if (!isAttach && sessionOptions?.sandbox) { + … + if (!isAttach && sessionOptions?.preLaunchCmd) { + … + if (!isAttach && sessionOptions?.mcpEmulation !== false) { +``` + +(d) The session record (line 2598) gains, after `isPlainTerminal, panelFor: panelOwnerId, forkFrom: …,`: + +```js + isAttach, attachJobId, +``` + +`stop-session` (line 1689) becomes: + +```js +ipcMain.handle('stop-session', (_event, sessionId) => { + const session = activeSessions.get(sessionId); + if (!session || session.exited) return { ok: false, error: 'not running' }; + session.stopRequested = true; + // see .ai/contexts/bg-agents.md ("Detach") + if (session.isAttach) { + detachPty(session, sessionId); + return { ok: true, detached: true }; + } + killPty(session, sessionId); + return { ok: true }; +}); +``` + +In the `closed` handler (line 412), after `closeAllFileWatchers();` add `bgAgents.stop();`. `bgAgents` is declared later in the file with `const`; the handler runs long after module evaluation, so the reference is fine (the same holds for `changesWatchers` there). + +After the `sessions-live-elsewhere` handler (line 2861) add: + +```js +// see .ai/contexts/bg-agents.md +const bgAgents = require('./bg-agents'); +bgAgents.init({ + log, + runClaude: runClaudeCommand, + cliSessionState, + makeIsOwnPid: () => cliSessionState.ownProcessFilter(ptyPids), + isAttachedHere: (jobId) => { + for (const session of activeSessions.values()) { + if (session && !session.exited && session.isAttach && session.attachJobId === jobId) return true; + } + return false; + }, +}); +require('./bg-agents-ipc').init({ ipcMain, bgAgents, getMainWindow: () => mainWindow, log }); +``` + +- [ ] **Step 8: Edit `preload.js`** + +After the `stopSession:` line add: + +```js + // see .ai/contexts/bg-agents.md + getBgAgents: () => ipcRenderer.invoke('get-bg-agents'), + bgAgentVerb: (verb, id) => ipcRenderer.invoke('bg-agent-verb', verb, id), + dispatchBgAgent: (fields) => ipcRenderer.invoke('dispatch-bg-agent', fields), +``` + +In the listeners block, after `onSubagentWatchEvent`: + +```js + onBgAgentsChanged: (cb) => ipcRenderer.on('bg-agents-changed', (_e, payload) => cb(payload)), +``` + +- [ ] **Step 9: Run the pins and the lint** + +Run: `node --test test/open-terminal-attach.test.js && npx eslint main.js preload.js bg-agents-ipc.js` +Expected: PASS, 0 lint errors. + +- [ ] **Step 10: Commit** + +```bash +/usr/bin/git add main.js preload.js bg-agents-ipc.js test/bg-agents-ipc.test.js test/open-terminal-attach.test.js +/usr/bin/git commit -m "(main): attach to a background session in a tab, detach on close, expose the agents roster" +``` + +--- + +### Task 6: Shortcut, resume guard, detach wording + +**Files:** +- Modify: `public/shortcuts.js`, `public/resume-guard.js`, `public/stop-session-ui.js` +- Create: `test/agents-toggle-shortcut.test.js`, `test/stop-session-ui-attach.test.js` +- Modify: `test/resume-guard.test.js` (append) + +**Interfaces:** +- Produces: `DEFAULT_SHORTCUTS.agentsToggle = { primary: true, alt: false, shift: true, key: 'a' }`; `guardResume(...)` now returns `true | false | { attach: jobId, cwd }`; `resolveSessionStop(session, { attach })` returns `{ remote: false, alias: null, attach: true, confirmText }` for an attach tab. + +- [ ] **Step 1: Write the failing tests** + +```js +// test/agents-toggle-shortcut.test.js +'use strict'; +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { DEFAULT_SHORTCUTS, SHORTCUT_DEFS, matchShortcut, normalizeShortcuts, formatBinding } = require('../public/shortcuts'); + +test('agentsToggle defaults to Primary+Shift+A and is a rebindable key-family action', () => { + assert.deepEqual(DEFAULT_SHORTCUTS.agentsToggle, { primary: true, alt: false, shift: true, key: 'a' }); + const def = SHORTCUT_DEFS.find(d => d.id === 'agentsToggle'); + assert.equal(def.family, 'key'); + assert.equal(formatBinding('agentsToggle', false, normalizeShortcuts(null)), 'Ctrl+Shift+A'); + const e = { key: 'A', ctrlKey: true, shiftKey: true, altKey: false, metaKey: false }; + assert.equal(matchShortcut('agentsToggle', e, false, normalizeShortcuts(null)), true); + assert.equal(matchShortcut('gridToggle', e, false, normalizeShortcuts(null)), false); +}); +``` + +Append to `test/resume-guard.test.js`: + +```js +// --- Background sessions (see .ai/contexts/bg-agents.md) -------------------- + +const LIVE_BG = { pid: 346590, cwd: '/w/em', startedAt: 1, kind: 'bg', jobId: 'bc3fd129' }; + +test('a user click on a session the daemon runs answers "attach", without asking', async () => { + const api = makeApi(LIVE_BG); + const { confirm, messages } = makeConfirm(false); + assert.deepEqual(await guardResume(SESSION, { api, confirm }), { attach: 'bc3fd129', cwd: '/w/em' }); + assert.equal(messages.length, 0); +}); + +test('an automatic resume of a session the daemon runs is still refused', async () => { + const api = makeApi(LIVE_BG); + const { confirm } = makeConfirm(true); + assert.equal(await guardResume(SESSION, { automatic: true, api, confirm }), false); +}); + +test('app.js turns the attach verdict into attach options and skips the guard for an explicit attach', () => { + const app = read('public/app.js'); + assert.match(app, /customOptions\?\.type === 'attach'\s*\?\s*true\s*:\s*await guardResume\(/); + assert.match(app, /if \(verdict === false\) return false;/); + assert.match(app, /customOptions = \{ type: 'attach', jobId: verdict\.attach, cwd: verdict\.cwd \|\| projectPath \};/); + assert.match(app, /entry\.attach = resumeOptions\.type === 'attach';/); + assert.match(app, /if \(entry\.attach\) continue; \/\/ attach tabs are not restored/); +}); +``` + +```js +// test/stop-session-ui-attach.test.js +'use strict'; +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { resolveSessionStop } = require('../public/stop-session-ui'); + +test('an attach tab asks to detach, not to stop, and stays local', () => { + const plan = resolveSessionStop({ sessionId: 's' }, { attach: true }); + assert.equal(plan.remote, false); + assert.equal(plan.attach, true); + assert.match(plan.confirmText, /Detach/); + assert.match(plan.confirmText, /keeps running/); + assert.equal(resolveSessionStop({ sessionId: 's' }).attach, false); + assert.equal(resolveSessionStop({ sessionId: 's', remoteAlias: 'h' }, { attach: true }).remote, true); +}); +``` + +- [ ] **Step 2: Run them to verify they fail** + +Run: `node --test test/agents-toggle-shortcut.test.js test/resume-guard.test.js test/stop-session-ui-attach.test.js` +Expected: the new tests FAIL. + +- [ ] **Step 3: Implement** + +`public/shortcuts.js`, in `DEFAULT_SHORTCUTS` after `gridToggle`: + +```js + // Ctrl/Cmd+Shift+A — toggle the background agents view. + agentsToggle: { primary: true, alt: false, shift: true, key: 'a' }, +``` + +and in `SHORTCUT_DEFS` after the `gridToggle` entry: + +```js + { + id: 'agentsToggle', + label: 'Toggle agents view', + description: 'Show or hide the background agents view', + family: 'key', + }, +``` + +`public/resume-guard.js`, `guardResume` becomes: + +```js +async function guardResume(session, { automatic = false, api, confirm, live } = {}) { + if (!session || session.type === 'terminal') return true; + if (live === undefined) { + try { + live = await api.getSessionLiveElsewhere(session.sessionId); + } catch { + live = null; + } + } + if (!live) return true; + if (automatic) return false; + // A session the claude daemon runs is attached, never resumed -- see .ai/contexts/bg-agents.md + if (live.kind === 'bg' && typeof live.jobId === 'string' && live.jobId) { + return { attach: live.jobId, cwd: live.cwd || null }; + } + return !!confirm(liveElsewhereMessage(live)); +} +``` + +`public/stop-session-ui.js`, `resolveSessionStop` becomes: + +```js +function resolveSessionStop(session, { attach = false } = {}) { + const alias = session && session.remoteAlias; + if (alias) { + return { remote: true, alias, attach: false, confirmText: `Stop this session on ${alias}?` }; + } + if (attach) { + return { remote: false, alias: null, attach: true, confirmText: 'Detach from this background session? It keeps running; the Agents view can stop it.' }; + } + return { remote: false, alias: null, attach: false, confirmText: 'Stop this session?' }; +} +``` + +`public/app.js`: + +In `persistWorkingSet` (line 176), after `if (entry.session.type === 'terminal') continue; // exclude plain shells` add: + +```js + if (entry.attach) continue; // attach tabs are not restored +``` + +In `openSession` (line 1170), replace the guard line and the `resumeOptions` block with: + +```js + // see .ai/contexts/cli-session-state.md ("Live elsewhere") and .ai/contexts/bg-agents.md ("Attach") + const verdict = customOptions?.type === 'attach' ? true : await guardResume(session, { automatic, live, api: window.api, confirm: (msg) => window.confirm(msg) }); + if (verdict === false) return false; + if (verdict && typeof verdict === 'object' && verdict.attach) { + customOptions = { type: 'attach', jobId: verdict.attach, cwd: verdict.cwd || projectPath }; + } + + // Create new terminal entry (hidden until showSession) + const entry = createTerminalEntry(session); + + // Open terminal in main process — see .ai/contexts/session-state.md ("Reopening a plain terminal") + const resumeOptions = customOptions + || (session.type === 'terminal' ? { type: 'terminal' } : await resolveDefaultSessionOptions({ projectPath })); + entry.attach = resumeOptions.type === 'attach'; +``` + +In `confirmAndStopSession` (line 822), the first line becomes: + +```js + const openEntry = openSessions.get(sessionId); + const plan = resolveSessionStop(sessionMap.get(sessionId), { attach: !!(openEntry && openEntry.attach) }); +``` + +In `showTerminalHeader` (line 1140), after `terminalHeaderSandbox.style.display = …;` add: + +```js + const headerEntry = openSessions.get(session.sessionId); + terminalStopBtn.title = headerEntry && headerEntry.attach ? 'Detach (the session keeps running)' : 'Stop process'; + terminalStopBtn.setAttribute('aria-label', terminalStopBtn.title); +``` + +- [ ] **Step 4: Run the tests and lint** + +Run: `node --test test/agents-toggle-shortcut.test.js test/resume-guard.test.js test/stop-session-ui-attach.test.js test/confirm-and-stop-session.test.js test/open-session-terminal.test.js && npx eslint public/` +Expected: PASS; if `test/confirm-and-stop-session.test.js` or `test/open-session-terminal.test.js` pins a line you changed, update that pin to the new text (they are source-level mirrors, not behavior changes). + +- [ ] **Step 5: Commit** + +```bash +/usr/bin/git add public/shortcuts.js public/resume-guard.js public/stop-session-ui.js public/app.js test/agents-toggle-shortcut.test.js test/resume-guard.test.js test/stop-session-ui-attach.test.js +/usr/bin/git commit -m "(sessions): attach to a session the daemon runs instead of asking to resume it" +``` + +--- + +### Task 7: The Agents view (`public/agents-view.js`) + +**Files:** +- Create: `public/agents-view.js` +- Modify: `public/index.html` (markup + script tag), `public/style.css`, `public/memory-workfiles-view.js` (`hideAllViewers`), `public/app.js` (toggle button, shortcut, init, restore, stats-tab branch), `public/terminal-manager.js` (shortcut inside xterm), `eslint.config.js` +- Create: `test/agents-view-pure.test.js`, `test/dom-agents-view.test.js` + +**Interfaces:** +- Consumes: `window.api.getBgAgents/bgAgentVerb/onBgAgentsChanged/openExternal/stopSession` (Task 5), `renderSessionIcon` (session-state.js), `escapeHtml`, `shortProjectPath` (utils.js), `hideAllViewers`, `showSession`, `openSession`, `showJsonlViewer`, `refreshSidebar`, `fitAndScroll`, DOM handles from app.js. +- Produces (globals): `agentsViewActive` (writable), `bgAgentSessionIds` (Set of background session ids, read by sidebar.js in Task 8), `initAgentsView()`, `showAgentsView()`, `hideAgentsView({ restore })`, `toggleAgentsView()`, `applyAgentsSnapshot(snapshot)`, `renderAgentsView()`, `refreshAgentsRoster()`, `attachBgAgent(entry)`, `runAgentVerb(verb, entry)`, `selectAgentsRow(id)`; pure `sortAgentEntries`, `agentRowIcon`, `agentVerbAvailability`, `formatTokens`, `formatAgentAge`, `agentsEntryKey` (also `module.exports` for tests). `showDispatchAgentDialog` is called if defined (Task 9). + +- [ ] **Step 1: Write the failing pure tests** + +```js +// test/agents-view-pure.test.js — the decision helpers of the agents view. See .ai/contexts/bg-agents.md. +'use strict'; +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { sortAgentEntries, agentRowIcon, agentVerbAvailability, formatTokens, formatAgentAge, agentsEntryKey } = require('../public/agents-view'); + +const bg = (over) => ({ id: 'aaaaaaaa', sessionId: 's', kind: 'background', state: 'working', status: 'idle', startedAt: 100, ...over }); + +test('sort: working and external-interactive first, then newest first, unknown start last', () => { + const sorted = sortAgentEntries([ + bg({ id: 'd1', state: 'done', startedAt: 500 }), + bg({ id: 'w1', startedAt: 100 }), + { kind: 'interactive', sessionId: 'i1', state: null, status: 'busy', startedAt: 300 }, + bg({ id: 'w2', startedAt: null }), + bg({ id: 'w3', startedAt: 200 }), + ]); + assert.deepEqual(sorted.map(e => e.id || e.sessionId), ['i1', 'w3', 'w1', 'w2', 'd1']); +}); + +test('row icon: busy spinner, waiting, idle for live rows; stale for finished ones', () => { + assert.equal(agentRowIcon(bg({ status: 'busy' })).slotClass, 'session-icon--busy'); + assert.equal(agentRowIcon(bg({ status: 'waiting' })).slotClass, 'session-icon--waiting'); + assert.equal(agentRowIcon(bg({ status: 'idle' })).slotClass, 'session-icon--idle'); + assert.equal(agentRowIcon(bg({ state: 'done', status: 'busy' })).slotClass, 'session-icon--stale'); + assert.equal(agentRowIcon({ kind: 'interactive', status: 'busy' }).slotClass, 'session-icon--busy'); +}); + +test('verb availability follows the state, the kind and the daemon', () => { + assert.deepEqual(agentVerbAvailability(bg(), true), { transcript: true, attach: true, stop: true, respawn: true, rm: false }); + assert.deepEqual(agentVerbAvailability(bg({ state: 'done' }), true), { transcript: true, attach: false, stop: false, respawn: true, rm: true }); + assert.deepEqual(agentVerbAvailability(bg(), false), { transcript: true, attach: false, stop: false, respawn: false, rm: false }); + assert.deepEqual(agentVerbAvailability({ kind: 'interactive', sessionId: 'i' }, true), { transcript: true, attach: false, stop: false, respawn: false, rm: false }); + assert.equal(agentVerbAvailability(bg({ sessionId: null, state: 'done' }), true).transcript, false); +}); + +test('formatting helpers', () => { + assert.equal(formatTokens(null), ''); + assert.equal(formatTokens(274), '274'); + assert.equal(formatTokens(172999), '173k'); + assert.equal(formatTokens(2500000), '2.5M'); + const now = 1_000_000_000; + assert.equal(formatAgentAge(null, now), ''); + assert.equal(formatAgentAge(now - 30_000, now), '30s'); + assert.equal(formatAgentAge(now - 12 * 60_000, now), '12 min'); + assert.equal(formatAgentAge(now - 3 * 3_600_000, now), '3 h'); + assert.equal(formatAgentAge(now - (2 * 24 + 6) * 3_600_000, now), '2d 6h'); + assert.equal(agentsEntryKey(bg()), 'bg:aaaaaaaa'); + assert.equal(agentsEntryKey({ kind: 'interactive', sessionId: 'x' }), 'int:x'); +}); +``` + +- [ ] **Step 2: Write the failing DOM test** + +```js +// test/dom-agents-view.test.js — the agents view rendered in jsdom. See .ai/contexts/bg-agents.md. +'use strict'; +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const { JSDOM } = require('jsdom'); + +const PUBLIC = path.join(__dirname, '..', 'public'); +const HTML = ` +
+
+
+ + +`; + +function evalFile(dom, file) { + vm.runInContext(fs.readFileSync(file, 'utf8'), dom.getInternalVMContext(), { filename: file }); +} + +function setup() { + const dom = new JSDOM(HTML, { url: 'http://localhost/', runScripts: 'outside-only', pretendToBeVisual: true }); + const { window } = dom; + const calls = { verbs: [], opened: [], jsonl: [], external: [], stopped: [], shown: [], sidebarRefreshes: 0 }; + let changedCb = null; + let snapshot = { roster: [], daemonReachable: true }; + window.api = { + getBgAgents: async () => snapshot, + bgAgentVerb: async (verb, id) => { calls.verbs.push([verb, id]); return { ok: verb !== 'rm', error: verb === 'rm' ? 'nope' : undefined }; }, + onBgAgentsChanged: (cb) => { changedCb = cb; }, + openExternal: async (href) => calls.external.push(href), + stopSession: async (id) => { calls.stopped.push(id); return { ok: true }; }, + }; + const g = { + placeholder: window.document.getElementById('placeholder'), + terminalArea: window.document.getElementById('terminal-area'), + terminalHeader: window.document.getElementById('terminal-header'), + gridViewer: window.document.getElementById('grid-viewer'), + statsViewer: window.document.getElementById('stats-viewer'), + memoryViewer: window.document.getElementById('memory-viewer'), + workFilesViewer: window.document.getElementById('work-files-viewer'), + settingsViewer: window.document.getElementById('settings-viewer'), + jsonlViewer: window.document.getElementById('jsonl-viewer'), + resortBtn: window.document.getElementById('resort-btn'), + openSessions: new Map(), + sessionMap: new Map(), + activeSessionId: null, + gridViewActive: false, + showSession: (id) => calls.shown.push(id), + openSession: (session, opts) => calls.opened.push([session, opts]), + showJsonlViewer: (session) => calls.jsonl.push(session), + refreshSidebar: () => { calls.sidebarRefreshes++; }, + fitAndScroll: () => {}, + confirm: () => true, + }; + for (const [k, v] of Object.entries(g)) Object.defineProperty(window, k, { value: v, writable: true, configurable: true }); + vm.runInContext(fs.readFileSync(path.join(__dirname, '..', 'node_modules', 'morphdom', 'dist', 'morphdom-umd.js'), 'utf8'), dom.getInternalVMContext()); + evalFile(dom, path.join(PUBLIC, 'utils.js')); + evalFile(dom, path.join(PUBLIC, 'session-state.js')); + evalFile(dom, path.join(PUBLIC, 'memory-workfiles-view.js')); + evalFile(dom, path.join(PUBLIC, 'agents-view.js')); + window.initAgentsView(); + const read = (expr) => vm.runInContext(expr, dom.getInternalVMContext()); + return { + window, document: window.document, calls, read, + setSnapshot(s) { snapshot = s; }, + emitChanged(s) { snapshot = s; changedCb(s); }, + destroy() { window.close(); }, + }; +} + +const ROSTER = [ + { id: 'aaaaaaaa', sessionId: 's-a', name: 'em-platform', cwd: '/w/em', kind: 'background', state: 'working', status: 'idle', pid: 10, startedAt: Date.now() - 60_000, agent: 'fleet:em', model: 'sonnet', detail: 'awaiting !196', tempo: 'idle', tokens: 173000, fan: [{ id: 'f', kind: 'agent', label: 'Spawn developer', startedAt: 1, doneAt: 27_000 }], children: [{ id: '195', href: 'https://gitlab.example/mr/195', kind: 'mr' }], result: 'no action', attachedHere: false }, + { id: 'bbbbbbbb', sessionId: 's-b', name: 'spike', cwd: '/w/f', kind: 'background', state: 'done', status: null, pid: null, startedAt: Date.now() - 3_600_000, agent: null, model: null, detail: null, tempo: null, tokens: 274, fan: [], children: [], result: null, attachedHere: false }, + { id: null, sessionId: 's-i', name: 'lvds-1b', cwd: '/w/l', kind: 'interactive', state: null, status: 'busy', pid: 30, startedAt: Date.now() - 10_000, agent: null, model: null, detail: null, tempo: null, tokens: null, fan: [], children: [], result: null, attachedHere: false }, +]; + +test('showing the view hides the terminal area, lists the roster sorted, and counts running/finished', async (t) => { + const ctx = setup(); t.after(() => ctx.destroy()); + ctx.setSnapshot({ roster: ROSTER, daemonReachable: true }); + await ctx.window.showAgentsView(); + assert.equal(ctx.window.terminalArea.style.display, 'none'); + assert.equal(ctx.document.getElementById('agents-viewer').style.display, 'flex'); + assert.equal(ctx.read('agentsViewActive'), true); + const names = [...ctx.document.querySelectorAll('.agents-row-name')].map(el => el.textContent); + assert.deepEqual(names, ['lvds-1b', 'em-platform', 'spike']); + assert.equal(ctx.document.getElementById('agents-viewer-count').textContent, '1 running · 1 finished'); + assert.equal(ctx.document.getElementById('agents-viewer-banner').style.display, 'none'); + assert.equal(ctx.window.localStorage.getItem('agentsViewActive'), '1'); +}); + +test('the Finished filter hides done and stopped jobs and is remembered', async (t) => { + const ctx = setup(); t.after(() => ctx.destroy()); + ctx.setSnapshot({ roster: ROSTER, daemonReachable: true }); + await ctx.window.showAgentsView(); + const box = ctx.document.getElementById('agents-show-finished'); + box.checked = false; + box.dispatchEvent(new ctx.window.Event('change', { bubbles: true })); + assert.deepEqual([...ctx.document.querySelectorAll('.agents-row-name')].map(el => el.textContent), ['lvds-1b', 'em-platform']); + assert.equal(ctx.window.localStorage.getItem('agentsShowFinished'), '0'); +}); + +test('selecting a row renders its detail with the verbs disabled by state, and a roster update keeps the selection', async (t) => { + const ctx = setup(); t.after(() => ctx.destroy()); + ctx.setSnapshot({ roster: ROSTER, daemonReachable: true }); + await ctx.window.showAgentsView(); + ctx.document.querySelector('.agents-row[data-key="bg:aaaaaaaa"]').click(); + const detail = ctx.document.getElementById('agents-detail'); + assert.match(detail.textContent, /awaiting !196/); + assert.match(detail.textContent, /173k tokens/); + assert.match(detail.textContent, /Spawn developer/); + assert.equal(detail.querySelector('[data-verb="rm"]').disabled, true); + assert.equal(detail.querySelector('[data-verb="stop"]').disabled, false); + assert.equal(detail.querySelector('[data-verb="attach"]').disabled, false); + ctx.emitChanged({ roster: [{ ...ROSTER[0], detail: 'changed' }, ROSTER[1], ROSTER[2]], daemonReachable: true }); + assert.match(ctx.document.getElementById('agents-detail').textContent, /changed/); + assert.ok(ctx.document.querySelector('.agents-row[data-key="bg:aaaaaaaa"]').classList.contains('selected')); +}); + +test('the verbs: attach opens a tab keyed by the session id, transcript opens the viewer, stop calls the IPC, a failure shows in the detail', async (t) => { + const ctx = setup(); t.after(() => ctx.destroy()); + ctx.setSnapshot({ roster: ROSTER, daemonReachable: true }); + await ctx.window.showAgentsView(); + ctx.document.querySelector('.agents-row[data-key="bg:aaaaaaaa"]').click(); + const detail = ctx.document.getElementById('agents-detail'); + detail.querySelector('[data-verb="attach"]').click(); + assert.equal(ctx.calls.opened.length, 1); + assert.equal(ctx.calls.opened[0][0].sessionId, 's-a'); + assert.deepEqual(ctx.calls.opened[0][1], { type: 'attach', jobId: 'aaaaaaaa', cwd: '/w/em' }); + detail.querySelector('[data-verb="transcript"]').click(); + assert.equal(ctx.calls.jsonl[0].sessionId, 's-a'); + await ctx.window.runAgentVerb('stop', ROSTER[0]); + assert.deepEqual(ctx.calls.verbs, [['stop', 'aaaaaaaa']]); + ctx.document.querySelector('.agents-row[data-key="bg:bbbbbbbb"]').click(); + await ctx.window.runAgentVerb('rm', ROSTER[1]); + assert.match(ctx.document.getElementById('agents-detail').textContent, /nope/); +}); + +test('stop on a session attached here detaches the tab first', async (t) => { + const ctx = setup(); t.after(() => ctx.destroy()); + await ctx.window.showAgentsView(); + await ctx.window.runAgentVerb('stop', { ...ROSTER[0], attachedHere: true }); + assert.deepEqual(ctx.calls.stopped, ['s-a']); + assert.deepEqual(ctx.calls.verbs, [['stop', 'aaaaaaaa']]); +}); + +test('an unreachable daemon shows the banner and disables every verb but Transcript; an empty roster shows the empty state', async (t) => { + const ctx = setup(); t.after(() => ctx.destroy()); + ctx.setSnapshot({ roster: ROSTER, daemonReachable: false }); + await ctx.window.showAgentsView(); + assert.notEqual(ctx.document.getElementById('agents-viewer-banner').style.display, 'none'); + ctx.document.querySelector('.agents-row[data-key="bg:aaaaaaaa"]').click(); + const detail = ctx.document.getElementById('agents-detail'); + assert.equal(detail.querySelector('[data-verb="stop"]').disabled, true); + assert.equal(detail.querySelector('[data-verb="transcript"]').disabled, false); + ctx.emitChanged({ roster: [], daemonReachable: true }); + assert.match(ctx.document.getElementById('agents-list').textContent, /No background agents/); +}); + +test('hideAllViewers closes the view without restoring the terminal; hideAgentsView restores it', async (t) => { + const ctx = setup(); t.after(() => ctx.destroy()); + ctx.window.activeSessionId = 'open-1'; + ctx.window.openSessions.set('open-1', { closed: false }); + await ctx.window.showAgentsView(); + ctx.window.hideAllViewers(); + assert.equal(ctx.read('agentsViewActive'), false); + assert.equal(ctx.document.getElementById('agents-viewer').style.display, 'none'); + assert.deepEqual(ctx.calls.shown, [], 'no restore from hideAllViewers'); + await ctx.window.showAgentsView(); + ctx.window.hideAgentsView(); + assert.deepEqual(ctx.calls.shown, ['open-1']); + assert.equal(ctx.window.terminalArea.style.display, ''); +}); + +test('a roster push updates bgAgentSessionIds and refreshes the sidebar only when the set changes', async (t) => { + const ctx = setup(); t.after(() => ctx.destroy()); + ctx.emitChanged({ roster: ROSTER, daemonReachable: true }); + assert.deepEqual([...ctx.read('bgAgentSessionIds')].sort(), ['s-a', 's-b']); + assert.equal(ctx.calls.sidebarRefreshes, 1); + ctx.emitChanged({ roster: ROSTER, daemonReachable: true }); + assert.equal(ctx.calls.sidebarRefreshes, 1); +}); +``` + +- [ ] **Step 3: Run both to verify they fail** + +Run: `node --test test/agents-view-pure.test.js test/dom-agents-view.test.js` +Expected: FAIL, `Cannot find module '../public/agents-view'` / eval error on the missing file. + +- [ ] **Step 4: Write `public/agents-view.js`** + +```js +// --- Agents view: the sessions the claude daemon runs in the background --- +// see .ai/contexts/bg-agents.md +// Depends on globals: escapeHtml, shortProjectPath (utils.js), renderSessionIcon +// (session-state.js), morphdom, hideAllViewers (memory-workfiles-view.js), +// showSession, openSession, showJsonlViewer, refreshSidebar, fitAndScroll, +// placeholder, terminalArea, terminalHeader, gridViewer, gridViewActive, +// activeSessionId, openSessions, sessionMap (app.js / grid-view.js / terminal-manager.js). +// showDispatchAgentDialog (dialogs.js) is optional. + +const AGENTS_RECONCILE_MS = 30000; +const bgAgentSessionIds = new Set(); +let agentsViewActive = false; +let agentsRoster = []; +let agentsDaemonReachable = true; +let agentsSelectedKey = null; +let agentsShowFinished = true; +let agentsReconcileTimer = null; +const agentsPendingVerbs = new Set(); +const agentsVerbErrors = new Map(); + +// --- pure helpers --- + +function agentsEntryKey(entry) { + return entry.kind === 'background' ? 'bg:' + entry.id : 'int:' + entry.sessionId; +} + +function agentIsLive(entry) { + return entry.kind === 'interactive' || entry.state === 'working'; +} + +function sortAgentEntries(entries) { + return entries.slice().sort((a, b) => { + const rank = (e) => (agentIsLive(e) ? 0 : 1); + if (rank(a) !== rank(b)) return rank(a) - rank(b); + const sa = Number.isFinite(a.startedAt) ? a.startedAt : -Infinity; + const sb = Number.isFinite(b.startedAt) ? b.startedAt : -Infinity; + return sb - sa; + }); +} + +function agentRowIcon(entry) { + const live = agentIsLive(entry); + const icon = renderSessionIcon({ + busy: live && entry.status === 'busy', + waitingForInput: live && entry.status === 'waiting', + stale: !live, + }); + return { slotClass: icon.slotClasses[0], title: icon.title }; +} + +function agentVerbAvailability(entry, daemonReachable) { + const bg = entry.kind === 'background'; + const working = bg && entry.state === 'working'; + return { + transcript: !!entry.sessionId, + attach: working && !!daemonReachable, + stop: working && !!daemonReachable, + respawn: bg && !!daemonReachable, + rm: bg && !working && !!daemonReachable, + }; +} + +function formatTokens(n) { + if (!Number.isFinite(n)) return ''; + if (n < 1000) return String(n); + if (n < 1_000_000) return Math.round(n / 1000) + 'k'; + return (n / 1_000_000).toFixed(1) + 'M'; +} + +function formatAgentAge(startedAt, now = Date.now()) { + if (!Number.isFinite(startedAt)) return ''; + const s = Math.max(0, Math.floor((now - startedAt) / 1000)); + if (s < 60) return s + 's'; + const m = Math.floor(s / 60); + if (m < 60) return m + ' min'; + const h = Math.floor(m / 60); + if (h < 24) return h + ' h'; + return Math.floor(h / 24) + 'd ' + (h % 24) + 'h'; +} + +// --- state --- + +function agentsSelectedEntry() { + return agentsRoster.find(e => agentsEntryKey(e) === agentsSelectedKey) || null; +} + +function applyAgentsSnapshot(snapshot) { + agentsRoster = Array.isArray(snapshot && snapshot.roster) ? snapshot.roster : []; + agentsDaemonReachable = !snapshot || snapshot.daemonReachable !== false; + const next = new Set(agentsRoster.filter(e => e.kind === 'background' && e.sessionId).map(e => e.sessionId)); + let changed = next.size !== bgAgentSessionIds.size; + if (!changed) for (const id of next) if (!bgAgentSessionIds.has(id)) { changed = true; break; } + if (changed) { + bgAgentSessionIds.clear(); + for (const id of next) bgAgentSessionIds.add(id); + if (typeof refreshSidebar === 'function') refreshSidebar(); + } + if (agentsViewActive) renderAgentsView(); +} + +async function refreshAgentsRoster() { + try { + applyAgentsSnapshot(await window.api.getBgAgents()); + } catch (err) { + console.warn('[agents-view] roster refresh failed', err); + } +} + +function selectAgentsRow(id) { + agentsSelectedKey = 'bg:' + id; + if (agentsViewActive) renderAgentsView(); +} + +// --- render --- + +function renderAgentRow(entry) { + const key = agentsEntryKey(entry); + const icon = agentRowIcon(entry); + const state = entry.kind === 'interactive' ? 'external' : (entry.state || '?'); + const status = entry.status ? ' · ' + entry.status : ''; + const classes = ['agents-row']; + if (key === agentsSelectedKey) classes.push('selected'); + if (agentsPendingVerbs.has(key)) classes.push('pending'); + return `
+ + ${escapeHtml(entry.name || entry.sessionId || entry.id || '')} + ${escapeHtml(entry.agent || '—')} + ${escapeHtml(state + status)} + ${escapeHtml(entry.cwd ? shortProjectPath(entry.cwd) : '')} + ${escapeHtml(formatAgentAge(entry.startedAt))} +
`; +} + +function renderAgentDetail(entry) { + const key = agentsEntryKey(entry); + const v = agentVerbAvailability(entry, agentsDaemonReachable); + const pending = agentsPendingVerbs.has(key); + const btn = (verb, label, enabled) => + ``; + const meta = []; + if (Number.isFinite(entry.tokens)) meta.push(formatTokens(entry.tokens) + ' tokens'); + if (entry.model) meta.push(entry.model); + if (Number.isFinite(entry.startedAt)) meta.push('started ' + new Date(entry.startedAt).toLocaleString()); + if (entry.pid) meta.push('pid ' + entry.pid); + const fan = (entry.fan || []).map((f) => { + const dur = Number.isFinite(f.startedAt) && Number.isFinite(f.doneAt) ? formatAgentAge(f.startedAt, f.doneAt) : ''; + const tail = f.doneAt ? (dur ? ` (${dur}, done)` : ' (done)') : ' (running)'; + return `
  • ${escapeHtml(f.label || f.id || '')}${escapeHtml(tail)}
  • `; + }).join(''); + const children = (entry.children || []).filter(c => c.href) + .map(c => `
    ${escapeHtml(c.id || c.href)}`) + .join(' · '); + const error = agentsVerbErrors.get(key); + return ` +
    + ${escapeHtml(entry.name || entry.sessionId || '')} + + ${btn('attach', 'Attach', v.attach)}${btn('transcript', 'Transcript', v.transcript)}${btn('stop', 'Stop', v.stop)}${btn('respawn', 'Respawn', v.respawn)}${btn('rm', 'Delete', v.rm)} + +
    + ${entry.detail ? `
    ${escapeHtml(entry.detail)}
    ` : ''} + ${meta.length ? `
    ${escapeHtml(meta.join(' · '))}
    ` : ''} + ${entry.kind === 'interactive' ? `
    ${escapeHtml(entry.cwd || '')}${entry.status ? ' · ' + escapeHtml(entry.status) : ''}
    ` : ''} + ${fan ? `
    Subagents
      ${fan}
    ` : ''} + ${children ? `
    Produced: ${children}
    ` : ''} + ${entry.result ? `
    Last result: ${escapeHtml(entry.result)}
    ` : ''} + ${error ? `
    ${escapeHtml(error)}
    ` : ''} + `; +} + +function renderAgentsView() { + const listEl = document.getElementById('agents-list'); + const detailEl = document.getElementById('agents-detail'); + const countEl = document.getElementById('agents-viewer-count'); + const bannerEl = document.getElementById('agents-viewer-banner'); + if (!listEl || !detailEl) return; + const visible = sortAgentEntries(agentsRoster.filter(e => agentsShowFinished || agentIsLive(e))); + const running = agentsRoster.filter(e => e.kind === 'background' && e.state === 'working').length; + const finished = agentsRoster.filter(e => e.kind === 'background' && e.state !== 'working').length; + if (countEl) countEl.textContent = `${running} running · ${finished} finished`; + if (bannerEl) { + bannerEl.textContent = 'The daemon is not answering; state comes from files only.'; + bannerEl.style.display = agentsDaemonReachable ? 'none' : ''; + } + if (agentsSelectedKey && !visible.some(e => agentsEntryKey(e) === agentsSelectedKey)) agentsSelectedKey = null; + + const nextList = document.createElement('div'); + nextList.id = 'agents-list'; + nextList.innerHTML = visible.length + ? visible.map(renderAgentRow).join('') + : '
    No background agents. claude --bg starts one, or New agent.
    '; + morphdom(listEl, nextList); + + const selected = agentsSelectedEntry(); + const nextDetail = document.createElement('div'); + nextDetail.id = 'agents-detail'; + nextDetail.innerHTML = selected ? renderAgentDetail(selected) : '
    Select an agent
    '; + morphdom(detailEl, nextDetail); +} + +// --- verbs --- + +function attachBgAgent(entry) { + if (!entry || entry.kind !== 'background' || !entry.sessionId) return; + let session = sessionMap.get(entry.sessionId); + if (!session) { + session = { sessionId: entry.sessionId, projectPath: entry.cwd, name: entry.name, summary: entry.name || entry.id, firstPrompt: '' }; + sessionMap.set(entry.sessionId, session); + } + openSession(session, { type: 'attach', jobId: entry.id, cwd: entry.cwd }); +} + +async function runAgentVerb(verb, entry) { + if (!entry) return; + const key = agentsEntryKey(entry); + if (verb === 'transcript') { + showJsonlViewer(sessionMap.get(entry.sessionId) || { sessionId: entry.sessionId, name: entry.name, projectPath: entry.cwd }); + return; + } + if (verb === 'attach') { + attachBgAgent(entry); + return; + } + if (verb === 'rm' && !window.confirm('Delete this background session? Its conversation goes, and its worktree when that is safe.')) return; + if (entry.attachedHere && (verb === 'stop' || verb === 'rm')) { + try { await window.api.stopSession(entry.sessionId); } catch {} + } + agentsPendingVerbs.add(key); + agentsVerbErrors.delete(key); + if (agentsViewActive) renderAgentsView(); + let result; + try { + result = await window.api.bgAgentVerb(verb, entry.id); + } catch (err) { + result = { ok: false, error: err && err.message ? err.message : String(err) }; + } + agentsPendingVerbs.delete(key); + if (!result || result.ok === false) agentsVerbErrors.set(key, (result && result.error) || 'unknown error'); + await refreshAgentsRoster(); +} + +// --- show / hide --- + +function setAgentsToggleActive(on) { + const btn = document.getElementById('agents-toggle-btn'); + if (btn) btn.classList.toggle('active', on); +} + +async function showAgentsView() { + hideAllViewers(); + placeholder.style.display = 'none'; + terminalArea.style.display = 'none'; + terminalHeader.style.display = 'none'; + const el = document.getElementById('agents-viewer'); + if (el) el.style.display = 'flex'; + agentsViewActive = true; + localStorage.setItem('agentsViewActive', '1'); + setAgentsToggleActive(true); + renderAgentsView(); + await refreshAgentsRoster(); + if (!agentsReconcileTimer) { + agentsReconcileTimer = setInterval(() => { if (agentsViewActive) refreshAgentsRoster(); }, AGENTS_RECONCILE_MS); + } +} + +function hideAgentsView({ restore = true } = {}) { + const el = document.getElementById('agents-viewer'); + if (el) el.style.display = 'none'; + const wasActive = agentsViewActive; + agentsViewActive = false; + localStorage.setItem('agentsViewActive', '0'); + if (agentsReconcileTimer) { clearInterval(agentsReconcileTimer); agentsReconcileTimer = null; } + setAgentsToggleActive(false); + if (!wasActive || !restore) return; + terminalArea.style.display = ''; + if (gridViewActive) { + placeholder.style.display = 'none'; + terminalHeader.style.display = 'none'; + gridViewer.style.display = 'block'; + for (const entry of openSessions.values()) if (!entry.closed) fitAndScroll(entry); + } else if (activeSessionId && openSessions.has(activeSessionId)) { + showSession(activeSessionId); + } else { + placeholder.style.display = ''; + } +} + +function toggleAgentsView() { + if (agentsViewActive) hideAgentsView(); + else showAgentsView(); +} + +function initAgentsView() { + agentsShowFinished = localStorage.getItem('agentsShowFinished') !== '0'; + const box = document.getElementById('agents-show-finished'); + if (box) { + box.checked = agentsShowFinished; + box.addEventListener('change', () => { + agentsShowFinished = box.checked; + localStorage.setItem('agentsShowFinished', agentsShowFinished ? '1' : '0'); + renderAgentsView(); + }); + } + const newBtn = document.getElementById('agents-new-btn'); + if (newBtn) { + newBtn.addEventListener('click', () => { + if (typeof showDispatchAgentDialog !== 'function') return; + const active = activeSessionId ? sessionMap.get(activeSessionId) : null; + showDispatchAgentDialog(active && active.projectPath ? { projectPath: active.projectPath } : null); + }); + } + const viewer = document.getElementById('agents-viewer'); + if (viewer) { + viewer.addEventListener('click', (e) => { + const link = e.target.closest('.agents-link'); + if (link) { + e.preventDefault(); + window.api.openExternal(link.dataset.href); + return; + } + const verbBtn = e.target.closest('.agents-verb-btn'); + if (verbBtn) { + if (!verbBtn.disabled) runAgentVerb(verbBtn.dataset.verb, agentsSelectedEntry()); + return; + } + const row = e.target.closest('.agents-row'); + if (row) { + agentsSelectedKey = row.dataset.key; + renderAgentsView(); + } + }); + } + window.api.onBgAgentsChanged((snapshot) => applyAgentsSnapshot(snapshot)); +} + +if (typeof module !== 'undefined' && module.exports) { + module.exports = { sortAgentEntries, agentRowIcon, agentVerbAvailability, formatTokens, formatAgentAge, agentsEntryKey }; +} +``` + +- [ ] **Step 5: Markup, styles, wiring** + +`public/index.html`: after the `#jsonl-viewer` `` (before `
    `) add: + +```html + +``` + +and the script tag after `memory-workfiles-view.js`: + +```html + + +``` + +`public/memory-workfiles-view.js`, in `hideAllViewers()` after `jsonlViewer.style.display = 'none';`: + +```js + if (typeof hideAgentsView === 'function') hideAgentsView({ restore: false }); +``` + +`public/app.js`: + +- after `initGridGroupToggle();` (line 1303): `initAgentsView();` +- in the sidebar-filters block (line 1375), after `resortBtn.parentElement.insertBefore(gridToggleBtn, resortBtn);`: + +```js + const agentsToggleBtn = document.createElement('button'); + agentsToggleBtn.id = 'agents-toggle-btn'; + agentsToggleBtn.title = 'Background agents'; + agentsToggleBtn.innerHTML = ''; + agentsToggleBtn.addEventListener('click', toggleAgentsView); + resortBtn.parentElement.insertBefore(agentsToggleBtn, resortBtn); +``` + +- in the same block's keydown listener, before the grid branch: + +```js + if (matchShortcut('agentsToggle', e, isMac, appShortcuts)) { + e.preventDefault(); + toggleAgentsView(); + return; + } +``` + +- in the startup `loadProjects().then(async () => {` (line 1438), after the grid restore: + +```js + if (localStorage.getItem('agentsViewActive') === '1') showAgentsView(); +``` + +- in the tab-switch `stats` branch (line 1276), before `statsViewer.style.display = 'flex';`: + +```js + if (typeof hideAgentsView === 'function') hideAgentsView({ restore: false }); +``` + +`public/terminal-manager.js`, before the `gridToggle` branch at line 95: + +```js + if (matchShortcut('agentsToggle', e, isMac, appShortcuts)) { + if (e.type === 'keydown') { e._handled = true; toggleAgentsView(); } + return false; + } +``` + +`public/style.css`, after the `#grid-viewer-count` rule: + +```css +/* Agents view — see .ai/contexts/bg-agents.md */ +#agents-viewer { display: none; flex-direction: column; flex: 1; min-height: 0; } +#agents-viewer-header { + display: flex; align-items: center; gap: 10px; padding: 8px 16px; + background: var(--surface-chrome); border-bottom: 1px solid var(--hairline); flex-shrink: 0; +} +#agents-viewer-title { font-size: 13px; color: #b0b0c4; font-weight: 500; } +#agents-viewer-count { font-size: 11px; color: #7a7a90; margin-right: auto; } +#agents-finished-toggle { font-size: 11px; color: #7a7a90; display: inline-flex; align-items: center; gap: 4px; } +#agents-new-btn, .agents-verb-btn { + background: transparent; border: 1px solid var(--control-border); color: #b0b0c4; + font-size: 11px; padding: 3px 8px; border-radius: 6px; cursor: pointer; font-family: inherit; +} +#agents-new-btn:hover, .agents-verb-btn:hover:not([disabled]) { background: var(--control-surface); } +.agents-verb-btn[disabled] { opacity: 0.4; cursor: default; } +#agents-viewer-banner { padding: 6px 16px; font-size: 11px; color: #f0a050; background: rgba(240,160,80,0.08); } +#agents-viewer-body { display: flex; flex-direction: column; flex: 1; min-height: 0; } +#agents-list { flex: 1; overflow: auto; min-height: 0; } +.agents-row { + display: grid; grid-template-columns: 18px minmax(160px, 2fr) minmax(90px, 1fr) minmax(110px, 1fr) minmax(160px, 2fr) 60px; + gap: 8px; align-items: center; padding: 6px 16px; font-size: 12px; color: #c8c8d8; cursor: pointer; + border-bottom: 1px solid var(--hairline); +} +.agents-row:hover { background: var(--control-surface); } +.agents-row.selected { background: rgba(128,136,255,0.1); } +.agents-row.pending { opacity: 0.5; } +.agents-row-name, .agents-row-cwd { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.agents-row-agent, .agents-row-state, .agents-row-age { color: #7a7a90; } +#agents-detail { border-top: 1px solid var(--hairline); padding: 10px 16px; max-height: 40%; overflow: auto; font-size: 12px; color: #c8c8d8; flex-shrink: 0; } +.agents-detail-head { display: flex; align-items: center; gap: 10px; margin-bottom: 6px; } +.agents-detail-name { font-weight: 500; margin-right: auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.agents-detail-actions { display: flex; gap: 6px; flex-shrink: 0; } +.agents-detail-line { margin: 4px 0; } +.agents-detail-meta, .agents-detail-empty { color: #7a7a90; margin: 4px 0; } +.agents-detail-section { margin-top: 8px; } +.agents-detail-section ul { margin: 4px 0 0 16px; padding: 0; } +.agents-detail-error { margin-top: 8px; color: #f06060; } +.agents-link { color: var(--accent); } +``` + +and add `#agents-toggle-btn` to the three selectors that name `#grid-toggle-btn` (base, `:hover`, `.active`), plus `#agents-toggle-btn { margin-left: 2px; }` after `#grid-toggle-btn { margin-left: auto; }`. + +`eslint.config.js`, in `rendererCrossFileGlobals` after `liveElsewhereMany: 'readonly',`: + +```js + // Agents view (public/agents-view.js) — see .ai/contexts/bg-agents.md + agentsViewActive: 'writable', + bgAgentSessionIds: 'readonly', + initAgentsView: 'readonly', + showAgentsView: 'readonly', + hideAgentsView: 'readonly', + toggleAgentsView: 'readonly', + applyAgentsSnapshot: 'readonly', + refreshAgentsRoster: 'readonly', + attachBgAgent: 'readonly', + runAgentVerb: 'readonly', + selectAgentsRow: 'readonly', + showDispatchAgentDialog: 'readonly', +``` + +If `npx eslint public/` still reports `no-undef` for a name agents-view.js reads (for instance `fitAndScroll` or `gridViewActive`), add that name as `'readonly'` in the same list; do not disable the rule. + +- [ ] **Step 6: Run the tests and the lint** + +Run: `node --test test/agents-view-pure.test.js test/dom-agents-view.test.js && npx eslint public/ eslint.config.js` +Expected: PASS, 4 + 8 tests; 0 errors. + +- [ ] **Step 7: Commit** + +```bash +/usr/bin/git add public/agents-view.js public/index.html public/style.css public/memory-workfiles-view.js public/app.js public/terminal-manager.js eslint.config.js test/agents-view-pure.test.js test/dom-agents-view.test.js +/usr/bin/git commit -m "(agents): a view of the daemon's background sessions, with attach, transcript, stop, respawn and delete" +``` + +--- + +### Task 8: The sidebar's "bg" badge + +**Files:** +- Modify: `public/sidebar.js` (in `buildSessionItem`, after the remote badge), `public/style.css` +- Create: `test/dom-sidebar-bg-badge.test.js` + +**Interfaces:** +- Consumes: `bgAgentSessionIds` (Task 7). + +- [ ] **Step 1: Write the failing test** + +```js +// test/dom-sidebar-bg-badge.test.js — a session the daemon runs carries a "bg" badge once the roster is known. +'use strict'; +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { setupSidebarDom, makeSampleProject } = require('./dom-setup'); + +test('a row whose session id is in bgAgentSessionIds shows the bg badge; the others do not', (t) => { + const ctx = setupSidebarDom(); + t.after(() => ctx.destroy()); + Object.defineProperty(ctx.window, 'bgAgentSessionIds', { value: new Set(['s-top-1']), writable: true, configurable: true }); + const project = makeSampleProject(); + ctx.sidebar.renderProjects([project], false); + const badged = ctx.document.querySelector('[data-session-id="s-top-1"] .bg-badge'); + assert.ok(badged, 'the badge is there'); + assert.equal(badged.textContent, 'bg'); + assert.equal(ctx.document.querySelector('[data-session-id="s-top-2"] .bg-badge'), null); +}); + +test('without the roster global the sidebar renders as before', (t) => { + const ctx = setupSidebarDom(); + t.after(() => ctx.destroy()); + ctx.sidebar.renderProjects([makeSampleProject()], false); + assert.equal(ctx.document.querySelector('.bg-badge'), null); +}); +``` + +`renderProjects(projects, resort)` is the signature in `public/sidebar.js:576`; `test/dom-sidebar-icon-slot.test.js` calls it the same way. + +- [ ] **Step 2: Run it to verify it fails** + +Run: `node --test test/dom-sidebar-bg-badge.test.js` +Expected: the first test FAILS on "the badge is there". + +- [ ] **Step 3: Implement** + +`public/sidebar.js`, in `buildSessionItem` right after the `if (session.remoteAlias) { … }` badge block: + +```js + // see .ai/contexts/bg-agents.md ("The sidebar") + if (typeof bgAgentSessionIds !== 'undefined' && bgAgentSessionIds.has(session.sessionId)) { + const badge = document.createElement('span'); + badge.className = 'bg-badge'; + badge.title = 'Background session run by the claude daemon — click to attach'; + badge.textContent = 'bg'; + summaryEl.prepend(badge); + } +``` + +`public/style.css`, after `.remote-badge { … }`: + +```css +.bg-badge { + display: inline-block; margin-right: 5px; padding: 0 5px; + border: 1px solid #5a4a7a; border-radius: 3px; color: #b39ddb; + font-size: 10px; line-height: 15px; vertical-align: middle; +} +``` + +- [ ] **Step 4: Run the sidebar tests** + +Run: `node --test test/dom-sidebar-bg-badge.test.js test/dom-sidebar-icon-slot.test.js && npx eslint public/sidebar.js` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +/usr/bin/git add public/sidebar.js public/style.css test/dom-sidebar-bg-badge.test.js +/usr/bin/git commit -m "(sidebar): badge the sessions the daemon runs in the background" +``` + +--- + +### Task 9: The dispatch dialog + +**Files:** +- Modify: `public/dialogs.js` (append `showDispatchAgentDialog`) +- Create: `test/dom-dispatch-dialog.test.js` + +**Interfaces:** +- Consumes: `window.api.dispatchBgAgent(fields)` (Task 5), `selectAgentsRow(id)` (Task 7), `cachedAllProjects`, `PERMISSION_MODES`, `SETTING_DEFAULTS`, `escapeHtml`, `shortProjectPath`. +- Produces: `showDispatchAgentDialog(project | null)`. + +- [ ] **Step 1: Write the failing test** + +```js +// test/dom-dispatch-dialog.test.js — the fields become exactly the dispatch payload. See .ai/contexts/bg-agents.md. +'use strict'; +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const { JSDOM } = require('jsdom'); + +const PUBLIC = path.join(__dirname, '..', 'public'); +const tick = () => new Promise(r => setTimeout(r, 0)); + +function setup({ dispatchResult = { ok: true, id: 'cccccccc' } } = {}) { + const dom = new JSDOM('', { url: 'http://localhost/', runScripts: 'outside-only', pretendToBeVisual: true }); + const { window } = dom; + const calls = { dispatched: [], selected: [] }; + window.api = { + platform: 'linux', + getEffectiveSettings: async () => ({ permissionMode: 'auto', addDirs: '' }), + dispatchBgAgent: async (fields) => { calls.dispatched.push(fields); return dispatchResult; }, + }; + const g = { + cachedAllProjects: [{ projectPath: '/w/one' }, { projectPath: '/w/two' }], + selectAgentsRow: (id) => calls.selected.push(id), + launchNewSession: () => {}, cachedProjects: [], sessionMap: new Map(), pendingSessions: new Map(), + openSessions: new Map(), activePtyIds: new Set(), refreshSidebar: () => {}, pollActiveSessions: () => {}, + }; + for (const [k, v] of Object.entries(g)) Object.defineProperty(window, k, { value: v, writable: true, configurable: true }); + for (const f of ['setting-defaults.js', 'utils.js', 'icons.js', 'dialogs.js']) { + vm.runInContext(fs.readFileSync(path.join(PUBLIC, f), 'utf8'), dom.getInternalVMContext(), { filename: f }); + } + return { window, document: window.document, calls, destroy: () => window.close() }; +} + +test('Start sends the trimmed fields with the chosen project and mode, then selects the new row', async (t) => { + const ctx = setup(); t.after(ctx.destroy); + await ctx.window.showDispatchAgentDialog({ projectPath: '/w/two' }); + const d = ctx.document; + assert.equal(d.querySelector('#dad-project').value, '/w/two', 'the given project is preselected'); + d.querySelector('#dad-prompt').value = ' review the backlog '; + d.querySelector('#dad-name').value = 'em-1'; + d.querySelector('#dad-agent').value = 'fleet:em'; + d.querySelector('#dad-add-dirs').value = '/srv/a'; + d.querySelector('.permission-option[data-mode="plan"]').click(); + d.querySelector('.new-session-start-btn').click(); + await tick(); await tick(); + assert.deepEqual(ctx.calls.dispatched, [{ prompt: 'review the backlog', name: 'em-1', agent: 'fleet:em', cwd: '/w/two', permissionMode: 'plan', dangerouslySkipPermissions: false, addDirs: '/srv/a' }]); + assert.deepEqual(ctx.calls.selected, ['cccccccc']); + assert.equal(d.querySelector('.new-session-overlay'), null, 'the dialog closed'); +}); + +test('an empty prompt never reaches main, and a failed dispatch keeps the dialog open with the error', async (t) => { + const ctx = setup({ dispatchResult: { ok: false, error: 'daemon said no' } }); t.after(ctx.destroy); + await ctx.window.showDispatchAgentDialog(null); + const d = ctx.document; + d.querySelector('.new-session-start-btn').click(); + await tick(); + assert.equal(ctx.calls.dispatched.length, 0); + assert.match(d.querySelector('#dad-error').textContent, /prompt/); + d.querySelector('#dad-prompt').value = 'go'; + d.querySelector('.new-session-start-btn').click(); + await tick(); await tick(); + assert.equal(ctx.calls.dispatched.length, 1); + assert.equal(ctx.calls.dispatched[0].cwd, '/w/one', 'first project by default'); + assert.match(d.querySelector('#dad-error').textContent, /daemon said no/); + assert.ok(d.querySelector('.new-session-overlay'), 'still open'); +}); + +test('Enter inside the prompt does not start; Escape closes', async (t) => { + const ctx = setup(); t.after(ctx.destroy); + await ctx.window.showDispatchAgentDialog(null); + const d = ctx.document; + const prompt = d.querySelector('#dad-prompt'); + prompt.value = 'x'; + prompt.dispatchEvent(new ctx.window.KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + await tick(); + assert.equal(ctx.calls.dispatched.length, 0); + d.dispatchEvent(new ctx.window.KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + assert.equal(d.querySelector('.new-session-overlay'), null); +}); +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `node --test test/dom-dispatch-dialog.test.js` +Expected: FAIL, `showDispatchAgentDialog is not a function`. + +- [ ] **Step 3: Implement** + +Append to `public/dialogs.js`: + +```js +// --- Dispatch a background agent — see .ai/contexts/bg-agents.md ("Dispatch") --- +async function showDispatchAgentDialog(project) { + const projects = (typeof cachedAllProjects !== 'undefined' ? cachedAllProjects : []) + .map(p => p && p.projectPath).filter(Boolean); + const requested = project && project.projectPath; + if (requested && !projects.includes(requested)) projects.unshift(requested); + const defaultPath = requested || projects[0] || ''; + let effective = {}; + if (defaultPath) { + try { effective = await window.api.getEffectiveSettings(defaultPath); } catch { effective = {}; } + } + + const overlay = document.createElement('div'); + overlay.className = 'new-session-overlay'; + const dialog = document.createElement('div'); + dialog.className = 'new-session-dialog'; + + let selectedMode = effective.permissionMode || null; + let dangerousSkip = !!effective.dangerouslySkipPermissions; + + function renderModeGrid() { + return PERMISSION_MODES.map(m => { + const isSelected = !dangerousSkip && selectedMode === m.value; + return ``; + }).join('') + + ``; + } + + dialog.innerHTML = ` +

    New background agent

    +
    +
    + Prompt +
    The task the agent runs, in the background, under the claude daemon
    +
    +
    + +
    +
    +
    +
    + Project +
    Working directory of the agent
    +
    +
    + +
    +
    +
    +
    + Name +
    --name; empty lets the CLI pick one
    +
    +
    + +
    +
    +
    +
    + Agent +
    --agent, e.g. fleet:em; empty for none
    +
    +
    + +
    +
    +
    +
    Permission Mode
    +
    ${renderModeGrid()}
    +
    +
    +
    + Additional Directories +
    Extra directories to include (comma-separated)
    +
    +
    + +
    +
    +
    +
    + + +
    + `; + overlay.appendChild(dialog); + document.body.appendChild(overlay); + + const modeGrid = dialog.querySelector('#dad-mode-grid'); + modeGrid.addEventListener('click', (e) => { + const btn = e.target.closest('.permission-option'); + if (!btn) return; + const mode = btn.dataset.mode; + if (mode === 'dangerous-skip') { + dangerousSkip = !dangerousSkip; + if (dangerousSkip) selectedMode = null; + } else { + dangerousSkip = false; + selectedMode = mode === 'null' ? null : mode; + } + modeGrid.innerHTML = renderModeGrid(); + }); + + const errorEl = dialog.querySelector('#dad-error'); + const startBtn = dialog.querySelector('.new-session-start-btn'); + + function close() { + overlay.remove(); + document.removeEventListener('keydown', onKey); + } + + async function start() { + const prompt = dialog.querySelector('#dad-prompt').value.trim(); + if (!prompt) { errorEl.textContent = 'A prompt is required.'; return; } + const fields = { + prompt, + name: dialog.querySelector('#dad-name').value.trim(), + agent: dialog.querySelector('#dad-agent').value.trim(), + cwd: dialog.querySelector('#dad-project').value, + permissionMode: dangerousSkip ? null : selectedMode, + dangerouslySkipPermissions: dangerousSkip, + addDirs: dialog.querySelector('#dad-add-dirs').value.trim(), + }; + errorEl.textContent = ''; + startBtn.disabled = true; + let result; + try { result = await window.api.dispatchBgAgent(fields); } catch (err) { result = { ok: false, error: err.message }; } + startBtn.disabled = false; + if (!result || result.ok === false) { + errorEl.textContent = (result && result.error) || 'unknown error'; + return; + } + close(); + if (result.id && typeof selectAgentsRow === 'function') selectAgentsRow(result.id); + } + + dialog.querySelector('.new-session-cancel-btn').onclick = close; + startBtn.onclick = start; + overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); + + function onKey(e) { + if (e.key === 'Escape') close(); + if (e.key === 'Enter' && !e.target.matches('input, textarea, select')) start(); + } + document.addEventListener('keydown', onKey); + dialog.querySelector('#dad-prompt').focus(); +} +``` + +- [ ] **Step 4: Run the test and the lint** + +Run: `node --test test/dom-dispatch-dialog.test.js && npx eslint public/dialogs.js` +Expected: PASS, 3 tests, 0 lint errors: `cachedAllProjects` is already declared in `eslint.config.js` (line 89), and `selectAgentsRow` / `showDispatchAgentDialog` were added there in Task 7. + +- [ ] **Step 5: Commit** + +```bash +/usr/bin/git add public/dialogs.js test/dom-dispatch-dialog.test.js +/usr/bin/git commit -m "(agents): dispatch a new background agent from a dialog" +``` + +--- + +### Task 10: Documentation and context engineering + +**Files:** +- Create: `docs/background-agents.md`, `.ai/contexts/bg-agents.md` +- Modify: `README.md` (feature table), `docs/README.md`, `docs/keyboard-shortcuts.md`, `docs/settings.md` (if it lists `localStorage` keys; otherwise skip), `.ai/contexts/ipc-bridge.md`, `.ai/contexts/README.md`, `.ai/contexts/cli-session-state.md`, `.ai/shared-guidelines.md` + +- [ ] **Step 1: Write `docs/background-agents.md`** + +```markdown +# Background agents + +The Agents view is Switchboard's replacement for the `claude agents` TUI: it +lists the sessions the Claude CLI daemon runs in the background +(`claude --bg`) and the interactive `claude` sessions running outside this +Switchboard, and acts on them without a terminal. + +## Opening it + +The people icon in the sidebar's filter row, or `Ctrl+Shift+A` (`Cmd+Shift+A` +on macOS; [rebindable](keyboard-shortcuts.md)). The same toggle closes it and +brings back whatever was there: the grid, the active session, or the +placeholder. Whether the view is open is remembered across restarts. + +## The list + +One row per session: a state glyph (the same rungs as the sidebar: spinner +while busy, orange while waiting, green when idle, grey when finished), its +name, its `--agent`, `state · status`, its directory, and its age. Working +sessions and external interactive sessions come first, newest first. +**Finished** shows or hides `done` and `stopped` sessions; the choice is +remembered. + +Selecting a row opens its detail: the daemon's one-line status, tokens, +model, start time, pid, the subagents it ran, the links it produced (merge +requests open in the browser), its last result, and the verbs: + +| Verb | Runs | Available | +|---|---|---| +| Attach | `claude attach ` in a terminal tab | while the session is `working` | +| Transcript | the read-only transcript viewer | whenever the transcript exists | +| Stop | `claude stop `; the conversation is kept | while `working` | +| Respawn | `claude respawn ` | any background session | +| Delete | `claude rm `, after confirmation; the worktree goes too when that is safe | when not `working` | + +An external interactive session offers Transcript only. + +## Attaching + +An attach tab is an ordinary terminal tab running `claude attach`. Its stop +button reads **Detach**: closing the tab sends Ctrl+Z, the attach client +leaves, and the session keeps running under the daemon. Stopping the session +is only offered in the Agents view. Attach tabs are not reopened by +[session restore](session-restore.md). + +A click in the sidebar on a session the daemon is running attaches to it +instead of asking to resume it; the row carries a `bg` badge once the Agents +view has been opened. A finished background session resumes like any other. + +## New agent + +**New agent** opens a dialog: prompt, project, name (`--name`), agent +(`--agent`), permission mode or Dangerous Skip, additional directories. It +runs `claude --bg …` in the project directory and selects the new row. + +## When the daemon does not answer + +The view reads two files the CLI writes for itself, `~/.claude/jobs//state.json` +and `~/.claude/sessions/.json`, and asks `claude agents --json --all` +which sessions exist. When that command fails, a banner says so, the list +comes from the files alone, and every verb but Transcript is disabled. +Neither file is a documented interface; a CLI upgrade may change them, and +`test/canary-bg-agents-files.test.js` says so when it happens. +``` + +- [ ] **Step 2: Write `.ai/contexts/bg-agents.md`** + +```markdown +# Context: bg-agents + +**Purpose**: The Agents view — a graphical replacement for the `claude agents` +TUI. Lists the daemon's `--bg` sessions and the external interactive ones, +attaches/stops/respawns/deletes/dispatches through the CLI. Design: +`docs/superpowers/specs/2026-09-30-background-agents-view-design.md`. + +## Key files + +| File | Role | +|---|---| +| `bg-agents-roster.js` | Pure: `parseJobState`, `parseCliList`, `mergeRoster`, `dispatchArgs`, `parseDispatchOutput` | +| `bg-agents.js` | Watchers over `~/.claude/jobs/*/state.json`, descriptor subscription, `reconcile()` through `claude agents --json --all`, `runVerb`, `dispatch`, `onChange` | +| `bg-agents-ipc.js` | `get-bg-agents`, `bg-agent-verb`, `dispatch-bg-agent`, the `bg-agents-changed` push | +| `cli-session-state.js` | `onDescriptorsChanged`, `readAllDescriptors`, `kind`/`jobId` on live-elsewhere | +| `pty-ops.js` | `detachPty` | +| `main.js` | `runClaudeCommand`; the `type: 'attach'` branch of `open-terminal`; detach in `stop-session` | +| `public/agents-view.js` | The view; `bgAgentSessionIds` for the sidebar badge | +| `public/resume-guard.js` | A live `kind: 'bg'` descriptor answers `{ attach }` | +| `public/dialogs.js` | `showDispatchAgentDialog` | + +## Invariants + +1. Never `--resume` or `--fork-session` a session whose job is `working`. + `claude attach` is the only path to a live job (`guardResume` turns a + `bg` verdict into attach options; `open-terminal` builds `claude attach`). +2. Every call to the CLI goes through the login shell with an argv quoted by + `quoteArgvForShell` (`runClaudeCommand`, the scheduler's path). Never a + command string built by hand. The daemon's control socket and + `control.key` are never touched. +3. Closing an attach tab detaches (`\x1a`, 2 s grace, then kill — + `detachPty`). `claude stop` is the only stop. App quit kills the attach + client outright; the CLI documents that the session survives either way. +4. No steady-state cost before the view is first opened: `bgAgents.start()` + runs on the first `get-bg-agents`. Closing the view keeps the watchers so + the sidebar badge stays current; the window's `closed` handler releases + them. +5. `jobs/` and the `kind: "bg"` descriptor are undocumented. Failure is + silence: an unreadable `state.json` keeps the previous value; a CLI that + fails leaves a file-only roster with `daemonReachable: false`. Canaries: + `test/canary-bg-agents-files.test.js`, `test/canary-cli-session-state.test.js`. +6. A verb's id is validated against `JOB_ID_RE` before any spawn; a prompt + starting with `-` is refused by `dispatchArgs`. + +## Data flow + +`jobs//state.json` (fs.watch, per directory) and `sessions/.json` +(through `cli-session-state`'s flush) both call `scheduleRebuild()`, +coalesced at `FLUSH_MS` (250 ms). `rebuild()` = `mergeRoster(cli, jobs, +readAllDescriptors())`. The CLI list is the authority for which jobs exist +and their `state`; the file supplies `detail`, `tokens`, `fan`, `children`, +`result`, `--agent`/`--model`/`--name`; the descriptor supplies `status`, +`pid`, `agent`. `reconcile()` runs on every `get-bg-agents` (the renderer +calls it on open and every 30 s while visible) and after every verb. + +## Non-obvious behaviors + +- The view is a sibling of `#jsonl-viewer`, shown by hiding + `#terminal-area` (as the Stats tab does), so the grid's state survives. + `hideAllViewers()` calls `hideAgentsView({ restore: false })`; only the + toggle restores the terminal area. +- `claude --bg` prints its id in a format nobody measured (2026-09-30); + `parseDispatchOutput` takes the first eight-hex token and `dispatch` + reports `ok: true, id: null` otherwise — the row arrives through the files. +- An attach tab's `cli-session-state` status comes from the daemon worker's + descriptor (same `sessionId`), so busy/idle needs no special path. +- Attach tabs are excluded from the working set (`entry.attach`). + +## Measured facts (CLI 2.1.285, Linux, 2026-09-30) + +- `claude agents --json --all`: ~0.15 s CPU; array of `{id, sessionId, name, + cwd, kind, startedAt, pid?, state?, status?}`. +- `claude attach ` in a pty: Ctrl+Z detaches, client exits 0, session + stays `working`. +- `claude logs ` prints screen ANSI, unusable without xterm — not used. + +## If you change this, also check + +- `.ai/contexts/ipc-bridge.md` (the three handlers, the event) +- `.ai/contexts/cli-session-state.md` (the descriptor hooks) +- `docs/background-agents.md`, `docs/keyboard-shortcuts.md` +``` + +- [ ] **Step 3: Rows in the shared docs** + +`README.md`, feature table, after the Subagents row: + +``` +| The sessions the claude daemon runs in the background: list, attach, stop, dispatch | [Background agents](docs/background-agents.md) | +``` + +`docs/README.md`, after the Subagents row: + +``` +| [Background agents](background-agents.md) | The Agents view: the daemon's `--bg` sessions, attach in a tab, stop, respawn, delete, dispatch | +``` + +`docs/keyboard-shortcuts.md`, in the rebindable table after the grid row: + +``` +| Toggle agents view | Primary+Shift+`A` | Show or hide the [background agents](background-agents.md) view | +``` + +and change "how to rebind the three that can be" in `docs/README.md` to "the four". + +`docs/settings.md` does not list `localStorage` keys (checked 2026-09-30: no `gridViewActive` in it), so it is not touched; the two keys are named in `docs/background-agents.md` ("remembered"). + +`.ai/contexts/ipc-bridge.md`: a new subsection before "Misc": + +``` +### Background agents (see `.ai/contexts/bg-agents.md`) + +| IPC | Args | Returns | Notes | +|---|---|---|---| +| `get-bg-agents` | — | `{roster, daemonReachable}` | Arms the watchers on first 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`. | +| `dispatch-bg-agent` | `(fields)` | `{ok, id?, error?}` | `claude --bg …` in `fields.cwd`. | + +`open-terminal` accepts `sessionOptions = {type: 'attach', jobId, cwd}` and runs `claude attach `; `stop-session` on such a session detaches (`{ok, detached: true}`). +``` + +and add `bg-agents-changed` to the events list. Add `bg-agents-ipc.js` to the "Key files" table with the same warning as `schedule-ipc.js`. + +`.ai/contexts/README.md`, in the "When to read which" table: + +``` +| The Agents view, the daemon's job files, attach/detach, dispatch | [bg-agents](bg-agents.md) | +``` + +`.ai/contexts/cli-session-state.md`: a short section "Descriptor hooks for the agents view": `onDescriptorsChanged` fires once per flushed batch; `readAllDescriptors` returns the live descriptors' subset; `liveElsewhere` results carry `kind`/`jobId`; pointer to `bg-agents.md`. + +`.ai/shared-guidelines.md`: an orientation row ("Change the Agents view, the daemon's job files, attach/detach, dispatch | [contexts/bg-agents.md]") and a fork-feature bullet ("**Background agents view** — the daemon's `--bg` sessions listed, attached, stopped, dispatched; see [contexts/bg-agents.md]"). + +- [ ] **Step 4: Lint the markdown links by reading them once** + +Run: `ls docs/background-agents.md .ai/contexts/bg-agents.md && grep -n "background-agents\|bg-agents" README.md docs/README.md docs/keyboard-shortcuts.md .ai/contexts/README.md .ai/contexts/ipc-bridge.md .ai/shared-guidelines.md` +Expected: each file lists at least one hit. + +- [ ] **Step 5: Commit** + +```bash +/usr/bin/git add README.md docs/ .ai/ +/usr/bin/git commit -m "(docs): document the background agents view and its context" +``` + +--- + +### Task 11: Comment sweep, full check, live check, PR + +**Files:** every file touched above. + +- [ ] **Step 1: Comment sweep** + +Run: `/usr/bin/git diff main --stat && /usr/bin/git diff main -- '*.js' | grep -n "^+.*//" | grep -v "see .ai/contexts" ` +Expected: only one-line pointers remain. Move any rationale that survived into `.ai/contexts/bg-agents.md` and delete it from the code. + +- [ ] **Step 2: Full check** + +Run: `task check` +Expected: 0 errors (pre-existing warnings are fine), every test passing including the canaries (or skipping where the files are absent). + +- [ ] **Step 3: Live check against the isolated instance** + +Follow `docs/testing-a-pr.md` with this branch. In the test instance: + +1. In a shell, `cd` to any project and run `claude --bg --name plan-check "say hello and wait"`. Note the printed id and its exact output format; if `parseDispatchOutput` would not have found it, fix the regex in `bg-agents-roster.js` and its test, and record the format in `.ai/contexts/bg-agents.md`. +2. `Ctrl+Shift+A`: the row shows `working`, its detail line, and the sidebar row of that session carries `bg`. +3. Attach: a tab opens on the session; the header button reads Detach; close it; the Agents view still says `working`. +4. Click the same session in the sidebar: it attaches without a "Resume anyway?" dialog. +5. Stop from the view: the state goes `stopped`; Delete asks and removes it. +6. New agent with a prompt and the same project: a new row appears and is selected. +7. Quit the daemon-less path: `mv ~/.claude/daemon.lock ~/.claude/daemon.lock.bak`, reopen the view: the banner shows and the list still lists the jobs; restore the file. + +Record anything that differs from the plan in `.ai/contexts/bg-agents.md` before the PR. + +- [ ] **Step 4: Squash to clear commits and open the PR** + +Keep one commit per task if they read well, or squash into: roster + watchers (main), attach/detach (main + renderer), the view, the dialog, docs. Then: + +```bash +/usr/bin/git push -u origin worktree-background-agents-view +gh pr create --repo devsuitup/switchboard --base main --title "(agents): a view of the sessions the claude daemon runs in the background" --body-file - <<'EOF' +A graphical replacement for the `claude agents` TUI. + +- Lists the daemon's `--bg` sessions and the interactive sessions running outside this Switchboard, from `~/.claude/jobs/*/state.json` and the session descriptors, reconciled by `claude agents --json --all`. +- Attach opens `claude attach ` in a terminal tab keyed by the session's real id; closing it detaches (Ctrl+Z), never kills. +- Stop, respawn, delete and dispatch go through the CLI with a quoted argv. +- A click in the sidebar on a session the daemon runs attaches instead of asking to resume. + +Design: docs/superpowers/specs/2026-09-30-background-agents-view-design.md +Context: .ai/contexts/bg-agents.md +EOF +``` + +The review loop and the reviewer request (`gh api -X POST repos/devsuitup/switchboard/pulls//requested_reviewers -f 'reviewers[]=devsuitup'`) follow `.ai/shared-guidelines.md` "When you finish work", step 6, once the review converges. From 11cf36142ca72cd37973de11c5f3c4e8c05e64cd Mon Sep 17 00:00:00 2001 From: pjay Date: Wed, 30 Sep 2026 17:37:42 +0200 Subject: [PATCH 03/38] docs(spec): record the three deviations taken while planning the agents view --- ...026-09-30-background-agents-view-design.md | 28 +++++++++++-------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/docs/superpowers/specs/2026-09-30-background-agents-view-design.md b/docs/superpowers/specs/2026-09-30-background-agents-view-design.md index d1a1ced7..d1a0f6f5 100644 --- a/docs/superpowers/specs/2026-09-30-background-agents-view-design.md +++ b/docs/superpowers/specs/2026-09-30-background-agents-view-design.md @@ -138,9 +138,10 @@ IPC, and two callbacks from `app.js`: open a terminal tab, open the JSONL viewer. Renders with `morphdom` from an in-memory model so a roster update keeps the selection and the scroll. -Container `#agents-viewer` inside `#terminal-area`, a sibling of -`#grid-viewer`, shown and hidden the way the grid is (hide the active -terminal, refit on return). Toggle button in the sidebar filter row next to +Container `#agents-viewer`, a sibling of `#jsonl-viewer` outside +`#terminal-area`, shown the way the Stats tab shows its viewer (hide +`#terminal-area`, which keeps the grid's state intact) and hidden by +restoring whichever of grid, active session or placeholder was there. Toggle button in the sidebar filter row next to the grid button; shortcut `agentsToggle` (default Ctrl+Shift+A, Cmd on macOS) registered in `shortcuts.js` and listed in `docs/keyboard-shortcuts.md`. Open state persists in `localStorage.agentsViewActive`. Closing the view does not @@ -167,17 +168,14 @@ Layout: a master list and a detail pane. - List row: state glyph reusing the rungs of `session-state.js` (busy spinner, waiting orange, idle green, done/stopped grey, external interactive as a hollow circle), name, `--agent`, `state·status`, - abbreviated project path, age, a `⋯` menu. Sort: `working` first, then + abbreviated project path, age. Sort: `working` first, then `startedAt` descending. The "Finished" filter (on by default) shows or hides `done`/`stopped`; persisted in `localStorage.agentsShowFinished`. - Detail pane for the selected row: `detail`, tokens, model, start time, pid, `fan[]` with duration and state, `children[]` as clickable links (`shell.openExternal`, already exposed), `output.result`. For an external interactive session: name, cwd, status, and only the Transcript action. -- `⋯` menu and detail buttons: Attach, Transcript, Stop, Respawn, Delete, - disabled by state (Stop only when `working`; Delete never when `working`; - Respawn and Attach never on an interactive session). A verb in flight greys - the row; its error shows in the detail pane, never in a modal. +- A row click selects it; the detail pane carries the verbs. - "New agent" opens the dispatch dialog. - Banner under the header when `daemonReachable` is false: "The daemon is not answering; state comes from files only." Verbs other than Transcript @@ -206,7 +204,9 @@ daemon worker's descriptor with no change. No `--resume`, no fork, ever. like any pty. **Stop, Respawn, Delete.** `execFile('claude', [verb, id])` in the session's -cwd, 15 s timeout, no shell. Each returns `{ok, error}` (stderr verbatim) +cwd, 15 s timeout, through the user's login shell with an argv quoted by +`quoteArgvForShell`, the scheduler's existing path in `main.js`; never a +command string built by hand. Each returns `{ok, error}` (stderr verbatim) and triggers a reconciliation. Delete asks for confirmation with the CLI's own wording: the conversation and its worktree go, when that is safe. Stop or Delete on a session attached here detaches first. @@ -223,7 +223,9 @@ same pieces as the New Session dialog: | Permission mode / Dangerous Skip (as in New Session) | `--permission-mode ` or `--dangerously-skip-permissions` | | Additional directories | one `--add-dir` per entry, through the existing `parseAddDirs` | -Command: `claude --bg [options] ` via `execFile`, never a shell. The +Command: `claude --bg [options] ` via `execFile`, through the user's +login shell with an argv quoted by `quoteArgvForShell`, the scheduler's +existing path in `main.js`; never a command string built by hand. The printed id is parsed; on success the roster is reconciled and the new row selected. If the id does not parse, the result is `{ok: true, id: null}`; the row appears through the files. @@ -257,8 +259,10 @@ badge and no cost, and the guard's protection does not depend on it. 1. Never `--resume` or `--fork-session` a session whose job is `working`. `claude attach` is the only path to a live job. -2. Every write to the daemon goes through the CLI with `execFile` and no - shell. The control socket and `control.key` are never touched. +2. Every write to the daemon goes through the CLI through the user's login + shell with an argv quoted by `quoteArgvForShell`, the scheduler's existing + path in `main.js`; never a command string built by hand. The control socket + and `control.key` are never touched. 3. Closing an attach tab detaches; it never kills the session. `claude stop` is the only stop. 4. No steady-state cost before the view is first opened (ADR 0002). From dcfa66f4ddd1ce64d5f24f4351efbcb8bc475c80 Mon Sep 17 00:00:00 2001 From: pjay Date: Wed, 30 Sep 2026 17:41:20 +0200 Subject: [PATCH 04/38] (bg-agents): parse the daemon's job files and the CLI list into one roster Claude-Session: https://claude.ai/code/session_01WriJRPX84KSTHVY9wuKWyo --- bg-agents-roster.js | 176 ++++++++++++++++++++++++++++++++++ test/bg-agents-roster.test.js | 132 +++++++++++++++++++++++++ 2 files changed, 308 insertions(+) create mode 100644 bg-agents-roster.js create mode 100644 test/bg-agents-roster.test.js diff --git a/bg-agents-roster.js b/bg-agents-roster.js new file mode 100644 index 00000000..d4c64e2b --- /dev/null +++ b/bg-agents-roster.js @@ -0,0 +1,176 @@ +// bg-agents-roster.js — see .ai/contexts/bg-agents.md +'use strict'; + +const JOB_STATES = new Set(['working', 'done', 'stopped']); +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), + }; +} + +function parseCliList(text) { + let raw; + try { raw = JSON.parse(text); } catch { return null; } + 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 = 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, JOB_ID_RE, JOB_STATES, +}; diff --git a/test/bg-agents-roster.test.js b/test/bg-agents-roster.test.js new file mode 100644 index 00000000..f2ef77de --- /dev/null +++ b/test/bg-agents-roster.test.js @@ -0,0 +1,132 @@ +// test/bg-agents-roster.test.js — pure parsing and merging for the agents view. +// See .ai/contexts/bg-agents.md. +'use strict'; +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { + parseJobState, parseCliList, mergeRoster, dispatchArgs, parseDispatchOutput, JOB_ID_RE, +} = require('../bg-agents-roster'); + +const STATE = JSON.stringify({ + state: 'done', + detail: 'backlog reviewed; awaiting !196 merge', + tempo: 'idle', + tokens: 172999, + fan: [{ id: 'a4a8', kind: 'agent', label: 'Spawn developer', startedAt: 1790590786510, doneAt: 1790590813354 }, 'junk'], + children: [{ id: '195', href: 'https://gitlab.com/x/-/merge_requests/195', kind: 'merge_request' }], + output: { result: 'no new action needed' }, + template: 'fleet:em', + respawnFlags: ['--plugin-dir', '/x', '--agent', 'fleet:em', '--permission-mode', 'auto', '--name', 'em-platform', '--model', 'claude-sonnet-5'], + linkScanPath: '/home/u/.claude/projects/-home-u-p/bc3fd129-60bb-4bd2-8f38-63fecd1256e5.jsonl', +}); + +test('parseJobState keeps the fields the view shows and derives agent/model/name/sessionId', () => { + const job = parseJobState(STATE); + assert.equal(job.state, 'done'); + assert.equal(job.detail, 'backlog reviewed; awaiting !196 merge'); + assert.equal(job.tokens, 172999); + assert.deepEqual(job.fan, [{ id: 'a4a8', kind: 'agent', label: 'Spawn developer', startedAt: 1790590786510, doneAt: 1790590813354 }]); + assert.deepEqual(job.children, [{ id: '195', href: 'https://gitlab.com/x/-/merge_requests/195', kind: 'merge_request' }]); + assert.equal(job.result, 'no new action needed'); + assert.equal(job.agent, 'fleet:em'); + assert.equal(job.model, 'claude-sonnet-5'); + assert.equal(job.name, 'em-platform'); + assert.equal(job.sessionId, 'bc3fd129-60bb-4bd2-8f38-63fecd1256e5'); +}); + +test('parseJobState: unknown state, missing output and garbage are tolerated', () => { + assert.equal(parseJobState(''), null); + assert.equal(parseJobState('[]'), null); + const job = parseJobState('{"state":"weird","fan":null}'); + assert.equal(job.state, null); + assert.deepEqual(job.fan, []); + assert.equal(job.result, null); + assert.equal(job.sessionId, null); +}); + +test('parseCliList keeps background and interactive entries and drops the rest', () => { + const list = parseCliList(JSON.stringify([ + { id: 'bc3fd129', pid: 346590, cwd: '/w', kind: 'background', startedAt: 1, sessionId: 's-bg', name: 'em', status: 'idle', state: 'working' }, + { pid: 5, cwd: '/w', kind: 'interactive', startedAt: 2, sessionId: 's-int', name: 'n', status: 'busy' }, + { kind: 'background', sessionId: '' }, + 'junk', + ])); + assert.equal(list.length, 2); + assert.deepEqual(list[0], { id: 'bc3fd129', sessionId: 's-bg', name: 'em', cwd: '/w', kind: 'background', state: 'working', status: 'idle', pid: 346590, startedAt: 1 }); + assert.equal(list[1].kind, 'interactive'); + assert.equal(list[1].id, null); + assert.equal(parseCliList('not json'), null); + assert.equal(parseCliList('{}'), null); +}); + +function fixture() { + const cli = [ + { id: 'aaaaaaaa', sessionId: 's-a', name: 'a', cwd: '/a', kind: 'background', state: 'working', status: 'idle', pid: 10, startedAt: 100 }, + { id: 'bbbbbbbb', sessionId: 's-b', name: 'b', cwd: '/b', kind: 'background', state: 'done', status: null, pid: null, startedAt: 50 }, + { id: null, sessionId: 's-own', name: 'own', cwd: '/o', kind: 'interactive', state: null, status: 'busy', pid: 20, startedAt: 70 }, + ]; + const jobs = new Map([ + ['aaaaaaaa', parseJobState(JSON.stringify({ state: 'done', detail: 'stale detail', tokens: 5, respawnFlags: ['--agent', 'fleet:em'] }))], + ['cccccccc', parseJobState(JSON.stringify({ state: 'stopped', detail: 'orphan' }))], + ]); + const descriptors = [ + { pid: 10, sessionId: 's-a', kind: 'bg', jobId: 'aaaaaaaa', agent: 'fleet:em', name: 'a', cwd: '/a', status: 'busy', startedAt: 100 }, + { pid: 20, sessionId: 's-own', kind: 'interactive', jobId: null, agent: null, name: 'own', cwd: '/o', status: 'busy', startedAt: 70 }, + { pid: 30, sessionId: 's-ext', kind: 'interactive', jobId: null, agent: null, name: 'ext', cwd: '/e', status: 'waiting', startedAt: 80 }, + { pid: 40, sessionId: 's-nojob', kind: 'bg', jobId: 'dddddddd', agent: null, name: 'x', cwd: '/x', status: 'idle', startedAt: 90 }, + ]; + return { cli, jobs, descriptors, isOwnPid: (pid) => pid === 20, isAttachedHere: (id) => id === 'aaaaaaaa' }; +} + +test('mergeRoster: the CLI list decides which jobs exist and their state; the file and the descriptor enrich', () => { + const roster = mergeRoster(fixture()); + const ids = roster.map(e => e.kind === 'background' ? e.id : e.sessionId); + assert.deepEqual(ids, ['aaaaaaaa', 'bbbbbbbb', 's-ext']); + const a = roster[0]; + assert.equal(a.state, 'working', 'the CLI state wins over the file'); + assert.equal(a.status, 'busy', 'the descriptor status wins over the CLI snapshot'); + assert.equal(a.detail, 'stale detail'); + assert.equal(a.tokens, 5); + assert.equal(a.agent, 'fleet:em'); + assert.equal(a.attachedHere, true); + assert.equal(roster[1].attachedHere, false); + assert.equal(roster[1].detail, null, 'a job without a file still lists'); + const ext = roster[2]; + assert.equal(ext.kind, 'interactive'); + assert.equal(ext.id, null); + assert.equal(ext.status, 'waiting'); +}); + +test('mergeRoster without the CLI lists the jobs on disk instead', () => { + const f = fixture(); + const roster = mergeRoster({ ...f, cli: null }); + assert.deepEqual(roster.filter(e => e.kind === 'background').map(e => e.id).sort(), ['aaaaaaaa', 'cccccccc']); + const a = roster.find(e => e.id === 'aaaaaaaa'); + assert.equal(a.state, 'done', 'file state stands when the CLI is unreachable'); + assert.equal(a.sessionId, 's-a', 'the descriptor supplies the session id'); + assert.equal(a.pid, 10); +}); + +test('dispatchArgs builds the argv in a fixed order and omits empty options', () => { + const r = dispatchArgs({ prompt: ' do the thing ', name: 'n1', agent: 'fleet:em', permissionMode: 'auto', addDirs: '/a, /b', cwd: '/proj' }); + assert.deepEqual(r, { ok: true, cwd: '/proj', args: ['--bg', '--name', 'n1', '--agent', 'fleet:em', '--permission-mode', 'auto', '--add-dir', '/a', '--add-dir', '/b', 'do the thing'] }); + const bare = dispatchArgs({ prompt: 'p', cwd: '/proj', name: '', agent: ' ', dangerouslySkipPermissions: true, permissionMode: 'auto' }); + assert.deepEqual(bare.args, ['--bg', '--dangerously-skip-permissions', 'p']); +}); + +test('dispatchArgs refuses an empty prompt, a missing cwd, and a prompt that looks like a flag', () => { + assert.equal(dispatchArgs({ prompt: '', cwd: '/p' }).ok, false); + assert.equal(dispatchArgs({ prompt: 'p' }).ok, false); + const flag = dispatchArgs({ prompt: '--help', cwd: '/p' }); + assert.equal(flag.ok, false); + assert.match(flag.error, /cannot start with/); +}); + +test('parseDispatchOutput finds an eight-hex id anywhere in the output, or returns null', () => { + assert.equal(parseDispatchOutput('Started background session de3dfd18\nattach with claude attach de3dfd18\n'), 'de3dfd18'); + assert.equal(parseDispatchOutput('de3dfd18'), 'de3dfd18'); + assert.equal(parseDispatchOutput('deadbeefcafe is not an id, nor is 12345'), null); + assert.equal(parseDispatchOutput(''), null); + assert.ok(JOB_ID_RE.test('de3dfd18')); + assert.ok(!JOB_ID_RE.test('DE3DFD18')); +}); From 90c50e47910276999df1a25e27a17332d3d286c6 Mon Sep 17 00:00:00 2001 From: pjay Date: Wed, 30 Sep 2026 17:57:18 +0200 Subject: [PATCH 05/38] (cli-state): expose the session descriptors and their bg job id to the agents view Claude-Session: https://claude.ai/code/session_01WriJRPX84KSTHVY9wuKWyo --- cli-session-state.js | 58 +++++++++++++++++++++++ test/canary-bg-agents-files.test.js | 52 +++++++++++++++++++++ test/canary-cli-session-state.test.js | 21 +++++++++ test/cli-session-live-elsewhere.test.js | 2 +- test/cli-session-state.test.js | 62 +++++++++++++++++++++++++ 5 files changed, 194 insertions(+), 1 deletion(-) create mode 100644 test/canary-bg-agents-files.test.js diff --git a/cli-session-state.js b/cli-session-state.js index db1f3ed4..641a1703 100644 --- a/cli-session-state.js +++ b/cli-session-state.js @@ -38,6 +38,9 @@ const lastRescanAt = new Map(); // sessionId -> { status, statusUpdatedAt, pid } for live pids only -- see .ai/contexts/cli-session-state.md const statusBySession = new Map(); const lastProbeAt = new Map(); +const MAX_DESCRIPTOR_SCAN = 1000; +// Listeners told "the directory changed" after each flushed batch -- see .ai/contexts/bg-agents.md +const descriptorListeners = new Set(); function defaultIsProcessAlive(pid) { try { @@ -145,6 +148,54 @@ function parseState(text) { }; } +// The descriptor subset the agents view reads -- see .ai/contexts/bg-agents.md +function parseDescriptor(text) { + let raw; + try { raw = JSON.parse(text); } catch { return null; } + if (!raw || typeof raw !== 'object') return null; + if (!Number.isInteger(raw.pid) || raw.pid <= 0) return null; + if (typeof raw.sessionId !== 'string' || !raw.sessionId) return null; + const s = (v) => (typeof v === 'string' && v ? v : null); + return { + pid: raw.pid, + sessionId: raw.sessionId, + kind: s(raw.kind), + jobId: s(raw.jobId), + agent: s(raw.agent), + name: s(raw.name), + cwd: s(raw.cwd), + status: KNOWN_STATUSES.has(raw.status) ? raw.status : null, + startedAt: Number.isFinite(raw.startedAt) ? raw.startedAt : null, + }; +} + +function readAllDescriptors() { + let names; + try { names = fs.readdirSync(dir); } catch { return []; } + const out = []; + let seen = 0; + for (const name of names) { + if (!STATE_FILE_RE.test(name)) continue; + if (++seen > MAX_DESCRIPTOR_SCAN) break; + let text; + try { text = fs.readFileSync(path.join(dir, name), 'utf8'); } catch { continue; } + const d = parseDescriptor(text); + if (d && isProcessAlive(d.pid)) out.push(d); + } + return out; +} + +function onDescriptorsChanged(listener) { + descriptorListeners.add(listener); + return () => { descriptorListeners.delete(listener); }; +} + +function notifyDescriptorsChanged() { + for (const listener of descriptorListeners) { + try { listener(); } catch (err) { log.warn(`[cli-state] descriptor listener failed: ${err.message}`); } + } +} + function findSession(sessionId) { if (!activeSessions) return null; for (const [key, session] of activeSessions) { @@ -207,6 +258,7 @@ function flush() { const batch = [...pending]; pending.clear(); for (const name of batch) handleFile(name); + if (batch.length > 0) notifyDescriptorsChanged(); } function seed() { @@ -344,6 +396,8 @@ async function scanLiveProcesses(sessionIds, exclude) { pid: raw.pid, cwd: typeof raw.cwd === 'string' ? raw.cwd : null, startedAt: Number.isFinite(raw.startedAt) ? raw.startedAt : null, + kind: typeof raw.kind === 'string' && raw.kind ? raw.kind : null, + jobId: typeof raw.jobId === 'string' && raw.jobId ? raw.jobId : null, }); } return found; @@ -388,6 +442,10 @@ module.exports = { ensureWatching, stop, parseState, + onDescriptorsChanged, + readAllDescriptors, + parseDescriptor, + ownProcessFilter, getStatus, KNOWN_STATUSES, DEFAULT_DIR, diff --git a/test/canary-bg-agents-files.test.js b/test/canary-bg-agents-files.test.js new file mode 100644 index 00000000..fcf328bc --- /dev/null +++ b/test/canary-bg-agents-files.test.js @@ -0,0 +1,52 @@ +// test/canary-bg-agents-files.test.js — canary over an external dependency. +// +// Pins the observed shape of ~/.claude/jobs//state.json, written by the +// Claude CLI's daemon for every `claude --bg` session (CLI 2.1.285, Linux, +// 2026-09-30). bg-agents-roster.js reads it for the agents view. Not a +// documented interface: this test going red means the CLI changed, not that +// Switchboard broke. Skips wherever the directory is absent. +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { JOB_STATES } = require('../bg-agents-roster'); + +const JOBS_DIR = path.join(os.homedir(), '.claude', 'jobs'); + +function listStateFiles() { + try { + return fs.readdirSync(JOBS_DIR) + .filter(n => /^[0-9a-f]{8}$/.test(n)) + .map(n => path.join(JOBS_DIR, n, 'state.json')) + .filter(p => fs.existsSync(p)); + } catch { + return []; + } +} + +test('CANARY: the Claude CLI daemon still writes jobs//state.json in the shape the agents view reads', (t) => { + const files = listStateFiles(); + if (files.length === 0) { + t.skip(`no ${JOBS_DIR}//state.json on this machine — nothing to pin`); + return; + } + for (const file of files) { + let raw; + try { raw = JSON.parse(fs.readFileSync(file, 'utf8')); } catch { continue; } + const seen = `(${file})`; + assert.ok(JOB_STATES.has(raw.state), + `PINNED ASSUMPTION BROKEN: "state" used to be one of ${[...JOB_STATES].join(', ')} ${seen}`); + assert.ok(raw.detail === undefined || raw.detail === null || typeof raw.detail === 'string', + `PINNED ASSUMPTION BROKEN: "detail" used to be a string, the one-line status the view shows ${seen}`); + assert.ok(raw.respawnFlags === undefined || Array.isArray(raw.respawnFlags), + `PINNED ASSUMPTION BROKEN: "respawnFlags" used to be the original argv (--agent, --model, --name) ${seen}`); + assert.ok(raw.linkScanPath === undefined || /\.jsonl$/.test(String(raw.linkScanPath)), + `PINNED ASSUMPTION BROKEN: "linkScanPath" used to end in the session's .jsonl ${seen}`); + assert.ok(raw.fan === undefined || raw.fan === null || Array.isArray(raw.fan), + `PINNED ASSUMPTION BROKEN: "fan" used to be an array of {id, kind, label, startedAt, doneAt} ${seen}`); + } +}); diff --git a/test/canary-cli-session-state.test.js b/test/canary-cli-session-state.test.js index f878f961..aa355a86 100644 --- a/test/canary-cli-session-state.test.js +++ b/test/canary-cli-session-state.test.js @@ -67,3 +67,24 @@ test('CANARY: the Claude CLI still publishes per-session state we can read', (t) t.skip('every state file was mid-write — nothing to pin this run'); } }); + +test('CANARY: a background worker descriptor still carries kind "bg" and its short job id (CLI 2.1.285, 2026-09-30)', (t) => { + const bg = []; + for (const name of listStateFiles()) { + try { + const raw = JSON.parse(fs.readFileSync(path.join(SESSIONS_DIR, name), 'utf8')); + if (raw && raw.kind === 'bg') bg.push({ name, raw }); + } catch {} + } + if (bg.length === 0) { + t.skip('no kind:"bg" descriptor on this machine — start one with `claude --bg` to pin the shape'); + return; + } + for (const { name, raw } of bg) { + const seen = `(${name}, CLI version ${raw.version || 'unknown'})`; + assert.match(String(raw.jobId), /^[0-9a-f]{8}$/, + `PINNED ASSUMPTION BROKEN: a bg descriptor used to carry "jobId", the eight-hex id that joins it to ~/.claude/jobs/ and to \`claude attach \` ${seen}`); + assert.ok(raw.agent === undefined || typeof raw.agent === 'string', + `PINNED ASSUMPTION BROKEN: "agent" used to be a string when present ${seen}`); + } +}); diff --git a/test/cli-session-live-elsewhere.test.js b/test/cli-session-live-elsewhere.test.js index d7041d4a..0907190f 100644 --- a/test/cli-session-live-elsewhere.test.js +++ b/test/cli-session-live-elsewhere.test.js @@ -63,7 +63,7 @@ test('a session whose id is in a state file under a live pid is live elsewhere', writeState(dir, 4242); boot(dir); assert.deepEqual(await cliSessionState.liveElsewhere('sess-1', noPty), - { pid: 4242, cwd: '/work/proj', startedAt: 1790685077444 }); + { pid: 4242, cwd: '/work/proj', startedAt: 1790685077444, kind: 'interactive', jobId: null }); })); test('a session this instance holds a PTY for is not live elsewhere, even with a live state file', () => withDir(async (dir) => { diff --git a/test/cli-session-state.test.js b/test/cli-session-state.test.js index a174258a..b66247da 100644 --- a/test/cli-session-state.test.js +++ b/test/cli-session-state.test.js @@ -427,3 +427,65 @@ test('getStatus keeps returning the cached status within the 5s probe throttle e fs.rmSync(dir, { recursive: true, force: true }); } }); + +// --- Descriptor hooks for the agents view (see .ai/contexts/bg-agents.md) --- + +test('onDescriptorsChanged fires once per flushed batch, and the unsubscribe stops it', async () => { + const dir = mkTmp(); + try { + boot(dir, oneSession()); + let fired = 0; + const off = cliSessionState.onDescriptorsChanged(() => { fired++; }); + writeState(dir, 4242, { status: 'busy', kind: 'bg', jobId: 'aaaaaaaa' }); + writeState(dir, 4243, { status: 'idle', sessionId: 'sess-2' }); + await waitFor(() => fired >= 1); + await delay(SETTLE_MS); + assert.equal(fired, 1, 'two writes inside one FLUSH_MS window are one notification'); + off(); + writeState(dir, 4242, { status: 'idle', kind: 'bg', jobId: 'aaaaaaaa' }); + await delay(SETTLE_MS); + assert.equal(fired, 1); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } +}); + +test('readAllDescriptors returns the live descriptors with their kind, jobId and agent', () => { + const dir = mkTmp(); + try { + writeState(dir, 10, { status: 'idle', kind: 'bg', jobId: 'bc3fd129', agent: 'fleet:em', name: 'em', startedAt: 5 }); + writeState(dir, 11, { status: 'busy', kind: 'interactive', sessionId: 'sess-2' }); + writeState(dir, 12, { status: 'busy', kind: 'interactive', sessionId: 'sess-dead' }); + fs.writeFileSync(path.join(dir, '13.json'), '{not json', 'utf8'); + boot(dir, oneSession(), { isProcessAlive: (pid) => pid !== 12 }); + const all = cliSessionState.readAllDescriptors().sort((a, b) => a.pid - b.pid); + assert.deepEqual(all.map(d => d.pid), [10, 11]); + assert.deepEqual(all[0], { pid: 10, sessionId: 'sess-1', kind: 'bg', jobId: 'bc3fd129', agent: 'fleet:em', name: 'em', cwd: dir, status: 'idle', startedAt: 5 }); + assert.equal(all[1].kind, 'interactive'); + assert.equal(all[1].jobId, null); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } +}); + +test('liveElsewhere reports the descriptor kind and jobId, so a bg session can be attached instead of resumed', async () => { + const dir = mkTmp(); + try { + writeState(dir, 4242, { status: 'idle', kind: 'bg', jobId: 'bc3fd129' }); + boot(dir, new Map()); + const live = await cliSessionState.liveElsewhere('sess-1', () => false, () => []); + assert.equal(live.pid, 4242); + assert.equal(live.kind, 'bg'); + assert.equal(live.jobId, 'bc3fd129'); + writeState(dir, 4242, { status: 'idle' }); + const plain = await cliSessionState.liveElsewhere('sess-1', () => false, () => []); + assert.equal(plain.kind, null); + assert.equal(plain.jobId, null); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } +}); + +test('ownProcessFilter is exported and claims our own PTY pids', () => { + const dir = mkTmp(); + try { + boot(dir, new Map()); + const isOwn = cliSessionState.ownProcessFilter(() => [77]); + assert.equal(isOwn(77), true); + assert.equal(isOwn(78), false); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } +}); From c7cea3d0078f7a5338ecfeb675679dfafc686119 Mon Sep 17 00:00:00 2001 From: pjay Date: Wed, 30 Sep 2026 18:27:21 +0200 Subject: [PATCH 06/38] (bg-agents): recognise the blocked job state the daemon reports Claude-Session: https://claude.ai/code/session_01WriJRPX84KSTHVY9wuKWyo --- bg-agents-roster.js | 2 +- test/bg-agents-roster.test.js | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/bg-agents-roster.js b/bg-agents-roster.js index d4c64e2b..fb2bef06 100644 --- a/bg-agents-roster.js +++ b/bg-agents-roster.js @@ -1,7 +1,7 @@ // bg-agents-roster.js — see .ai/contexts/bg-agents.md 'use strict'; -const JOB_STATES = new Set(['working', 'done', 'stopped']); +const JOB_STATES = new Set(['working', 'blocked', 'done', 'stopped']); 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; diff --git a/test/bg-agents-roster.test.js b/test/bg-agents-roster.test.js index f2ef77de..266859dc 100644 --- a/test/bg-agents-roster.test.js +++ b/test/bg-agents-roster.test.js @@ -130,3 +130,9 @@ test('parseDispatchOutput finds an eight-hex id anywhere in the output, or retur assert.ok(JOB_ID_RE.test('de3dfd18')); assert.ok(!JOB_ID_RE.test('DE3DFD18')); }); + +test('parseJobState and parseCliList keep the blocked state a job reports while it waits', () => { + assert.equal(parseJobState('{"state":"blocked","detail":"awaiting developer MR"}').state, 'blocked'); + const [s] = parseCliList(JSON.stringify([{ id: 'aaaaaaaa', sessionId: 's1', kind: 'background', state: 'blocked' }])); + assert.equal(s.state, 'blocked'); +}); From ee87545af2fcf96a43fe5c152a90693cb864d472 Mon Sep 17 00:00:00 2001 From: pjay Date: Wed, 30 Sep 2026 18:29:52 +0200 Subject: [PATCH 07/38] (pty): detach an attach client with Ctrl+Z before ever killing it Claude-Session: https://claude.ai/code/session_01WriJRPX84KSTHVY9wuKWyo --- pty-ops.js | 13 +++++++++++- test/pty-ops-detach.test.js | 40 +++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 test/pty-ops-detach.test.js diff --git a/pty-ops.js b/pty-ops.js index d92fcb1a..9846be86 100644 --- a/pty-ops.js +++ b/pty-ops.js @@ -46,10 +46,21 @@ function writePty(session, data, sessionId) { return withPty(session, 'write', (pty) => pty.write(data), sessionId); } +// see .ai/contexts/bg-agents.md +function detachPty(session, sessionId, { graceMs = 2000, schedule = setTimeout } = {}) { + const wrote = withPty(session, 'detach', (pty) => pty.write('\x1a'), sessionId); + if (!wrote) return killPty(session, sessionId); + const timer = schedule(() => { + if (!session.exited) killPty(session, sessionId); + }, graceMs); + if (timer && typeof timer.unref === 'function') timer.unref(); + return true; +} + /** The name of the signal node-pty reports on exit (`SIGKILL`), or null when none killed it. */ function ptyExitSignalName(signal, signals = os.constants.signals) { if (!signal) return null; return Object.keys(signals).find((name) => signals[name] === signal) || `signal ${signal}`; } -module.exports = { setPtyOpLogger, withPty, resizePty, killPty, writePty, ptyExitSignalName }; +module.exports = { setPtyOpLogger, withPty, resizePty, killPty, writePty, detachPty, ptyExitSignalName }; diff --git a/test/pty-ops-detach.test.js b/test/pty-ops-detach.test.js new file mode 100644 index 00000000..0b5fd20f --- /dev/null +++ b/test/pty-ops-detach.test.js @@ -0,0 +1,40 @@ +// see .ai/contexts/bg-agents.md +'use strict'; +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { detachPty } = require('../pty-ops'); + +function fakeSession() { + const calls = { writes: [], kills: 0 }; + const session = { exited: false, pty: { write: (d) => calls.writes.push(d), kill: () => { calls.kills++; } } }; + return { session, calls }; +} + +test('detach writes Ctrl+Z and, when the client exits in time, never kills', () => { + const { session, calls } = fakeSession(); + const timers = []; + const ok = detachPty(session, 's1', { graceMs: 2000, schedule: (fn, ms) => { timers.push({ fn, ms }); return { unref() {} }; } }); + assert.equal(ok, true); + assert.deepEqual(calls.writes, ['\x1a']); + assert.equal(timers.length, 1); + assert.equal(timers[0].ms, 2000); + session.exited = true; + timers[0].fn(); + assert.equal(calls.kills, 0); +}); + +test('detach kills once the grace period passes with the client still attached', () => { + const { session, calls } = fakeSession(); + const timers = []; + detachPty(session, 's1', { schedule: (fn) => { timers.push(fn); return {}; } }); + timers[0](); + assert.equal(calls.kills, 1); +}); + +test('detach on a pty that refuses the write falls back to a kill', () => { + const calls = { kills: 0 }; + const session = { exited: false, pty: { write: () => { throw new Error('closed'); }, kill: () => { calls.kills++; } } }; + const ok = detachPty(session, 's1', { schedule: () => { throw new Error('must not schedule'); } }); + assert.equal(ok, true); + assert.equal(calls.kills, 1); +}); From 43da67ccf92b63321f5fe2b9a4914a969286a179 Mon Sep 17 00:00:00 2001 From: pjay Date: Wed, 30 Sep 2026 18:34:28 +0200 Subject: [PATCH 08/38] (bg-agents): keep a roster of the daemon's sessions from its files, reconciled by the CLI Claude-Session: https://claude.ai/code/session_01WriJRPX84KSTHVY9wuKWyo --- bg-agents.js | 215 ++++++++++++++++++++++++++++++++++++ test/bg-agents.test.js | 244 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 459 insertions(+) create mode 100644 bg-agents.js create mode 100644 test/bg-agents.test.js diff --git a/bg-agents.js b/bg-agents.js new file mode 100644 index 00000000..2aca549e --- /dev/null +++ b/bg-agents.js @@ -0,0 +1,215 @@ +// see .ai/contexts/bg-agents.md +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { + parseJobState, parseCliList, mergeRoster, dispatchArgs, parseDispatchOutput, JOB_ID_RE, +} = require('./bg-agents-roster'); + +const DEFAULT_JOBS_DIR = path.join(os.homedir(), '.claude', 'jobs'); +const FLUSH_MS = 250; +const MAX_JOBS = 200; +const LIST_TIMEOUT_MS = 5000; +const VERB_TIMEOUT_MS = 15000; +const VERBS = new Set(['stop', 'respawn', 'rm']); + +let jobsDir = DEFAULT_JOBS_DIR; +let homeDir = os.homedir(); +let log = { info() {}, warn() {}, error() {}, debug() {} }; +let runClaude = null; +let cliSessionState = null; +let makeIsOwnPid = () => () => false; +let isAttachedHere = () => false; + +let started = false; +let dirWatcher = null; +const jobWatchers = new Map(); +const jobs = new Map(); +let cliList = null; +let daemonReachable = false; +let roster = []; +let flushTimer = null; +let unsubscribeDescriptors = null; +const listeners = new Set(); + +function init(ctx) { + stop(); + jobsDir = ctx.jobsDir || DEFAULT_JOBS_DIR; + homeDir = ctx.homeDir || os.homedir(); + log = ctx.log || log; + runClaude = ctx.runClaude; + cliSessionState = ctx.cliSessionState; + makeIsOwnPid = ctx.makeIsOwnPid || (() => () => false); + isAttachedHere = ctx.isAttachedHere || (() => false); +} + +function onChange(listener) { + listeners.add(listener); + return () => { listeners.delete(listener); }; +} + +function getSnapshot() { + return { roster, daemonReachable }; +} + +function emit() { + const snapshot = getSnapshot(); + for (const listener of listeners) { + try { listener(snapshot); } catch (err) { log.warn(`[bg-agents] listener failed: ${err.message}`); } + } +} + +function rebuild() { + let descriptors = []; + try { descriptors = cliSessionState ? cliSessionState.readAllDescriptors() : []; } catch {} + roster = mergeRoster({ + cli: daemonReachable ? cliList : null, + jobs, + descriptors, + isOwnPid: makeIsOwnPid(), + isAttachedHere, + }); + emit(); +} + +function scheduleRebuild() { + if (!started || flushTimer) return; + flushTimer = setTimeout(() => { flushTimer = null; rebuild(); }, FLUSH_MS); + if (typeof flushTimer.unref === 'function') flushTimer.unref(); +} + +function readJob(id) { + let text; + try { text = fs.readFileSync(path.join(jobsDir, id, 'state.json'), 'utf8'); } catch { return; } + const job = parseJobState(text); + if (job) jobs.set(id, job); + else log.debug(`[bg-agents] ${id}/state.json unreadable, keeping the previous value`); +} + +function watchJob(id) { + if (jobWatchers.has(id)) return; + readJob(id); + try { + const watcher = fs.watch(path.join(jobsDir, id), (_eventType, filename) => { + if (filename && filename !== 'state.json') return; + readJob(id); + scheduleRebuild(); + }); + watcher.on('error', () => { try { watcher.close(); } catch {} jobWatchers.delete(id); }); + jobWatchers.set(id, watcher); + } catch (err) { + log.debug(`[bg-agents] cannot watch ${id}: ${err.message}`); + } +} + +function syncJobWatchers() { + let names; + try { names = fs.readdirSync(jobsDir); } catch { names = []; } + const ids = names.filter(n => JOB_ID_RE.test(n)).sort().slice(0, MAX_JOBS); + const wanted = new Set(ids); + for (const [id, watcher] of jobWatchers) { + if (wanted.has(id)) continue; + try { watcher.close(); } catch {} + jobWatchers.delete(id); + jobs.delete(id); + } + for (const id of ids) watchJob(id); + scheduleRebuild(); +} + +function start() { + if (started) return true; + started = true; + try { + dirWatcher = fs.watch(jobsDir, () => syncJobWatchers()); + dirWatcher.on('error', (err) => { log.warn(`[bg-agents] jobs watcher error: ${err.message}`); }); + } catch (err) { + dirWatcher = null; + log.debug(`[bg-agents] cannot watch ${jobsDir}: ${err.message}`); + } + syncJobWatchers(); + if (cliSessionState) { + unsubscribeDescriptors = cliSessionState.onDescriptorsChanged(scheduleRebuild); + try { cliSessionState.ensureWatching(); } catch {} + } + return true; +} + +function stop() { + started = false; + if (dirWatcher) { try { dirWatcher.close(); } catch {} dirWatcher = null; } + for (const watcher of jobWatchers.values()) { try { watcher.close(); } catch {} } + jobWatchers.clear(); + jobs.clear(); + if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; } + if (unsubscribeDescriptors) { unsubscribeDescriptors(); unsubscribeDescriptors = null; } + listeners.clear(); + cliList = null; + daemonReachable = false; + roster = []; +} + +async function run(argv, opts) { + if (typeof runClaude !== 'function') return { code: null, stdout: '', stderr: 'claude runner not configured' }; + try { + return await runClaude(argv, opts); + } catch (err) { + return { code: null, stdout: '', stderr: err && err.message ? err.message : String(err) }; + } +} + +async function reconcile() { + const result = await run(['agents', '--json', '--all'], { cwd: homeDir, timeout: LIST_TIMEOUT_MS }); + const list = result.code === 0 ? parseCliList(result.stdout) : null; + if (list) { + cliList = list; + daemonReachable = true; + syncJobWatchers(); + } else { + cliList = null; + daemonReachable = false; + log.debug(`[bg-agents] claude agents --json failed: code=${result.code} ${String(result.stderr).trim().slice(0, 200)}`); + } + rebuild(); + return getSnapshot(); +} + +function cwdFor(id) { + const entry = roster.find(e => e.kind === 'background' && e.id === id); + if (entry && entry.cwd && fs.existsSync(entry.cwd)) return entry.cwd; + return homeDir; +} + +async function runVerb(verb, id) { + if (!VERBS.has(verb)) return { ok: false, error: `unknown verb: ${String(verb)}` }; + if (typeof id !== 'string' || !JOB_ID_RE.test(id)) return { ok: false, error: 'invalid background session id' }; + const live = roster.find(e => e.kind === 'background' && e.id === id); + if (verb !== 'stop' && live && (live.state === 'working' || live.state === 'blocked')) { + return { ok: false, error: `cannot ${verb} a ${live.state} session; stop it first` }; + } + const result = await run([verb, id], { cwd: cwdFor(id), timeout: VERB_TIMEOUT_MS }); + const ok = result.code === 0; + const error = ok ? null : (String(result.stderr).trim() || `claude ${verb} exited with ${result.code}`); + await reconcile(); + return ok ? { ok: true } : { ok: false, error }; +} + +async function dispatch(fields) { + const built = dispatchArgs(fields); + if (!built.ok) return { ok: false, error: built.error }; + if (!fs.existsSync(built.cwd)) return { ok: false, error: `project directory no longer exists: ${built.cwd}` }; + const result = await run(built.args, { cwd: built.cwd, timeout: VERB_TIMEOUT_MS }); + if (result.code !== 0) { + return { ok: false, error: String(result.stderr).trim() || `claude --bg exited with ${result.code}` }; + } + const id = parseDispatchOutput(result.stdout); + await reconcile(); + return { ok: true, id }; +} + +module.exports = { + init, start, stop, onChange, getSnapshot, reconcile, runVerb, dispatch, + DEFAULT_JOBS_DIR, FLUSH_MS, MAX_JOBS, LIST_TIMEOUT_MS, VERB_TIMEOUT_MS, +}; diff --git a/test/bg-agents.test.js b/test/bg-agents.test.js new file mode 100644 index 00000000..ff166d82 --- /dev/null +++ b/test/bg-agents.test.js @@ -0,0 +1,244 @@ +// see .ai/contexts/bg-agents.md +'use strict'; +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const bgAgents = require('../bg-agents'); + +function mkTmp() { + return fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'sw-bg-agents-'))); +} +const silentLog = { info() {}, warn() {}, error() {}, debug() {} }; +const delay = (ms) => new Promise(r => setTimeout(r, ms)); +function waitFor(fn, maxMs = 4000) { + return new Promise((resolve, reject) => { + const start = Date.now(); + (function poll() { + if (fn()) return resolve(); + if (Date.now() - start > maxMs) return reject(new Error('timed out')); + setTimeout(poll, 20); + })(); + }); +} + +function writeJob(dir, id, state) { + fs.mkdirSync(path.join(dir, id), { recursive: true }); + fs.writeFileSync(path.join(dir, id, 'state.json'), JSON.stringify(state), 'utf8'); +} + +const CLI_LIST = [ + { id: 'aaaaaaaa', sessionId: 's-a', name: 'a', cwd: '/a', kind: 'background', startedAt: 1, state: 'working', status: 'idle', pid: 10 }, + { id: 'bbbbbbbb', sessionId: 's-b', name: 'b', cwd: '/b', kind: 'background', startedAt: 2, state: 'done' }, +]; + +function fakeCli(overrides = {}) { + const calls = []; + const runClaude = async (argv, opts) => { + calls.push({ argv, opts }); + if (overrides.fail) return { code: 1, stdout: '', stderr: 'boom' }; + if (argv[0] === 'agents') return { code: 0, stdout: JSON.stringify(overrides.list || CLI_LIST), stderr: '' }; + if (argv[0] === '--bg') return { code: 0, stdout: 'Started background session cccccccc\n', stderr: '' }; + return { code: 0, stdout: '', stderr: '' }; + }; + return { calls, runClaude }; +} + +function fakeSessionState(descriptors = []) { + const listeners = new Set(); + return { + listeners, + onDescriptorsChanged: (l) => { listeners.add(l); return () => listeners.delete(l); }, + readAllDescriptors: () => descriptors, + ensureWatching: () => true, + fire() { for (const l of listeners) l(); }, + }; +} + +function boot(dir, { cli = fakeCli(), sessionState = fakeSessionState(), attached = () => false } = {}) { + bgAgents.init({ + jobsDir: dir, log: silentLog, runClaude: cli.runClaude, cliSessionState: sessionState, + makeIsOwnPid: () => () => false, isAttachedHere: attached, + }); + return { cli, sessionState }; +} + +test.afterEach(() => bgAgents.stop()); + +test('reconcile runs `claude agents --json --all`, merges the job files, and reports the daemon reachable', async () => { + const dir = mkTmp(); + try { + writeJob(dir, 'aaaaaaaa', { state: 'working', detail: 'reading rules', tokens: 42 }); + const { cli } = boot(dir); + assert.equal(bgAgents.start(), true); + const snap = await bgAgents.reconcile(); + assert.deepEqual(cli.calls[0].argv, ['agents', '--json', '--all']); + assert.equal(cli.calls[0].opts.timeout, bgAgents.LIST_TIMEOUT_MS); + assert.equal(snap.daemonReachable, true); + assert.deepEqual(snap.roster.map(e => e.id), ['aaaaaaaa', 'bbbbbbbb']); + assert.equal(snap.roster[0].detail, 'reading rules'); + assert.equal(snap.roster[0].tokens, 42); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } +}); + +test('a state.json rewrite reaches listeners once, coalesced, without another CLI call', async () => { + const dir = mkTmp(); + try { + writeJob(dir, 'aaaaaaaa', { state: 'working', detail: 'one' }); + const { cli } = boot(dir); + bgAgents.start(); + await bgAgents.reconcile(); + const seen = []; + bgAgents.onChange((snap) => seen.push(snap.roster.find(e => e.id === 'aaaaaaaa').detail)); + const callsBefore = cli.calls.length; + writeJob(dir, 'aaaaaaaa', { state: 'working', detail: 'two' }); + await waitFor(() => seen.includes('two')); + await delay(bgAgents.FLUSH_MS * 2); + assert.deepEqual(seen, ['two']); + assert.equal(cli.calls.length, callsBefore, 'a file change never spawns the CLI'); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } +}); + +test('a job directory that appears after start is watched too', async () => { + const dir = mkTmp(); + try { + boot(dir); + bgAgents.start(); + await bgAgents.reconcile(); + const seen = []; + bgAgents.onChange((snap) => seen.push((snap.roster.find(e => e.id === 'bbbbbbbb') || {}).detail)); + writeJob(dir, 'bbbbbbbb', { state: 'done', detail: 'late' }); + await waitFor(() => seen.includes('late')); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } +}); + +test('an empty state.json (mid-rewrite) keeps the previous value', async () => { + const dir = mkTmp(); + try { + writeJob(dir, 'aaaaaaaa', { state: 'working', detail: 'kept' }); + boot(dir); + bgAgents.start(); + await bgAgents.reconcile(); + fs.writeFileSync(path.join(dir, 'aaaaaaaa', 'state.json'), '', 'utf8'); + await delay(bgAgents.FLUSH_MS * 3); + assert.equal(bgAgents.getSnapshot().roster[0].detail, 'kept'); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } +}); + +test('a descriptor change rebuilds the roster from readAllDescriptors', async () => { + const dir = mkTmp(); + try { + const descriptors = []; + const sessionState = fakeSessionState(descriptors); + boot(dir, { sessionState }); + bgAgents.start(); + await bgAgents.reconcile(); + const seen = []; + bgAgents.onChange((snap) => seen.push(snap.roster.map(e => e.sessionId).join(','))); + descriptors.push({ pid: 30, sessionId: 's-ext', kind: 'interactive', jobId: null, agent: null, name: 'ext', cwd: '/e', status: 'busy', startedAt: 3 }); + sessionState.fire(); + await waitFor(() => seen.some(s => s.includes('s-ext'))); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } +}); + +test('when the CLI fails the roster comes from the files and the daemon is reported unreachable', async () => { + const dir = mkTmp(); + try { + writeJob(dir, 'cccccccc', { state: 'stopped', detail: 'from disk' }); + boot(dir, { cli: fakeCli({ fail: true }) }); + bgAgents.start(); + const snap = await bgAgents.reconcile(); + assert.equal(snap.daemonReachable, false); + assert.deepEqual(snap.roster.map(e => e.id), ['cccccccc']); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } +}); + +test('runVerb spawns `claude ` in the session cwd when it exists, then reconciles', async () => { + const dir = mkTmp(); + try { + const list = [{ ...CLI_LIST[0], cwd: dir }]; + const { cli } = boot(dir, { cli: fakeCli({ list }) }); + bgAgents.start(); + await bgAgents.reconcile(); + const r = await bgAgents.runVerb('stop', 'aaaaaaaa'); + assert.deepEqual(r, { ok: true }); + const verbCall = cli.calls.find(c => c.argv[0] === 'stop'); + assert.deepEqual(verbCall.argv, ['stop', 'aaaaaaaa']); + assert.equal(verbCall.opts.cwd, dir); + assert.equal(verbCall.opts.timeout, bgAgents.VERB_TIMEOUT_MS); + assert.equal(cli.calls[cli.calls.length - 1].argv[0], 'agents', 'a verb is followed by a reconcile'); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } +}); + +test('runVerb refuses an unknown verb or a malformed id before spawning anything', async () => { + const dir = mkTmp(); + try { + const { cli } = boot(dir); + bgAgents.start(); + assert.equal((await bgAgents.runVerb('kill', 'aaaaaaaa')).ok, false); + assert.equal((await bgAgents.runVerb('stop', '--all')).ok, false); + assert.equal((await bgAgents.runVerb('rm', 'AAAAAAAA')).ok, false); + assert.equal(cli.calls.length, 0); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } +}); + +test('runVerb reports the CLI stderr when it fails', async () => { + const dir = mkTmp(); + try { + boot(dir, { cli: fakeCli({ fail: true }) }); + bgAgents.start(); + const r = await bgAgents.runVerb('rm', 'aaaaaaaa'); + assert.equal(r.ok, false); + assert.equal(r.error, 'boom'); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } +}); + +test('dispatch runs `claude --bg …` in the project directory and returns the printed id', async () => { + const dir = mkTmp(); + try { + const { cli } = boot(dir); + bgAgents.start(); + const r = await bgAgents.dispatch({ prompt: 'hello', name: 'n', cwd: dir }); + assert.deepEqual(r, { ok: true, id: 'cccccccc' }); + const call = cli.calls.find(c => c.argv[0] === '--bg'); + assert.deepEqual(call.argv, ['--bg', '--name', 'n', 'hello']); + assert.equal(call.opts.cwd, dir); + const missing = await bgAgents.dispatch({ prompt: 'hello', cwd: path.join(dir, 'nope') }); + assert.equal(missing.ok, false); + assert.match(missing.error, /no longer exists/); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } +}); + +test('stop releases the watchers: a later write reaches nobody', async () => { + const dir = mkTmp(); + try { + writeJob(dir, 'aaaaaaaa', { state: 'working' }); + boot(dir); + bgAgents.start(); + let fired = 0; + bgAgents.onChange(() => fired++); + bgAgents.stop(); + writeJob(dir, 'aaaaaaaa', { state: 'done' }); + await delay(bgAgents.FLUSH_MS * 3); + assert.equal(fired, 0); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } +}); + +for (const liveState of ['working', 'blocked']) { + test(`runVerb refuses rm and respawn on a ${liveState} session but allows stop`, async () => { + const dir = mkTmp(); + try { + const list = [{ ...CLI_LIST[0], state: liveState, cwd: dir }]; + const { cli } = boot(dir, { cli: fakeCli({ list }) }); + bgAgents.start(); + await bgAgents.reconcile(); + const before = cli.calls.length; + assert.equal((await bgAgents.runVerb('rm', 'aaaaaaaa')).ok, false); + assert.equal((await bgAgents.runVerb('respawn', 'aaaaaaaa')).ok, false); + assert.equal(cli.calls.length, before); + assert.equal((await bgAgents.runVerb('stop', 'aaaaaaaa')).ok, true); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } + }); +} From 71d51c9a37693d2938b2f3b25ec1c03a9464c609 Mon Sep 17 00:00:00 2001 From: pjay Date: Wed, 30 Sep 2026 18:39:55 +0200 Subject: [PATCH 09/38] (bg-agents): drop a reconcile that outlives stop() Claude-Session: https://claude.ai/code/session_01WriJRPX84KSTHVY9wuKWyo --- bg-agents.js | 7 ++++++- test/bg-agents.test.js | 47 ++++++++++++++++++++++++++++++++++++------ 2 files changed, 47 insertions(+), 7 deletions(-) diff --git a/bg-agents.js b/bg-agents.js index 2aca549e..a5288b04 100644 --- a/bg-agents.js +++ b/bg-agents.js @@ -24,6 +24,7 @@ let makeIsOwnPid = () => () => false; let isAttachedHere = () => false; let started = false; +let generation = 0; let dirWatcher = null; const jobWatchers = new Map(); const jobs = new Map(); @@ -89,7 +90,7 @@ function readJob(id) { } function watchJob(id) { - if (jobWatchers.has(id)) return; + if (!started || jobWatchers.has(id)) return; readJob(id); try { const watcher = fs.watch(path.join(jobsDir, id), (_eventType, filename) => { @@ -105,6 +106,7 @@ function watchJob(id) { } function syncJobWatchers() { + if (!started) return; let names; try { names = fs.readdirSync(jobsDir); } catch { names = []; } const ids = names.filter(n => JOB_ID_RE.test(n)).sort().slice(0, MAX_JOBS); @@ -139,6 +141,7 @@ function start() { function stop() { started = false; + generation++; if (dirWatcher) { try { dirWatcher.close(); } catch {} dirWatcher = null; } for (const watcher of jobWatchers.values()) { try { watcher.close(); } catch {} } jobWatchers.clear(); @@ -161,7 +164,9 @@ async function run(argv, opts) { } async function reconcile() { + const gen = generation; const result = await run(['agents', '--json', '--all'], { cwd: homeDir, timeout: LIST_TIMEOUT_MS }); + if (gen !== generation || !started) return getSnapshot(); const list = result.code === 0 ? parseCliList(result.stdout) : null; if (list) { cliList = list; diff --git a/test/bg-agents.test.js b/test/bg-agents.test.js index ff166d82..37328f9f 100644 --- a/test/bg-agents.test.js +++ b/test/bg-agents.test.js @@ -211,18 +211,53 @@ test('dispatch runs `claude --bg …` in the project directory and returns the p } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); -test('stop releases the watchers: a later write reaches nobody', async () => { +function trackWatchers(t) { + const open = new Set(); + const real = fs.watch; + t.mock.method(fs, 'watch', (...args) => { + const w = real.apply(fs, args); + open.add(w); + const close = w.close.bind(w); + w.close = () => { open.delete(w); close(); }; + return w; + }); + return open; +} + +test('stop closes every watcher it opened', async (t) => { const dir = mkTmp(); try { writeJob(dir, 'aaaaaaaa', { state: 'working' }); + writeJob(dir, 'bbbbbbbb', { state: 'done' }); + const open = trackWatchers(t); boot(dir); bgAgents.start(); - let fired = 0; - bgAgents.onChange(() => fired++); + assert.equal(open.size, 3); bgAgents.stop(); - writeJob(dir, 'aaaaaaaa', { state: 'done' }); - await delay(bgAgents.FLUSH_MS * 3); - assert.equal(fired, 0); + assert.equal(open.size, 0); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } +}); + +test('a reconcile still running when stop() is called arms nothing and restores nothing', async (t) => { + const dir = mkTmp(); + try { + writeJob(dir, 'aaaaaaaa', { state: 'working' }); + writeJob(dir, 'bbbbbbbb', { state: 'done' }); + const open = trackWatchers(t); + let release; + const gate = new Promise(r => { release = r; }); + const runClaude = async () => { await gate; return { code: 0, stdout: JSON.stringify(CLI_LIST), stderr: '' }; }; + boot(dir, { cli: { runClaude, calls: [] } }); + bgAgents.start(); + const pending = bgAgents.reconcile(); + bgAgents.stop(); + assert.equal(open.size, 0); + release(); + const snap = await pending; + assert.equal(open.size, 0, 'no watcher armed after stop'); + assert.equal(snap.daemonReachable, false); + assert.equal(bgAgents.getSnapshot().daemonReachable, false); + assert.deepEqual(bgAgents.getSnapshot().roster, []); } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); From ad13234425a620e18e5f178381cd450fdafe8537 Mon Sep 17 00:00:00 2001 From: pjay Date: Wed, 30 Sep 2026 19:11:19 +0200 Subject: [PATCH 10/38] (main): attach to a background session in a tab, detach on close, expose the agents roster open-terminal runs `claude attach ` for a validated job id (no resume, sandbox, pre-launch or MCP), stop-session detaches an attach tab instead of killing it, and the roster module is wired to IPC, the preload API and the window teardown. Claude-Session: https://claude.ai/code/session_01WriJRPX84KSTHVY9wuKWyo --- bg-agents-ipc.js | 18 +++ main.js | 187 +++++++++++++++++++++--------- preload.js | 5 + test/bg-agents-ipc.test.js | 48 ++++++++ test/open-terminal-attach.test.js | 42 +++++++ 5 files changed, 243 insertions(+), 57 deletions(-) create mode 100644 bg-agents-ipc.js create mode 100644 test/bg-agents-ipc.test.js create mode 100644 test/open-terminal-attach.test.js diff --git a/bg-agents-ipc.js b/bg-agents-ipc.js new file mode 100644 index 00000000..adce9e6a --- /dev/null +++ b/bg-agents-ipc.js @@ -0,0 +1,18 @@ +// bg-agents-ipc.js — see .ai/contexts/bg-agents.md and .ai/contexts/ipc-bridge.md +'use strict'; + +function init({ ipcMain, bgAgents, getMainWindow, log }) { + ipcMain.handle('get-bg-agents', async () => { + 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)); + bgAgents.onChange((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}`); } + }); +} + +module.exports = { init }; diff --git a/main.js b/main.js index 02578e5e..795a59ac 100644 --- a/main.js +++ b/main.js @@ -1,6 +1,6 @@ const { app, BrowserWindow, clipboard, dialog, ipcMain, Menu, powerMonitor, screen, session, shell } = require('electron'); const { Worker } = require('worker_threads'); -const { execFile } = require('child_process'); +const { execFile, spawn: spawnChild } = require('child_process'); const path = require('path'); const fs = require('fs'); const os = require('os'); @@ -80,7 +80,8 @@ const { scanMdFiles, acceptMdFile } = require('./scan-md-files'); const { isSensitivePath, isAllowedMemoryPath: _isAllowedMemoryPath, resolveAllowedMemoryPath: _resolveAllowedMemoryPath, isKnownProjectRoot: _isKnownProjectRoot } = require('./ipc-path-validator'); const { validatePreLaunchCmd } = require('./pre-launch-cmd-guard'); const { normalizePtySize } = require('./pty-size'); -const { setPtyOpLogger, resizePty, killPty, ptyExitSignalName } = require('./pty-ops'); +const { setPtyOpLogger, resizePty, killPty, detachPty, ptyExitSignalName } = require('./pty-ops'); +const { JOB_ID_RE } = require('./bg-agents-roster'); const { createComposerState } = require('./composer-state'); const { handleTerminalInput } = require('./terminal-input'); const { createTriggerContext } = require('./trigger-context'); @@ -419,6 +420,7 @@ function createWindow() { } changesWatchers.closeAll(); closeAllFileWatchers(); + bgAgents.stop(); // Release all subagent file watchers (closes fs.watch handles + clears any // debounce timers / polling fallbacks via the stored teardown closure) for (const [, entry] of subagentWatchers) { @@ -1689,6 +1691,12 @@ ipcMain.handle('get-active-terminals', () => { ipcMain.handle('stop-session', (_event, sessionId) => { const session = activeSessions.get(sessionId); if (!session || session.exited) return { ok: false, error: 'not running' }; + // see .ai/contexts/bg-agents.md ("Detach") + if (session.isAttach) { + session.stopRequested = true; + detachPty(session, sessionId); + return { ok: true, detached: true }; + } session.stopRequested = true; killPty(session, sessionId); return { ok: true }; @@ -2268,6 +2276,41 @@ function wireSessionPty(session, sessionId, ptyProcess) { }); } +// Run `claude ` to completion through the login shell -- see .ai/contexts/bg-agents.md +function runClaudeCommand(claudeArgv, { cwd, timeout }) { + return new Promise((resolve) => { + const globalSettings = getSetting('global') || {}; + const profile = resolveShell(globalSettings.shellProfile || SETTING_DEFAULTS.shellProfile); + const shell = profile.path; + const args = shellArgs(shell, 'claude ' + quoteArgvForShell(shell, claudeArgv), profile.args || []); + let stdout = ''; + let stderr = ''; + let settled = false; + const finish = (code, err) => { + if (settled) return; + settled = true; + resolve({ code, stdout, stderr: err ? `${stderr}${err.message}` : stderr }); + }; + let child; + try { + child = spawnChild(shell, args, { + cwd, stdio: ['ignore', 'pipe', 'pipe'], env: { ...cleanPtyEnv, FORCE_COLOR: '0' }, windowsHide: true, + }); + } catch (err) { + finish(null, err); + return; + } + const timer = setTimeout(() => { + try { child.kill('SIGKILL'); } catch {} + finish(null, new Error(`claude ${claudeArgv[0]} timed out after ${timeout} ms`)); + }, timeout); + child.stdout.on('data', (d) => { stdout += d.toString(); }); + child.stderr.on('data', (d) => { stderr += d.toString(); }); + child.on('error', (err) => { clearTimeout(timer); finish(null, err); }); + child.on('exit', (code) => { clearTimeout(timer); finish(code); }); + }); +} + // --- IPC: open-terminal --- ipcMain.handle('open-terminal', async (_event, sessionId, projectPath, isNew, sessionOptions, initialSize) => { if (!mainWindow) return { ok: false, error: 'no window' }; @@ -2344,13 +2387,22 @@ ipcMain.handle('open-terminal', async (_event, sessionId, projectPath, isNew, se const resumeSourceId = sessionOptions?.forkFrom ? String(sessionOptions.forkFrom) : (!isNew ? sessionId : null); - if (resumeSourceId && sessionOptions?.type !== 'terminal') { + if (resumeSourceId && sessionOptions?.type !== 'terminal' && sessionOptions?.type !== 'attach') { const realCwd = resolveSessionRealCwd( PROJECTS_DIR, resumeSourceId, projectPath ? encodeProjectPath(projectPath) : null ); if (realCwd && fs.existsSync(realCwd)) spawnCwd = realCwd; } + // see .ai/contexts/bg-agents.md ("Attach") + const isAttach = sessionOptions?.type === 'attach'; + let attachJobId = null; + if (isAttach) { + if (!JOB_ID_RE.test(String(sessionOptions.jobId))) return { ok: false, error: 'invalid background session id' }; + attachJobId = String(sessionOptions.jobId); + if (typeof sessionOptions.cwd === 'string' && sessionOptions.cwd) spawnCwd = sessionOptions.cwd; + } + // see .ai/contexts/panel-terminal.md ("Main process") const panelOwnerId = sessionOptions?.type === 'terminal' && sessionOptions.panelFor ? String(sessionOptions.panelFor) @@ -2452,61 +2504,65 @@ ipcMain.handle('open-terminal', async (_event, sessionId, projectPath, isNew, se } else { // Build claude command, using array to prevent accidental shell injection const claudeArgs = []; - // A sidebar card is not proof the session exists on disk: launchNewSession - // shows one before claude starts, so a launch that fails immediately (bad - // flag, missing directory) leaves a card whose transcript was never - // written. Resuming that id makes claude exit with "No conversation found - // with session ID", and the banner's advice — re-click to relaunch — could - // never work because every retry resumed the same missing id. Start it - // instead, reusing the id the sidebar already shows. - const startsFresh = isNew - || (!sessionOptions?.forkFrom && !sessionTranscriptExists(PROJECTS_DIR, sessionId)); - if (!isNew && startsFresh && !sessionOptions?.forkFrom) { - log.info(`[open-terminal] ${sessionId} has no transcript — starting it instead of resuming`); - } - if (sessionOptions?.forkFrom) { - claudeArgs.push('--resume', String(sessionOptions.forkFrom), '--fork-session'); - } else if (startsFresh) { - claudeArgs.push('--session-id', String(sessionId)); + if (isAttach) { + claudeArgs.push('attach', attachJobId); } else { - claudeArgs.push('--resume', String(sessionId)); - } - - if (sessionOptions) { - if (sessionOptions.dangerouslySkipPermissions) { - claudeArgs.push('--dangerously-skip-permissions'); - } else if (sessionOptions.permissionMode) { - claudeArgs.push('--permission-mode', String(sessionOptions.permissionMode)); + // A sidebar card is not proof the session exists on disk: launchNewSession + // shows one before claude starts, so a launch that fails immediately (bad + // flag, missing directory) leaves a card whose transcript was never + // written. Resuming that id makes claude exit with "No conversation found + // with session ID", and the banner's advice — re-click to relaunch — could + // never work because every retry resumed the same missing id. Start it + // instead, reusing the id the sidebar already shows. + const startsFresh = isNew + || (!sessionOptions?.forkFrom && !sessionTranscriptExists(PROJECTS_DIR, sessionId)); + if (!isNew && startsFresh && !sessionOptions?.forkFrom) { + log.info(`[open-terminal] ${sessionId} has no transcript — starting it instead of resuming`); } - // --worktree only applies when STARTING a session — it creates a fresh - // isolated git worktree. Resuming (isNew === false) must reuse the - // session's existing directory, so ignore the worktree option on resume - // regardless of which call site supplied it (sidebar click, schedule - // creator, fork, …). Otherwise a resume tries to spin up a new worktree - // and fails to attach. - if (startsFresh && sessionOptions.worktree && !isGitRepo(spawnCwd)) { - // Worktree is commonly enabled globally, but plenty of projects are - // not git repos. Passing --worktree there makes claude refuse to start - // at all ("Can only use --worktree in a git repository"), which turns - // one global convenience toggle into a broken project. Drop the flag - // and run unisolated rather than fail the launch. - log.warn(`[open-terminal] ${spawnCwd} is not a git repository — launching without --worktree`); - } else if (startsFresh && sessionOptions.worktree) { - claudeArgs.push('--worktree'); - if (sessionOptions.worktreeName) { - claudeArgs.push(String(sessionOptions.worktreeName)); - } - } - if (sessionOptions.chrome) { - claudeArgs.push('--chrome'); + if (sessionOptions?.forkFrom) { + claudeArgs.push('--resume', String(sessionOptions.forkFrom), '--fork-session'); + } else if (startsFresh) { + claudeArgs.push('--session-id', String(sessionId)); + } else { + claudeArgs.push('--resume', String(sessionId)); } - for (const dir of parseAddDirs(sessionOptions.addDirs)) { - claudeArgs.push('--add-dir', dir); + + if (sessionOptions) { + if (sessionOptions.dangerouslySkipPermissions) { + claudeArgs.push('--dangerously-skip-permissions'); + } else if (sessionOptions.permissionMode) { + claudeArgs.push('--permission-mode', String(sessionOptions.permissionMode)); + } + // --worktree only applies when STARTING a session — it creates a fresh + // isolated git worktree. Resuming (isNew === false) must reuse the + // session's existing directory, so ignore the worktree option on resume + // regardless of which call site supplied it (sidebar click, schedule + // creator, fork, …). Otherwise a resume tries to spin up a new worktree + // and fails to attach. + if (startsFresh && sessionOptions.worktree && !isGitRepo(spawnCwd)) { + // Worktree is commonly enabled globally, but plenty of projects are + // not git repos. Passing --worktree there makes claude refuse to start + // at all ("Can only use --worktree in a git repository"), which turns + // one global convenience toggle into a broken project. Drop the flag + // and run unisolated rather than fail the launch. + log.warn(`[open-terminal] ${spawnCwd} is not a git repository — launching without --worktree`); + } else if (startsFresh && sessionOptions.worktree) { + claudeArgs.push('--worktree'); + if (sessionOptions.worktreeName) { + claudeArgs.push(String(sessionOptions.worktreeName)); + } + } + if (sessionOptions.chrome) { + claudeArgs.push('--chrome'); + } + for (const dir of parseAddDirs(sessionOptions.addDirs)) { + claudeArgs.push('--add-dir', dir); + } } - } - if (sessionOptions?.appendSystemPrompt) { - claudeArgs.push('--append-system-prompt', String(sessionOptions.appendSystemPrompt)); + if (sessionOptions?.appendSystemPrompt) { + claudeArgs.push('--append-system-prompt', String(sessionOptions.appendSystemPrompt)); + } } let claudeCmd = 'claude ' + quoteArgvForShell(shell, claudeArgs); @@ -2514,7 +2570,7 @@ ipcMain.handle('open-terminal', async (_event, sessionId, projectPath, isNew, se // Sandbox: run claude inside a bubblewrap sandbox that only exposes the // project directory and Claude's own config/state dirs. Linux only — // bwrap has no macOS/Windows equivalent wired up here. - if (sessionOptions?.sandbox) { + if (!isAttach && sessionOptions?.sandbox) { if (process.platform !== 'linux') { return { ok: false, error: 'Sandbox mode requires Linux (bubblewrap)' }; } @@ -2523,7 +2579,7 @@ ipcMain.handle('open-terminal', async (_event, sessionId, projectPath, isNew, se // preLaunchCmd is raw shell by design (e.g. "aws-vault exec profile --") // — see pre-launch-cmd-guard.js for what is and isn't blocked and why. - if (sessionOptions?.preLaunchCmd) { + if (!isAttach && sessionOptions?.preLaunchCmd) { const pre = String(sessionOptions.preLaunchCmd); const preCheck = validatePreLaunchCmd(pre); if (!preCheck.ok) { @@ -2534,7 +2590,7 @@ ipcMain.handle('open-terminal', async (_event, sessionId, projectPath, isNew, se // Start MCP server for this session so Claude CLI sends diffs/file opens to Switchboard // (skip if user disabled IDE emulation in global settings) - if (sessionOptions?.mcpEmulation !== false) { + if (!isAttach && sessionOptions?.mcpEmulation !== false) { try { mcpServer = await startMcpServer(sessionId, [spawnCwd], mainWindow, log); claudeCmd += ' --ide'; @@ -2568,7 +2624,7 @@ ipcMain.handle('open-terminal', async (_event, sessionId, projectPath, isNew, se if (mcpServer) { ptyEnv.CLAUDE_CODE_SSE_PORT = String(mcpServer.port); } - if (sessionOptions?.sandbox) { + if (!isAttach && sessionOptions?.sandbox) { // Directories the sandboxed claude must still reach beyond the cwd: // the project root when resuming inside a worktree (git metadata lives // there), and any user-configured Additional Directories. @@ -2603,6 +2659,7 @@ ipcMain.handle('open-terminal', async (_event, sessionId, projectPath, isNew, se cwd: spawnCwd, projectFolder, knownJsonlFiles, isPlainTerminal, panelFor: panelOwnerId, forkFrom: sessionOptions?.forkFrom || null, + isAttach, attachJobId, // Recorded so a reattach can report it too — the renderer badges sandboxed // sessions, and a reattached session is still inside the same sandbox. sandbox: !!sessionOptions?.sandbox, @@ -2860,6 +2917,22 @@ const localTranscriptTracker = createLocalTranscriptTracker({ hasPty: sessionHas ipcMain.handle('session-live-elsewhere', (_event, sessionId) => cliSessionState.liveElsewhere(sessionId, sessionHasPty, ptyPids)); ipcMain.handle('sessions-live-elsewhere', (_event, sessionIds) => cliSessionState.liveElsewhereMany(sessionIds, sessionHasPty, ptyPids)); +// see .ai/contexts/bg-agents.md +const bgAgents = require('./bg-agents'); +bgAgents.init({ + log, + runClaude: runClaudeCommand, + cliSessionState, + makeIsOwnPid: () => cliSessionState.ownProcessFilter(ptyPids), + isAttachedHere: (jobId) => { + for (const session of activeSessions.values()) { + if (session && !session.exited && session.isAttach && session.attachJobId === jobId) return true; + } + return false; + }, +}); +require('./bg-agents-ipc').init({ ipcMain, bgAgents, getMainWindow: () => mainWindow, log }); + // --- fs.watch on projects directory --- let projectsWatcher = null; diff --git a/preload.js b/preload.js index f45cdc12..8870b30e 100644 --- a/preload.js +++ b/preload.js @@ -19,6 +19,10 @@ contextBridge.exposeInMainWorld('api', { getSessionsLiveElsewhere: (ids) => ipcRenderer.invoke('sessions-live-elsewhere', ids), getActiveTerminals: () => ipcRenderer.invoke('get-active-terminals'), stopSession: (id) => ipcRenderer.invoke('stop-session', id), + // see .ai/contexts/bg-agents.md + getBgAgents: () => ipcRenderer.invoke('get-bg-agents'), + bgAgentVerb: (verb, id) => ipcRenderer.invoke('bg-agent-verb', verb, id), + dispatchBgAgent: (fields) => ipcRenderer.invoke('dispatch-bg-agent', fields), // see .ai/contexts/session-state.md ("The two lifecycle verbs: detach and stop") remoteStopSession: (alias, sessionId) => ipcRenderer.invoke('remote-stop-session', { alias, sessionId }), toggleStar: (id) => ipcRenderer.invoke('toggle-star', id), @@ -118,6 +122,7 @@ contextBridge.exposeInMainWorld('api', { onSubagentSpawned: (cb) => ipcRenderer.on('subagent-spawned', (_e, payload) => cb(payload)), onSubagentCompleted: (cb) => ipcRenderer.on('subagent-completed', (_e, payload) => cb(payload)), onSubagentWatchEvent: (cb) => ipcRenderer.on('subagent-watch-event', (_e, payload) => cb(payload)), + onBgAgentsChanged: (cb) => ipcRenderer.on('bg-agents-changed', (_e, payload) => cb(payload)), onProjectsChanged: (callback) => { ipcRenderer.on('projects-changed', () => callback()); }, diff --git a/test/bg-agents-ipc.test.js b/test/bg-agents-ipc.test.js new file mode 100644 index 00000000..e6ed6e72 --- /dev/null +++ b/test/bg-agents-ipc.test.js @@ -0,0 +1,48 @@ +// test/bg-agents-ipc.test.js — the three handlers and the push. See .ai/contexts/bg-agents.md. +'use strict'; +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { init } = require('../bg-agents-ipc'); + +function fakeIpc() { + const handlers = new Map(); + return { handlers, ipcMain: { handle: (name, fn) => handlers.set(name, fn) } }; +} + +function fakeBgAgents() { + const calls = []; + let listener = null; + return { + calls, + start: () => { calls.push('start'); return true; }, + reconcile: async () => { calls.push('reconcile'); return { roster: [], daemonReachable: true }; }, + runVerb: async (verb, id) => { calls.push(['verb', verb, id]); return { ok: true }; }, + dispatch: async (fields) => { calls.push(['dispatch', fields]); return { ok: true, id: 'aaaaaaaa' }; }, + onChange: (l) => { listener = l; return () => { listener = null; }; }, + fire: (snap) => listener && listener(snap), + }; +} + +test('get-bg-agents arms the watchers then reconciles; the verbs pass straight through', async () => { + const { handlers, ipcMain } = fakeIpc(); + const bg = fakeBgAgents(); + init({ ipcMain, bgAgents: bg, getMainWindow: () => null, log: { warn() {} } }); + assert.deepEqual(await handlers.get('get-bg-agents')({}), { roster: [], daemonReachable: true }); + assert.deepEqual(bg.calls, ['start', 'reconcile']); + assert.deepEqual(await handlers.get('bg-agent-verb')({}, 'stop', 'aaaaaaaa'), { ok: true }); + assert.deepEqual(await handlers.get('dispatch-bg-agent')({}, { prompt: 'p', cwd: '/x' }), { ok: true, id: 'aaaaaaaa' }); + assert.deepEqual(bg.calls.slice(2), [['verb', 'stop', 'aaaaaaaa'], ['dispatch', { prompt: 'p', cwd: '/x' }]]); +}); + +test('a roster change is pushed to the window on bg-agents-changed, and skipped when the window is gone', () => { + const { ipcMain } = fakeIpc(); + const bg = fakeBgAgents(); + const sent = []; + let window = { isDestroyed: () => false, webContents: { send: (ch, payload) => sent.push([ch, payload]) } }; + init({ ipcMain, bgAgents: bg, getMainWindow: () => window, log: { warn() {} } }); + bg.fire({ roster: [{ id: 'aaaaaaaa' }], daemonReachable: true }); + assert.deepEqual(sent, [['bg-agents-changed', { roster: [{ id: 'aaaaaaaa' }], daemonReachable: true }]]); + window = null; + bg.fire({ roster: [], daemonReachable: false }); + assert.equal(sent.length, 1); +}); diff --git a/test/open-terminal-attach.test.js b/test/open-terminal-attach.test.js new file mode 100644 index 00000000..9621af0d --- /dev/null +++ b/test/open-terminal-attach.test.js @@ -0,0 +1,42 @@ +// test/open-terminal-attach.test.js — main.js boots Electron, so these are source-level pins; see .ai/contexts/bg-agents.md. +'use strict'; +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const MAIN = fs.readFileSync(path.join(__dirname, '..', 'main.js'), 'utf8'); +const PRELOAD = fs.readFileSync(path.join(__dirname, '..', 'preload.js'), 'utf8'); + +test('open-terminal builds `claude attach ` for an attach session and never a --resume', () => { + assert.match(MAIN, /const isAttach = sessionOptions\?\.type === 'attach';/); + assert.match(MAIN, /claudeArgs\.push\('attach', attachJobId\);/); + assert.match(MAIN, /if \(!isAttach && sessionOptions\?\.sandbox\)/); + assert.match(MAIN, /if \(!isAttach && sessionOptions\?\.preLaunchCmd\)/); + assert.match(MAIN, /if \(!isAttach && sessionOptions\?\.mcpEmulation !== false\)/); + assert.match(MAIN, /isAttach, attachJobId,/, 'the session record must carry both fields'); +}); + +test('an attach job id is validated against the eight-hex shape before anything is spawned', () => { + assert.match(MAIN, /JOB_ID_RE\.test\(String\(sessionOptions\.jobId\)\)/); +}); + +test('stop-session detaches an attach session instead of killing it', () => { + const idx = MAIN.indexOf("ipcMain.handle('stop-session'"); + const body = MAIN.slice(idx, idx + 600); + assert.match(body, /if \(session\.isAttach\)/); + assert.match(body, /detachPty\(session, sessionId\)/); + assert.match(body, /detached: true/); +}); + +test('the window closing releases the agents watchers', () => { + const idx = MAIN.indexOf("mainWindow.on('closed'"); + assert.match(MAIN.slice(idx, idx + 900), /bgAgents\.stop\(\);/); +}); + +test('preload exposes the four agents-view entries', () => { + for (const name of ['getBgAgents', 'bgAgentVerb', 'dispatchBgAgent', 'onBgAgentsChanged']) { + assert.ok(PRELOAD.includes(name + ':'), `${name} missing from preload.js`); + } + assert.match(PRELOAD, /ipcRenderer\.on\('bg-agents-changed'/); +}); From 2abc7e622bba68c2afeb8c0ca05c4d938d47a358 Mon Sep 17 00:00:00 2001 From: pjay Date: Wed, 30 Sep 2026 19:18:32 +0200 Subject: [PATCH 11/38] (main): resubscribe the agents push on every fetch so a closed window does not silence it bgAgents.stop() clears its listeners when the window closes; a re-created window's next get-bg-agents now restores the bg-agents-changed push, idempotently. Claude-Session: https://claude.ai/code/session_01WriJRPX84KSTHVY9wuKWyo --- bg-agents-ipc.js | 17 ++++++++++++----- test/bg-agents-ipc.test.js | 19 +++++++++++++++++++ 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/bg-agents-ipc.js b/bg-agents-ipc.js index adce9e6a..9bdcba9e 100644 --- a/bg-agents-ipc.js +++ b/bg-agents-ipc.js @@ -2,17 +2,24 @@ '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)); - bgAgents.onChange((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}`); } - }); + subscribe(); } module.exports = { init }; diff --git a/test/bg-agents-ipc.test.js b/test/bg-agents-ipc.test.js index e6ed6e72..82a2be50 100644 --- a/test/bg-agents-ipc.test.js +++ b/test/bg-agents-ipc.test.js @@ -46,3 +46,22 @@ test('a roster change is pushed to the window on bg-agents-changed, and skipped bg.fire({ roster: [], daemonReachable: false }); assert.equal(sent.length, 1); }); + +test('get-bg-agents restores the push after stop() cleared it, without ever doubling it', async () => { + const { handlers, ipcMain } = fakeIpc(); + const listeners = new Set(); + const bg = { + start: () => true, + reconcile: async () => ({ roster: [], daemonReachable: true }), + onChange: (l) => { listeners.add(l); return () => listeners.delete(l); }, + stop: () => listeners.clear(), + }; + const sent = []; + const window = { isDestroyed: () => false, webContents: { send: (ch, payload) => sent.push([ch, payload]) } }; + init({ ipcMain, bgAgents: bg, getMainWindow: () => window, log: { warn() {} } }); + await handlers.get('get-bg-agents')({}); + bg.stop(); + await handlers.get('get-bg-agents')({}); + for (const l of listeners) l({ roster: [], daemonReachable: true }); + assert.deepEqual(sent, [['bg-agents-changed', { roster: [], daemonReachable: true }]]); +}); From c48eaa41ef83d53f0c3620e0fd210f10407ec820 Mon Sep 17 00:00:00 2001 From: pjay Date: Wed, 30 Sep 2026 19:35:54 +0200 Subject: [PATCH 12/38] (sessions): attach to a session the daemon runs instead of asking to resume it Claude-Session: https://claude.ai/code/session_01WriJRPX84KSTHVY9wuKWyo --- public/app.js | 16 +++++++++++++--- public/resume-guard.js | 4 ++++ public/shortcuts.js | 8 ++++++++ public/stop-session-ui.js | 9 ++++++--- test/agents-toggle-shortcut.test.js | 14 ++++++++++++++ test/resume-guard.test.js | 26 ++++++++++++++++++++++++++ test/stop-session-ui-attach.test.js | 14 ++++++++++++++ test/stop-session-ui.test.js | 4 ++-- 8 files changed, 87 insertions(+), 8 deletions(-) create mode 100644 test/agents-toggle-shortcut.test.js create mode 100644 test/stop-session-ui-attach.test.js diff --git a/public/app.js b/public/app.js index 9b87eaf4..06ef3fd6 100644 --- a/public/app.js +++ b/public/app.js @@ -175,6 +175,7 @@ function persistWorkingSet() { const set = []; for (const [sessionId, entry] of openSessions) { if (entry.session.type === 'terminal') continue; // exclude plain shells + if (entry.attach) continue; // attach tabs are not restored if (entry.closed) continue; set.push({ sessionId, @@ -820,7 +821,8 @@ async function triggerRebuildAndSearch() { // see .ai/contexts/session-state.md ("The two lifecycle verbs: detach and stop") // btn (optional): the clicked control, flashed on failure instead of alert() — see sidebar.js's session-delete-btn async function confirmAndStopSession(sessionId, btn) { - const plan = resolveSessionStop(sessionMap.get(sessionId)); + const openEntry = openSessions.get(sessionId); + const plan = resolveSessionStop(sessionMap.get(sessionId), { attach: !!(openEntry && openEntry.attach) }); if (!confirm(plan.confirmText)) return; const result = plan.remote ? await window.api.remoteStopSession(plan.alias, sessionId) @@ -1145,6 +1147,9 @@ async function showTerminalHeader(session) { terminalHeaderName.textContent = displayName; terminalHeaderId.textContent = session.sessionId; terminalHeaderSandbox.style.display = sandboxedSessions.get(session.sessionId) ? '' : 'none'; + const headerEntry = openSessions.get(session.sessionId); + terminalStopBtn.title = headerEntry && headerEntry.attach ? 'Detach (the session keeps running)' : 'Stop process'; + terminalStopBtn.setAttribute('aria-label', terminalStopBtn.title); terminalHeader.style.display = ''; updateTerminalHeader(); @@ -1182,8 +1187,12 @@ async function openSession(session, customOptions, { automatic = false, live } = } } - // see .ai/contexts/cli-session-state.md ("Live elsewhere") - if (!(await guardResume(session, { automatic, live, api: window.api, confirm: (msg) => window.confirm(msg) }))) return false; + // see .ai/contexts/cli-session-state.md ("Live elsewhere") and .ai/contexts/bg-agents.md ("Attach") + const verdict = customOptions?.type === 'attach' ? true : await guardResume(session, { automatic, live, api: window.api, confirm: (msg) => window.confirm(msg) }); + if (verdict === false) return false; + if (verdict && typeof verdict === 'object' && verdict.attach) { + customOptions = { type: 'attach', jobId: verdict.attach, cwd: verdict.cwd || projectPath }; + } // Create new terminal entry (hidden until showSession) const entry = createTerminalEntry(session); @@ -1191,6 +1200,7 @@ async function openSession(session, customOptions, { automatic = false, live } = // Open terminal in main process — see .ai/contexts/session-state.md ("Reopening a plain terminal") const resumeOptions = customOptions || (session.type === 'terminal' ? { type: 'terminal' } : await resolveDefaultSessionOptions({ projectPath })); + entry.attach = resumeOptions.type === 'attach'; forgetSessionExit(sessionId); const result = await window.api.openTerminal(sessionId, projectPath, false, resumeOptions, entry.initialSize); if (!result.ok) { diff --git a/public/resume-guard.js b/public/resume-guard.js index 7c088d38..977cfd39 100644 --- a/public/resume-guard.js +++ b/public/resume-guard.js @@ -28,6 +28,10 @@ async function guardResume(session, { automatic = false, api, confirm, live } = } if (!live) return true; if (automatic) return false; + // A session the claude daemon runs is attached, never resumed -- see .ai/contexts/bg-agents.md + if (live.kind === 'bg' && typeof live.jobId === 'string' && live.jobId) { + return { attach: live.jobId, cwd: live.cwd || null }; + } return !!confirm(liveElsewhereMessage(live)); } diff --git a/public/shortcuts.js b/public/shortcuts.js index f192850e..358a1708 100644 --- a/public/shortcuts.js +++ b/public/shortcuts.js @@ -20,6 +20,8 @@ const DEFAULT_SHORTCUTS = { sessionNavBrackets: { primary: true, alt: false, shift: true }, // Ctrl/Cmd+Shift+G — toggle the grid overview. gridToggle: { primary: true, alt: false, shift: true, key: 'g' }, + // Ctrl/Cmd+Shift+A — toggle the background agents view. + agentsToggle: { primary: true, alt: false, shift: true, key: 'a' }, }; // Metadata for rendering the settings UI and resolving each action's key family. @@ -42,6 +44,12 @@ const SHORTCUT_DEFS = [ description: 'Show or hide the session grid overview', family: 'key', }, + { + id: 'agentsToggle', + label: 'Toggle agents view', + description: 'Show or hide the background agents view', + family: 'key', + }, ]; function getDef(id) { diff --git a/public/stop-session-ui.js b/public/stop-session-ui.js index 32ec02ff..e42fff23 100644 --- a/public/stop-session-ui.js +++ b/public/stop-session-ui.js @@ -1,12 +1,15 @@ // Dual-mode helper — see .ai/contexts/session-state.md ("The two lifecycle verbs: detach and stop") // session: the sessionMap entry for the row being stopped, or undefined. -function resolveSessionStop(session) { +function resolveSessionStop(session, { attach = false } = {}) { const alias = session && session.remoteAlias; if (alias) { - return { remote: true, alias, confirmText: `Stop this session on ${alias}?` }; + return { remote: true, alias, attach: false, confirmText: `Stop this session on ${alias}?` }; } - return { remote: false, alias: null, confirmText: 'Stop this session?' }; + if (attach) { + return { remote: false, alias: null, attach: true, confirmText: 'Detach from this background session? It keeps running; the Agents view can stop it.' }; + } + return { remote: false, alias: null, attach: false, confirmText: 'Stop this session?' }; } // Is this remote session's process still running? See .ai/contexts/session-state.md ("stopBeforeArchive"). diff --git a/test/agents-toggle-shortcut.test.js b/test/agents-toggle-shortcut.test.js new file mode 100644 index 00000000..0f7780fd --- /dev/null +++ b/test/agents-toggle-shortcut.test.js @@ -0,0 +1,14 @@ +'use strict'; +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { DEFAULT_SHORTCUTS, SHORTCUT_DEFS, matchShortcut, normalizeShortcuts, formatBinding } = require('../public/shortcuts'); + +test('agentsToggle defaults to Primary+Shift+A and is a rebindable key-family action', () => { + assert.deepEqual(DEFAULT_SHORTCUTS.agentsToggle, { primary: true, alt: false, shift: true, key: 'a' }); + const def = SHORTCUT_DEFS.find(d => d.id === 'agentsToggle'); + assert.equal(def.family, 'key'); + assert.equal(formatBinding('agentsToggle', false, normalizeShortcuts(null)), 'Ctrl+Shift+A'); + const e = { key: 'A', ctrlKey: true, shiftKey: true, altKey: false, metaKey: false }; + assert.equal(matchShortcut('agentsToggle', e, false, normalizeShortcuts(null)), true); + assert.equal(matchShortcut('gridToggle', e, false, normalizeShortcuts(null)), false); +}); diff --git a/test/resume-guard.test.js b/test/resume-guard.test.js index 458c2a96..f3c33523 100644 --- a/test/resume-guard.test.js +++ b/test/resume-guard.test.js @@ -152,3 +152,29 @@ test('index.html loads the guard before app.js, preload exposes the check, main assert.match(read('main.js'), /ipcMain\.handle\('sessions-live-elsewhere', \(_event, sessionIds\) => cliSessionState\.liveElsewhereMany\(sessionIds, sessionHasPty, ptyPids\)\)/); }); + +// --- Background sessions (see .ai/contexts/bg-agents.md) -------------------- + +const LIVE_BG = { pid: 346590, cwd: '/w/em', startedAt: 1, kind: 'bg', jobId: 'bc3fd129' }; + +test('a user click on a session the daemon runs answers "attach", without asking', async () => { + const api = makeApi(LIVE_BG); + const { confirm, messages } = makeConfirm(false); + assert.deepEqual(await guardResume(SESSION, { api, confirm }), { attach: 'bc3fd129', cwd: '/w/em' }); + assert.equal(messages.length, 0); +}); + +test('an automatic resume of a session the daemon runs is still refused', async () => { + const api = makeApi(LIVE_BG); + const { confirm } = makeConfirm(true); + assert.equal(await guardResume(SESSION, { automatic: true, api, confirm }), false); +}); + +test('app.js turns the attach verdict into attach options and skips the guard for an explicit attach', () => { + const app = read('public/app.js'); + assert.match(app, /customOptions\?\.type === 'attach'\s*\?\s*true\s*:\s*await guardResume\(/); + assert.match(app, /if \(verdict === false\) return false;/); + assert.match(app, /customOptions = \{ type: 'attach', jobId: verdict\.attach, cwd: verdict\.cwd \|\| projectPath \};/); + assert.match(app, /entry\.attach = resumeOptions\.type === 'attach';/); + assert.match(app, /if \(entry\.attach\) continue; \/\/ attach tabs are not restored/); +}); diff --git a/test/stop-session-ui-attach.test.js b/test/stop-session-ui-attach.test.js new file mode 100644 index 00000000..5f38488b --- /dev/null +++ b/test/stop-session-ui-attach.test.js @@ -0,0 +1,14 @@ +'use strict'; +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { resolveSessionStop } = require('../public/stop-session-ui'); + +test('an attach tab asks to detach, not to stop, and stays local', () => { + const plan = resolveSessionStop({ sessionId: 's' }, { attach: true }); + assert.equal(plan.remote, false); + assert.equal(plan.attach, true); + assert.match(plan.confirmText, /Detach/); + assert.match(plan.confirmText, /keeps running/); + assert.equal(resolveSessionStop({ sessionId: 's' }).attach, false); + assert.equal(resolveSessionStop({ sessionId: 's', remoteAlias: 'h' }, { attach: true }).remote, true); +}); diff --git a/test/stop-session-ui.test.js b/test/stop-session-ui.test.js index e3ac224c..d9da6abc 100644 --- a/test/stop-session-ui.test.js +++ b/test/stop-session-ui.test.js @@ -11,12 +11,12 @@ const { resolveSessionStop, isRemoteSessionAlive } = require('../public/stop-ses test('a local session (no remoteAlias) resolves to the plain stop dialog and the local IPC', () => { const plan = resolveSessionStop({ sessionId: 's1' }); - assert.deepEqual(plan, { remote: false, alias: null, confirmText: 'Stop this session?' }); + assert.deepEqual(plan, { remote: false, alias: null, attach: false, confirmText: 'Stop this session?' }); }); test('an undefined session (row not found) still resolves to the local stop, never throws', () => { const plan = resolveSessionStop(undefined); - assert.deepEqual(plan, { remote: false, alias: null, confirmText: 'Stop this session?' }); + assert.deepEqual(plan, { remote: false, alias: null, attach: false, confirmText: 'Stop this session?' }); }); test('a remote session resolves to the remote IPC with the host alias named in the dialog text', () => { From 15329c1f45c9bd5923243ebd6dc0d4473b2237f4 Mon Sep 17 00:00:00 2001 From: pjay Date: Wed, 30 Sep 2026 19:49:30 +0200 Subject: [PATCH 13/38] (sessions): refuse a live bg session that has no job id instead of offering to resume it Claude-Session: https://claude.ai/code/session_01WriJRPX84KSTHVY9wuKWyo --- public/resume-guard.js | 5 +++-- test/resume-guard.test.js | 9 +++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/public/resume-guard.js b/public/resume-guard.js index 977cfd39..3bb0a0e1 100644 --- a/public/resume-guard.js +++ b/public/resume-guard.js @@ -29,8 +29,9 @@ async function guardResume(session, { automatic = false, api, confirm, live } = if (!live) return true; if (automatic) return false; // A session the claude daemon runs is attached, never resumed -- see .ai/contexts/bg-agents.md - if (live.kind === 'bg' && typeof live.jobId === 'string' && live.jobId) { - return { attach: live.jobId, cwd: live.cwd || null }; + if (live.kind === 'bg') { + if (typeof live.jobId === 'string' && live.jobId) return { attach: live.jobId, cwd: live.cwd || null }; + return false; } return !!confirm(liveElsewhereMessage(live)); } diff --git a/test/resume-guard.test.js b/test/resume-guard.test.js index f3c33523..f87ef0c3 100644 --- a/test/resume-guard.test.js +++ b/test/resume-guard.test.js @@ -170,6 +170,15 @@ test('an automatic resume of a session the daemon runs is still refused', async assert.equal(await guardResume(SESSION, { automatic: true, api, confirm }), false); }); +test('a live bg descriptor without a usable jobId is refused: no resume offer, no attach', async () => { + for (const jobId of [null, undefined, '']) { + const api = makeApi({ ...LIVE_BG, jobId }); + const { confirm, messages } = makeConfirm(true); + assert.equal(await guardResume(SESSION, { api, confirm }), false); + assert.equal(messages.length, 0); + } +}); + test('app.js turns the attach verdict into attach options and skips the guard for an explicit attach', () => { const app = read('public/app.js'); assert.match(app, /customOptions\?\.type === 'attach'\s*\?\s*true\s*:\s*await guardResume\(/); From e07cccafbc6d0ca845a781fd2bba89f4d543b107 Mon Sep 17 00:00:00 2001 From: pjay Date: Wed, 30 Sep 2026 20:01:44 +0200 Subject: [PATCH 14/38] (agents): a view of the daemon's background sessions, with attach, transcript, stop, respawn and delete Lists the roster the main process keeps, live jobs (working or blocked) first, with the verbs each state allows; opening the view is what arms the roster push. Ctrl/Cmd+Shift+A toggles it. Claude-Session: https://claude.ai/code/session_01WriJRPX84KSTHVY9wuKWyo --- eslint.config.js | 15 +- public/agents-view.js | 328 ++++++++++++++++++++++++++++++++ public/app.js | 15 ++ public/grid-view.js | 1 + public/index.html | 15 ++ public/memory-workfiles-view.js | 1 + public/style.css | 45 ++++- public/terminal-manager.js | 5 + test/agents-view-pure.test.js | 74 +++++++ test/dom-agents-view.test.js | 228 ++++++++++++++++++++++ 10 files changed, 725 insertions(+), 2 deletions(-) create mode 100644 public/agents-view.js create mode 100644 test/agents-view-pure.test.js create mode 100644 test/dom-agents-view.test.js diff --git a/eslint.config.js b/eslint.config.js index dccd5c7e..38835fc3 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -326,6 +326,19 @@ const rendererCrossFileGlobals = { // Resume guard for sessions live in another process (public/resume-guard.js) guardResume: 'readonly', liveElsewhereMany: 'readonly', + // Agents view (public/agents-view.js) — see .ai/contexts/bg-agents.md + agentsViewActive: 'writable', + bgAgentSessionIds: 'readonly', + initAgentsView: 'readonly', + showAgentsView: 'readonly', + hideAgentsView: 'readonly', + toggleAgentsView: 'readonly', + applyAgentsSnapshot: 'readonly', + refreshAgentsRoster: 'readonly', + attachBgAgent: 'readonly', + runAgentVerb: 'readonly', + selectAgentsRow: 'readonly', + showDispatchAgentDialog: 'readonly', }; module.exports = [ @@ -366,7 +379,7 @@ module.exports = [ // Dual-mode helper: classic + + diff --git a/public/memory-workfiles-view.js b/public/memory-workfiles-view.js index 6b2b0871..190e50dd 100644 --- a/public/memory-workfiles-view.js +++ b/public/memory-workfiles-view.js @@ -16,6 +16,7 @@ function hideAllViewers() { if (traceViewer) traceViewer.style.display = 'none'; settingsViewer.style.display = 'none'; jsonlViewer.style.display = 'none'; + if (typeof hideAgentsView === 'function') hideAgentsView({ restore: false }); terminalArea.style.display = ''; // Stop any subagent file-watches kept alive by Agent blocks that the user // was viewing — without this, fs.watchFile keeps polling indefinitely. diff --git a/public/style.css b/public/style.css index 2e3dfb2b..a139ae66 100644 --- a/public/style.css +++ b/public/style.css @@ -1661,6 +1661,45 @@ body { display: flex; flex-direction: column; } margin-right: auto; } +/* Agents view — see .ai/contexts/bg-agents.md */ +#agents-viewer { display: none; flex-direction: column; flex: 1; min-height: 0; } +#agents-viewer-header { + display: flex; align-items: center; gap: 10px; padding: 8px 16px; + background: var(--surface-chrome); border-bottom: 1px solid var(--hairline); flex-shrink: 0; +} +#agents-viewer-title { font-size: 13px; color: #b0b0c4; font-weight: 500; } +#agents-viewer-count { font-size: 11px; color: #7a7a90; margin-right: auto; } +#agents-finished-toggle { font-size: 11px; color: #7a7a90; display: inline-flex; align-items: center; gap: 4px; } +#agents-new-btn, .agents-verb-btn { + background: transparent; border: 1px solid var(--control-border); color: #b0b0c4; + font-size: 11px; padding: 3px 8px; border-radius: 6px; cursor: pointer; font-family: inherit; +} +#agents-new-btn:hover, .agents-verb-btn:hover:not([disabled]) { background: var(--control-surface); } +.agents-verb-btn[disabled] { opacity: 0.4; cursor: default; } +#agents-viewer-banner { padding: 6px 16px; font-size: 11px; color: #f0a050; background: rgba(240,160,80,0.08); } +#agents-viewer-body { display: flex; flex-direction: column; flex: 1; min-height: 0; } +#agents-list { flex: 1; overflow: auto; min-height: 0; } +.agents-row { + display: grid; grid-template-columns: 18px minmax(160px, 2fr) minmax(90px, 1fr) minmax(110px, 1fr) minmax(160px, 2fr) 60px; + gap: 8px; align-items: center; padding: 6px 16px; font-size: 12px; color: #c8c8d8; cursor: pointer; + border-bottom: 1px solid var(--hairline); +} +.agents-row:hover { background: var(--control-surface); } +.agents-row.selected { background: rgba(128,136,255,0.1); } +.agents-row.pending { opacity: 0.5; } +.agents-row-name, .agents-row-cwd { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.agents-row-agent, .agents-row-state, .agents-row-age { color: #7a7a90; } +#agents-detail { border-top: 1px solid var(--hairline); padding: 10px 16px; max-height: 40%; overflow: auto; font-size: 12px; color: #c8c8d8; flex-shrink: 0; } +.agents-detail-head { display: flex; align-items: center; gap: 10px; margin-bottom: 6px; } +.agents-detail-name { font-weight: 500; margin-right: auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.agents-detail-actions { display: flex; gap: 6px; flex-shrink: 0; } +.agents-detail-line { margin: 4px 0; } +.agents-detail-meta, .agents-detail-empty { color: #7a7a90; margin: 4px 0; } +.agents-detail-section { margin-top: 8px; } +.agents-detail-section ul { margin: 4px 0 0 16px; padding: 0; } +.agents-detail-error { margin-top: 8px; color: #f06060; } +.agents-link { color: var(--accent); } + #grid-group-toggle-btn { background: transparent; border: 1px solid var(--control-border); @@ -3235,6 +3274,7 @@ body { display: flex; flex-direction: column; } /* ========== ADD PROJECT BUTTON ========== */ #grid-toggle-btn, +#agents-toggle-btn, #resort-btn, #add-project-btn { background: transparent; @@ -3256,10 +3296,12 @@ body { display: flex; flex-direction: column; } } #grid-toggle-btn { margin-left: auto; } +#agents-toggle-btn { margin-left: 2px; } #resort-btn { margin-left: 2px; } #add-project-btn { margin-left: 2px; } #grid-toggle-btn:hover, +#agents-toggle-btn:hover, #resort-btn:hover, #add-project-btn:hover { background: var(--control-surface); @@ -3267,7 +3309,8 @@ body { display: flex; flex-direction: column; } color: #888; } -#grid-toggle-btn.active { +#grid-toggle-btn.active, +#agents-toggle-btn.active { background: rgba(128,136,255,0.1); border-color: rgba(128,136,255,0.3); color: var(--accent); diff --git a/public/terminal-manager.js b/public/terminal-manager.js index 3912ee66..9a176254 100644 --- a/public/terminal-manager.js +++ b/public/terminal-manager.js @@ -91,6 +91,11 @@ function setupTerminalKeyBindings(terminal, container, getSessionId, { onFind } return false; } + if (matchShortcut('agentsToggle', e, isMac, appShortcuts)) { + if (e.type === 'keydown') { e._handled = true; toggleAgentsView(); } + return false; + } + // Toggle grid view (default Cmd/Ctrl+Shift+G) if (matchShortcut('gridToggle', e, isMac, appShortcuts)) { if (e.type === 'keydown') { e._handled = true; toggleGridView(); } diff --git a/test/agents-view-pure.test.js b/test/agents-view-pure.test.js new file mode 100644 index 00000000..6f63320c --- /dev/null +++ b/test/agents-view-pure.test.js @@ -0,0 +1,74 @@ +// test/agents-view-pure.test.js — the decision helpers of the agents view. See .ai/contexts/bg-agents.md. +'use strict'; +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { renderSessionIcon } = require('../public/session-state'); +global.renderSessionIcon = renderSessionIcon; +const { sortAgentEntries, agentRowIcon, agentVerbAvailability, formatTokens, formatAgentAge, agentsEntryKey } = require('../public/agents-view'); + +const bg = (over) => ({ id: 'aaaaaaaa', sessionId: 's', kind: 'background', state: 'working', status: 'idle', startedAt: 100, ...over }); + +test('sort: working and external-interactive first, then newest first, unknown start last', () => { + const sorted = sortAgentEntries([ + bg({ id: 'd1', state: 'done', startedAt: 500 }), + bg({ id: 'w1', startedAt: 100 }), + { kind: 'interactive', sessionId: 'i1', state: null, status: 'busy', startedAt: 300 }, + bg({ id: 'w2', startedAt: null }), + bg({ id: 'w3', startedAt: 200 }), + ]); + assert.deepEqual(sorted.map(e => e.id || e.sessionId), ['i1', 'w3', 'w1', 'w2', 'd1']); +}); + +test('sort: a blocked job is live and ranks with the working ones', () => { + const sorted = sortAgentEntries([ + bg({ id: 's1', state: 'stopped', startedAt: 900 }), + bg({ id: 'b1', state: 'blocked', startedAt: 150 }), + bg({ id: 'w1', startedAt: 100 }), + bg({ id: 'd1', state: 'done', startedAt: 800 }), + ]); + assert.deepEqual(sorted.map(e => e.id), ['b1', 'w1', 's1', 'd1']); +}); + +test('row icon: busy spinner, waiting, idle for live rows; stale for finished ones', () => { + assert.equal(agentRowIcon(bg({ status: 'busy' })).slotClass, 'session-icon--busy'); + assert.equal(agentRowIcon(bg({ status: 'waiting' })).slotClass, 'session-icon--waiting'); + assert.equal(agentRowIcon(bg({ status: 'idle' })).slotClass, 'session-icon--idle'); + assert.equal(agentRowIcon(bg({ state: 'done', status: 'busy' })).slotClass, 'session-icon--stale'); + assert.equal(agentRowIcon(bg({ state: 'stopped', status: 'idle' })).slotClass, 'session-icon--stale'); + assert.equal(agentRowIcon({ kind: 'interactive', status: 'busy' }).slotClass, 'session-icon--busy'); +}); + +test('row icon: a blocked job shows the waiting dot whatever its status', () => { + assert.equal(agentRowIcon(bg({ state: 'blocked', status: 'idle' })).slotClass, 'session-icon--waiting'); + assert.equal(agentRowIcon(bg({ state: 'blocked', status: null })).slotClass, 'session-icon--waiting'); + assert.equal(agentRowIcon(bg({ state: 'blocked', status: 'busy' })).slotClass, 'session-icon--waiting'); +}); + +test('verb availability follows the state, the kind and the daemon', () => { + assert.deepEqual(agentVerbAvailability(bg(), true), { transcript: true, attach: true, stop: true, respawn: false, rm: false }); + assert.deepEqual(agentVerbAvailability(bg({ state: 'done' }), true), { transcript: true, attach: false, stop: false, respawn: true, rm: true }); + assert.deepEqual(agentVerbAvailability(bg({ state: 'stopped' }), true), { transcript: true, attach: false, stop: false, respawn: true, rm: true }); + assert.deepEqual(agentVerbAvailability(bg(), false), { transcript: true, attach: false, stop: false, respawn: false, rm: false }); + assert.deepEqual(agentVerbAvailability({ kind: 'interactive', sessionId: 'i' }, true), { transcript: true, attach: false, stop: false, respawn: false, rm: false }); + assert.equal(agentVerbAvailability(bg({ sessionId: null, state: 'done' }), true).transcript, false); +}); + +test('verb availability: a blocked job is live like a working one', () => { + assert.deepEqual(agentVerbAvailability(bg({ state: 'blocked' }), true), { transcript: true, attach: true, stop: true, respawn: false, rm: false }); + assert.deepEqual(agentVerbAvailability(bg({ state: 'blocked' }), false), { transcript: true, attach: false, stop: false, respawn: false, rm: false }); +}); + +test('formatting helpers', () => { + assert.equal(formatTokens(null), ''); + assert.equal(formatTokens(274), '274'); + assert.equal(formatTokens(172999), '173k'); + assert.equal(formatTokens(2500000), '2.5M'); + const now = 1_000_000_000; + assert.equal(formatAgentAge(null, now), ''); + assert.equal(formatAgentAge(now - 30_000, now), '30s'); + assert.equal(formatAgentAge(now - 12 * 60_000, now), '12 min'); + assert.equal(formatAgentAge(now - 3 * 3_600_000, now), '3 h'); + assert.equal(formatAgentAge(now - (2 * 24 + 6) * 3_600_000, now), '2d 6h'); + assert.equal(agentsEntryKey(bg()), 'bg:aaaaaaaa'); + assert.equal(agentsEntryKey({ kind: 'interactive', sessionId: 'x' }), 'int:x'); +}); diff --git a/test/dom-agents-view.test.js b/test/dom-agents-view.test.js new file mode 100644 index 00000000..f756566d --- /dev/null +++ b/test/dom-agents-view.test.js @@ -0,0 +1,228 @@ +// test/dom-agents-view.test.js — the agents view rendered in jsdom. See .ai/contexts/bg-agents.md. +'use strict'; +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const { JSDOM } = require('jsdom'); + +const PUBLIC = path.join(__dirname, '..', 'public'); +const HTML = ` +
    +
    +
    + + +`; + +function evalFile(dom, file) { + vm.runInContext(fs.readFileSync(file, 'utf8'), dom.getInternalVMContext(), { filename: file }); +} + +function setup() { + const dom = new JSDOM(HTML, { url: 'http://localhost/', runScripts: 'outside-only', pretendToBeVisual: true }); + const { window } = dom; + const calls = { verbs: [], opened: [], jsonl: [], external: [], stopped: [], shown: [], sidebarRefreshes: 0, fetches: 0 }; + let changedCb = null; + let snapshot = { roster: [], daemonReachable: true }; + window.api = { + getBgAgents: async () => { calls.fetches++; return snapshot; }, + bgAgentVerb: async (verb, id) => { calls.verbs.push([verb, id]); return { ok: verb !== 'rm', error: verb === 'rm' ? 'nope' : undefined }; }, + onBgAgentsChanged: (cb) => { changedCb = cb; }, + openExternal: async (href) => calls.external.push(href), + stopSession: async (id) => { calls.stopped.push(id); return { ok: true }; }, + }; + const g = { + placeholder: window.document.getElementById('placeholder'), + terminalArea: window.document.getElementById('terminal-area'), + terminalHeader: window.document.getElementById('terminal-header'), + gridViewer: window.document.getElementById('grid-viewer'), + statsViewer: window.document.getElementById('stats-viewer'), + memoryViewer: window.document.getElementById('memory-viewer'), + workFilesViewer: window.document.getElementById('work-files-viewer'), + settingsViewer: window.document.getElementById('settings-viewer'), + jsonlViewer: window.document.getElementById('jsonl-viewer'), + resortBtn: window.document.getElementById('resort-btn'), + openSessions: new Map(), + sessionMap: new Map(), + activeSessionId: null, + gridViewActive: false, + showSession: (id) => calls.shown.push(id), + openSession: (session, opts) => calls.opened.push([session, opts]), + showJsonlViewer: (session) => calls.jsonl.push(session), + refreshSidebar: () => { calls.sidebarRefreshes++; }, + fitAndScroll: () => {}, + confirm: () => true, + }; + for (const [k, v] of Object.entries(g)) Object.defineProperty(window, k, { value: v, writable: true, configurable: true }); + vm.runInContext(fs.readFileSync(path.join(__dirname, '..', 'node_modules', 'morphdom', 'dist', 'morphdom-umd.js'), 'utf8'), dom.getInternalVMContext()); + evalFile(dom, path.join(PUBLIC, 'utils.js')); + evalFile(dom, path.join(PUBLIC, 'session-state.js')); + evalFile(dom, path.join(PUBLIC, 'memory-workfiles-view.js')); + evalFile(dom, path.join(PUBLIC, 'agents-view.js')); + window.initAgentsView(); + const read = (expr) => vm.runInContext(expr, dom.getInternalVMContext()); + return { + window, document: window.document, calls, read, + setSnapshot(s) { snapshot = s; }, + emitChanged(s) { snapshot = s; changedCb(s); }, + destroy() { window.close(); }, + }; +} + +const ROSTER = [ + { id: 'aaaaaaaa', sessionId: 's-a', name: 'em-platform', cwd: '/w/em', kind: 'background', state: 'working', status: 'idle', pid: 10, startedAt: Date.now() - 60_000, agent: 'fleet:em', model: 'sonnet', detail: 'awaiting !196', tempo: 'idle', tokens: 173000, fan: [{ id: 'f', kind: 'agent', label: 'Spawn developer', startedAt: 1, doneAt: 27_000 }], children: [{ id: '195', href: 'https://gitlab.example/mr/195', kind: 'mr' }], result: 'no action', attachedHere: false }, + { id: 'bbbbbbbb', sessionId: 's-b', name: 'spike', cwd: '/w/f', kind: 'background', state: 'done', status: null, pid: null, startedAt: Date.now() - 3_600_000, agent: null, model: null, detail: null, tempo: null, tokens: 274, fan: [], children: [], result: null, attachedHere: false }, + { id: null, sessionId: 's-i', name: 'lvds-1b', cwd: '/w/l', kind: 'interactive', state: null, status: 'busy', pid: 30, startedAt: Date.now() - 10_000, agent: null, model: null, detail: null, tempo: null, tokens: null, fan: [], children: [], result: null, attachedHere: false }, +]; + +test('showing the view hides the terminal area, lists the roster sorted, and counts running/finished', async (t) => { + const ctx = setup(); t.after(() => ctx.destroy()); + ctx.setSnapshot({ roster: ROSTER, daemonReachable: true }); + await ctx.window.showAgentsView(); + assert.equal(ctx.window.terminalArea.style.display, 'none'); + assert.equal(ctx.document.getElementById('agents-viewer').style.display, 'flex'); + assert.equal(ctx.read('agentsViewActive'), true); + const names = [...ctx.document.querySelectorAll('.agents-row-name')].map(el => el.textContent); + assert.deepEqual(names, ['lvds-1b', 'em-platform', 'spike']); + assert.equal(ctx.document.getElementById('agents-viewer-count').textContent, '1 running · 1 finished'); + assert.equal(ctx.document.getElementById('agents-viewer-banner').style.display, 'none'); + assert.equal(ctx.window.localStorage.getItem('agentsViewActive'), '1'); +}); + +test('the Finished filter hides done and stopped jobs and is remembered', async (t) => { + const ctx = setup(); t.after(() => ctx.destroy()); + ctx.setSnapshot({ roster: ROSTER, daemonReachable: true }); + await ctx.window.showAgentsView(); + const box = ctx.document.getElementById('agents-show-finished'); + box.checked = false; + box.dispatchEvent(new ctx.window.Event('change', { bubbles: true })); + assert.deepEqual([...ctx.document.querySelectorAll('.agents-row-name')].map(el => el.textContent), ['lvds-1b', 'em-platform']); + assert.equal(ctx.window.localStorage.getItem('agentsShowFinished'), '0'); +}); + +test('selecting a row renders its detail with the verbs disabled by state, and a roster update keeps the selection', async (t) => { + const ctx = setup(); t.after(() => ctx.destroy()); + ctx.setSnapshot({ roster: ROSTER, daemonReachable: true }); + await ctx.window.showAgentsView(); + ctx.document.querySelector('.agents-row[data-key="bg:aaaaaaaa"]').click(); + const detail = ctx.document.getElementById('agents-detail'); + assert.match(detail.textContent, /awaiting !196/); + assert.match(detail.textContent, /173k tokens/); + assert.match(detail.textContent, /Spawn developer/); + assert.equal(detail.querySelector('[data-verb="rm"]').disabled, true); + assert.equal(detail.querySelector('[data-verb="stop"]').disabled, false); + assert.equal(detail.querySelector('[data-verb="attach"]').disabled, false); + ctx.emitChanged({ roster: [{ ...ROSTER[0], detail: 'changed' }, ROSTER[1], ROSTER[2]], daemonReachable: true }); + assert.match(ctx.document.getElementById('agents-detail').textContent, /changed/); + assert.ok(ctx.document.querySelector('.agents-row[data-key="bg:aaaaaaaa"]').classList.contains('selected')); +}); + +test('the verbs: attach opens a tab keyed by the session id, transcript opens the viewer, stop calls the IPC, a failure shows in the detail', async (t) => { + const ctx = setup(); t.after(() => ctx.destroy()); + ctx.setSnapshot({ roster: ROSTER, daemonReachable: true }); + await ctx.window.showAgentsView(); + ctx.document.querySelector('.agents-row[data-key="bg:aaaaaaaa"]').click(); + const detail = ctx.document.getElementById('agents-detail'); + detail.querySelector('[data-verb="attach"]').click(); + assert.equal(ctx.calls.opened.length, 1); + assert.equal(ctx.calls.opened[0][0].sessionId, 's-a'); + assert.deepEqual({ ...ctx.calls.opened[0][1] }, { type: 'attach', jobId: 'aaaaaaaa', cwd: '/w/em' }); + detail.querySelector('[data-verb="transcript"]').click(); + assert.equal(ctx.calls.jsonl[0].sessionId, 's-a'); + await ctx.window.runAgentVerb('stop', ROSTER[0]); + assert.deepEqual(ctx.calls.verbs, [['stop', 'aaaaaaaa']]); + ctx.document.querySelector('.agents-row[data-key="bg:bbbbbbbb"]').click(); + await ctx.window.runAgentVerb('rm', ROSTER[1]); + assert.match(ctx.document.getElementById('agents-detail').textContent, /nope/); +}); + +test('stop on a session attached here detaches the tab first', async (t) => { + const ctx = setup(); t.after(() => ctx.destroy()); + await ctx.window.showAgentsView(); + await ctx.window.runAgentVerb('stop', { ...ROSTER[0], attachedHere: true }); + assert.deepEqual(ctx.calls.stopped, ['s-a']); + assert.deepEqual(ctx.calls.verbs, [['stop', 'aaaaaaaa']]); +}); + +test('an unreachable daemon shows the banner and disables every verb but Transcript; an empty roster shows the empty state', async (t) => { + const ctx = setup(); t.after(() => ctx.destroy()); + ctx.setSnapshot({ roster: ROSTER, daemonReachable: false }); + await ctx.window.showAgentsView(); + assert.notEqual(ctx.document.getElementById('agents-viewer-banner').style.display, 'none'); + ctx.document.querySelector('.agents-row[data-key="bg:aaaaaaaa"]').click(); + const detail = ctx.document.getElementById('agents-detail'); + assert.equal(detail.querySelector('[data-verb="stop"]').disabled, true); + assert.equal(detail.querySelector('[data-verb="transcript"]').disabled, false); + ctx.emitChanged({ roster: [], daemonReachable: true }); + assert.match(ctx.document.getElementById('agents-list').textContent, /No background agents/); +}); + +test('hideAllViewers closes the view without restoring the terminal; hideAgentsView restores it', async (t) => { + const ctx = setup(); t.after(() => ctx.destroy()); + ctx.window.activeSessionId = 'open-1'; + ctx.window.openSessions.set('open-1', { closed: false }); + await ctx.window.showAgentsView(); + ctx.window.hideAllViewers(); + assert.equal(ctx.read('agentsViewActive'), false); + assert.equal(ctx.document.getElementById('agents-viewer').style.display, 'none'); + assert.deepEqual(ctx.calls.shown, [], 'no restore from hideAllViewers'); + await ctx.window.showAgentsView(); + ctx.window.hideAgentsView(); + assert.deepEqual(ctx.calls.shown, ['open-1']); + assert.equal(ctx.window.terminalArea.style.display, ''); +}); + +test('a roster push updates bgAgentSessionIds and refreshes the sidebar only when the set changes', async (t) => { + const ctx = setup(); t.after(() => ctx.destroy()); + ctx.emitChanged({ roster: ROSTER, daemonReachable: true }); + assert.deepEqual([...ctx.read('bgAgentSessionIds')].sort(), ['s-a', 's-b']); + assert.equal(ctx.calls.sidebarRefreshes, 1); + ctx.emitChanged({ roster: ROSTER, daemonReachable: true }); + assert.equal(ctx.calls.sidebarRefreshes, 1); +}); + +test('no roster fetch before the view is first opened; opening it fetches', async (t) => { + const ctx = setup(); t.after(() => ctx.destroy()); + assert.equal(ctx.calls.fetches, 0); + await ctx.window.showAgentsView(); + assert.equal(ctx.calls.fetches, 1); +}); + +test('attach from the view while the grid is shown closes the view onto the grid', async (t) => { + const ctx = setup(); t.after(() => ctx.destroy()); + ctx.setSnapshot({ roster: ROSTER, daemonReachable: true }); + await ctx.window.showAgentsView(); + ctx.window.gridViewActive = true; + ctx.window.attachBgAgent(ROSTER[0]); + assert.equal(ctx.read('agentsViewActive'), false); + assert.equal(ctx.window.gridViewer.style.display, 'block'); + assert.equal(ctx.calls.opened.length, 1); +}); + +test('a blocked job counts as running, sorts with the live rows and keeps only the live verbs', async (t) => { + const ctx = setup(); t.after(() => ctx.destroy()); + const blocked = { ...ROSTER[0], id: 'cccccccc', sessionId: 's-c', name: 'asks', state: 'blocked', status: 'waiting', startedAt: Date.now() - 5_000 }; + ctx.setSnapshot({ roster: [...ROSTER, blocked], daemonReachable: true }); + await ctx.window.showAgentsView(); + assert.deepEqual([...ctx.document.querySelectorAll('.agents-row-name')].map(el => el.textContent), ['asks', 'lvds-1b', 'em-platform', 'spike']); + assert.equal(ctx.document.getElementById('agents-viewer-count').textContent, '2 running · 1 finished'); + const box = ctx.document.getElementById('agents-show-finished'); + box.checked = false; + box.dispatchEvent(new ctx.window.Event('change', { bubbles: true })); + ctx.document.querySelector('.agents-row[data-key="bg:cccccccc"]').click(); + const detail = ctx.document.getElementById('agents-detail'); + assert.equal(detail.querySelector('[data-verb="attach"]').disabled, false); + assert.equal(detail.querySelector('[data-verb="stop"]').disabled, false); + assert.equal(detail.querySelector('[data-verb="respawn"]').disabled, true); + assert.equal(detail.querySelector('[data-verb="rm"]').disabled, true); + detail.querySelector('[data-verb="attach"]').click(); + assert.deepEqual({ ...ctx.calls.opened[0][1] }, { type: 'attach', jobId: 'cccccccc', cwd: '/w/em' }); +}); From 61d4cefcdfcda303a5f4a7e6c94d48cdc8fd9a24 Mon Sep 17 00:00:00 2001 From: pjay Date: Wed, 30 Sep 2026 20:11:18 +0200 Subject: [PATCH 15/38] (agents): keep the view open across a restart and close it when another viewer opens A hide of an already-hidden view no longer clears the persisted flag, and the flag is read before the working-set restore runs. Memory, Work Files and Settings now close the view instead of stacking over it. Claude-Session: https://claude.ai/code/session_01WriJRPX84KSTHVY9wuKWyo --- eslint.config.js | 1 + public/agents-view.js | 10 ++++- public/app.js | 2 +- public/memory-workfiles-view.js | 2 + public/settings-panel.js | 1 + test/dom-agents-view.test.js | 79 ++++++++++++++++++++++++++++++++- 6 files changed, 91 insertions(+), 4 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index 38835fc3..62e4d66a 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -333,6 +333,7 @@ const rendererCrossFileGlobals = { showAgentsView: 'readonly', hideAgentsView: 'readonly', toggleAgentsView: 'readonly', + restoreAgentsViewAtStartup: 'readonly', applyAgentsSnapshot: 'readonly', refreshAgentsRoster: 'readonly', attachBgAgent: 'readonly', diff --git a/public/agents-view.js b/public/agents-view.js index 3de98260..156d6e58 100644 --- a/public/agents-view.js +++ b/public/agents-view.js @@ -8,6 +8,7 @@ let agentsDaemonReachable = true; let agentsSelectedKey = null; let agentsShowFinished = true; let agentsReconcileTimer = null; +let agentsOpenAtStartup = false; const agentsPendingVerbs = new Set(); const agentsVerbErrors = new Map(); @@ -258,7 +259,7 @@ function hideAgentsView({ restore = true } = {}) { if (el) el.style.display = 'none'; const wasActive = agentsViewActive; agentsViewActive = false; - localStorage.setItem('agentsViewActive', '0'); + if (wasActive) localStorage.setItem('agentsViewActive', '0'); if (agentsReconcileTimer) { clearInterval(agentsReconcileTimer); agentsReconcileTimer = null; } setAgentsToggleActive(false); if (!wasActive || !restore) return; @@ -280,7 +281,14 @@ function toggleAgentsView() { else showAgentsView(); } +async function restoreAgentsViewAtStartup() { + if (!agentsOpenAtStartup) return; + agentsOpenAtStartup = false; + await showAgentsView(); +} + function initAgentsView() { + agentsOpenAtStartup = localStorage.getItem('agentsViewActive') === '1'; agentsShowFinished = localStorage.getItem('agentsShowFinished') !== '0'; const box = document.getElementById('agents-show-finished'); if (box) { diff --git a/public/app.js b/public/app.js index 38504e52..9dcacd13 100644 --- a/public/app.js +++ b/public/app.js @@ -1473,7 +1473,7 @@ loadProjects().then(async () => { } // Restore working set (persisted across full restarts via global settings) await restoreWorkingSet(); - if (localStorage.getItem('agentsViewActive') === '1') showAgentsView(); + restoreAgentsViewAtStartup(); }); // Live-reload sidebar when filesystem changes are detected diff --git a/public/memory-workfiles-view.js b/public/memory-workfiles-view.js index 190e50dd..1aa5381c 100644 --- a/public/memory-workfiles-view.js +++ b/public/memory-workfiles-view.js @@ -193,6 +193,7 @@ async function openMemory(file) { terminalArea.style.display = 'none'; statsViewer.style.display = 'none'; settingsViewer.style.display = 'none'; + if (typeof hideAgentsView === 'function') hideAgentsView({ restore: false }); memoryViewer.style.display = 'flex'; memoryPanel.open(file.filename, file.filePath, content); @@ -345,6 +346,7 @@ async function openWorkFile(file) { statsViewer.style.display = 'none'; settingsViewer.style.display = 'none'; memoryViewer.style.display = 'none'; + if (typeof hideAgentsView === 'function') hideAgentsView({ restore: false }); workFilesViewer.style.display = 'flex'; workFilesPanel.open(file.filename, file.filePath, content); diff --git a/public/settings-panel.js b/public/settings-panel.js index 88e39d0b..c53af288 100644 --- a/public/settings-panel.js +++ b/public/settings-panel.js @@ -42,6 +42,7 @@ document.getElementById('stats-viewer').style.display = 'none'; document.getElementById('memory-viewer').style.display = 'none'; document.getElementById('jsonl-viewer').style.display = 'none'; + if (typeof hideAgentsView === 'function') hideAgentsView({ restore: false }); settingsViewer.style.display = 'flex'; function useGlobalCheckbox(fieldName) { diff --git a/test/dom-agents-view.test.js b/test/dom-agents-view.test.js index f756566d..6784b693 100644 --- a/test/dom-agents-view.test.js +++ b/test/dom-agents-view.test.js @@ -11,7 +11,9 @@ const PUBLIC = path.join(__dirname, '..', 'public'); const HTML = `
    -
    +
    +
    +
    ` (before `
    `) add: - -```html - -``` - -and the script tag after `memory-workfiles-view.js`: - -```html - - -``` - -`public/memory-workfiles-view.js`, in `hideAllViewers()` after `jsonlViewer.style.display = 'none';`: - -```js - if (typeof hideAgentsView === 'function') hideAgentsView({ restore: false }); -``` - -`public/app.js`: - -- after `initGridGroupToggle();` (line 1303): `initAgentsView();` -- in the sidebar-filters block (line 1375), after `resortBtn.parentElement.insertBefore(gridToggleBtn, resortBtn);`: - -```js - const agentsToggleBtn = document.createElement('button'); - agentsToggleBtn.id = 'agents-toggle-btn'; - agentsToggleBtn.title = 'Background agents'; - agentsToggleBtn.innerHTML = ''; - agentsToggleBtn.addEventListener('click', toggleAgentsView); - resortBtn.parentElement.insertBefore(agentsToggleBtn, resortBtn); -``` - -- in the same block's keydown listener, before the grid branch: - -```js - if (matchShortcut('agentsToggle', e, isMac, appShortcuts)) { - e.preventDefault(); - toggleAgentsView(); - return; - } -``` - -- in the startup `loadProjects().then(async () => {` (line 1438), after the grid restore: - -```js - if (localStorage.getItem('agentsViewActive') === '1') showAgentsView(); -``` - -- in the tab-switch `stats` branch (line 1276), before `statsViewer.style.display = 'flex';`: - -```js - if (typeof hideAgentsView === 'function') hideAgentsView({ restore: false }); -``` - -`public/terminal-manager.js`, before the `gridToggle` branch at line 95: - -```js - if (matchShortcut('agentsToggle', e, isMac, appShortcuts)) { - if (e.type === 'keydown') { e._handled = true; toggleAgentsView(); } - return false; - } -``` - -`public/style.css`, after the `#grid-viewer-count` rule: - -```css -/* Agents view — see .ai/contexts/bg-agents.md */ -#agents-viewer { display: none; flex-direction: column; flex: 1; min-height: 0; } -#agents-viewer-header { - display: flex; align-items: center; gap: 10px; padding: 8px 16px; - background: var(--surface-chrome); border-bottom: 1px solid var(--hairline); flex-shrink: 0; -} -#agents-viewer-title { font-size: 13px; color: #b0b0c4; font-weight: 500; } -#agents-viewer-count { font-size: 11px; color: #7a7a90; margin-right: auto; } -#agents-finished-toggle { font-size: 11px; color: #7a7a90; display: inline-flex; align-items: center; gap: 4px; } -#agents-new-btn, .agents-verb-btn { - background: transparent; border: 1px solid var(--control-border); color: #b0b0c4; - font-size: 11px; padding: 3px 8px; border-radius: 6px; cursor: pointer; font-family: inherit; -} -#agents-new-btn:hover, .agents-verb-btn:hover:not([disabled]) { background: var(--control-surface); } -.agents-verb-btn[disabled] { opacity: 0.4; cursor: default; } -#agents-viewer-banner { padding: 6px 16px; font-size: 11px; color: #f0a050; background: rgba(240,160,80,0.08); } -#agents-viewer-body { display: flex; flex-direction: column; flex: 1; min-height: 0; } -#agents-list { flex: 1; overflow: auto; min-height: 0; } -.agents-row { - display: grid; grid-template-columns: 18px minmax(160px, 2fr) minmax(90px, 1fr) minmax(110px, 1fr) minmax(160px, 2fr) 60px; - gap: 8px; align-items: center; padding: 6px 16px; font-size: 12px; color: #c8c8d8; cursor: pointer; - border-bottom: 1px solid var(--hairline); -} -.agents-row:hover { background: var(--control-surface); } -.agents-row.selected { background: rgba(128,136,255,0.1); } -.agents-row.pending { opacity: 0.5; } -.agents-row-name, .agents-row-cwd { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.agents-row-agent, .agents-row-state, .agents-row-age { color: #7a7a90; } -#agents-detail { border-top: 1px solid var(--hairline); padding: 10px 16px; max-height: 40%; overflow: auto; font-size: 12px; color: #c8c8d8; flex-shrink: 0; } -.agents-detail-head { display: flex; align-items: center; gap: 10px; margin-bottom: 6px; } -.agents-detail-name { font-weight: 500; margin-right: auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.agents-detail-actions { display: flex; gap: 6px; flex-shrink: 0; } -.agents-detail-line { margin: 4px 0; } -.agents-detail-meta, .agents-detail-empty { color: #7a7a90; margin: 4px 0; } -.agents-detail-section { margin-top: 8px; } -.agents-detail-section ul { margin: 4px 0 0 16px; padding: 0; } -.agents-detail-error { margin-top: 8px; color: #f06060; } -.agents-link { color: var(--accent); } -``` - -and add `#agents-toggle-btn` to the three selectors that name `#grid-toggle-btn` (base, `:hover`, `.active`), plus `#agents-toggle-btn { margin-left: 2px; }` after `#grid-toggle-btn { margin-left: auto; }`. - -`eslint.config.js`, in `rendererCrossFileGlobals` after `liveElsewhereMany: 'readonly',`: - -```js - // Agents view (public/agents-view.js) — see .ai/contexts/bg-agents.md - agentsViewActive: 'writable', - bgAgentSessionIds: 'readonly', - initAgentsView: 'readonly', - showAgentsView: 'readonly', - hideAgentsView: 'readonly', - toggleAgentsView: 'readonly', - applyAgentsSnapshot: 'readonly', - refreshAgentsRoster: 'readonly', - attachBgAgent: 'readonly', - runAgentVerb: 'readonly', - selectAgentsRow: 'readonly', - showDispatchAgentDialog: 'readonly', -``` - -If `npx eslint public/` still reports `no-undef` for a name agents-view.js reads (for instance `fitAndScroll` or `gridViewActive`), add that name as `'readonly'` in the same list; do not disable the rule. - -- [ ] **Step 6: Run the tests and the lint** - -Run: `node --test test/agents-view-pure.test.js test/dom-agents-view.test.js && npx eslint public/ eslint.config.js` -Expected: PASS, 4 + 8 tests; 0 errors. - -- [ ] **Step 7: Commit** - -```bash -/usr/bin/git add public/agents-view.js public/index.html public/style.css public/memory-workfiles-view.js public/app.js public/terminal-manager.js eslint.config.js test/agents-view-pure.test.js test/dom-agents-view.test.js -/usr/bin/git commit -m "(agents): a view of the daemon's background sessions, with attach, transcript, stop, respawn and delete" -``` - ---- - -### Task 8: The sidebar's "bg" badge - -**Files:** -- Modify: `public/sidebar.js` (in `buildSessionItem`, after the remote badge), `public/style.css` -- Create: `test/dom-sidebar-bg-badge.test.js` - -**Interfaces:** -- Consumes: `bgAgentSessionIds` (Task 7). - -- [ ] **Step 1: Write the failing test** - -```js -// test/dom-sidebar-bg-badge.test.js — a session the daemon runs carries a "bg" badge once the roster is known. -'use strict'; -const test = require('node:test'); -const assert = require('node:assert/strict'); -const { setupSidebarDom, makeSampleProject } = require('./dom-setup'); - -test('a row whose session id is in bgAgentSessionIds shows the bg badge; the others do not', (t) => { - const ctx = setupSidebarDom(); - t.after(() => ctx.destroy()); - Object.defineProperty(ctx.window, 'bgAgentSessionIds', { value: new Set(['s-top-1']), writable: true, configurable: true }); - const project = makeSampleProject(); - ctx.sidebar.renderProjects([project], false); - const badged = ctx.document.querySelector('[data-session-id="s-top-1"] .bg-badge'); - assert.ok(badged, 'the badge is there'); - assert.equal(badged.textContent, 'bg'); - assert.equal(ctx.document.querySelector('[data-session-id="s-top-2"] .bg-badge'), null); -}); - -test('without the roster global the sidebar renders as before', (t) => { - const ctx = setupSidebarDom(); - t.after(() => ctx.destroy()); - ctx.sidebar.renderProjects([makeSampleProject()], false); - assert.equal(ctx.document.querySelector('.bg-badge'), null); -}); -``` - -`renderProjects(projects, resort)` is the signature in `public/sidebar.js:576`; `test/dom-sidebar-icon-slot.test.js` calls it the same way. - -- [ ] **Step 2: Run it to verify it fails** - -Run: `node --test test/dom-sidebar-bg-badge.test.js` -Expected: the first test FAILS on "the badge is there". - -- [ ] **Step 3: Implement** - -`public/sidebar.js`, in `buildSessionItem` right after the `if (session.remoteAlias) { … }` badge block: - -```js - // see .ai/contexts/bg-agents.md ("The sidebar") - if (typeof bgAgentSessionIds !== 'undefined' && bgAgentSessionIds.has(session.sessionId)) { - const badge = document.createElement('span'); - badge.className = 'bg-badge'; - badge.title = 'Background session run by the claude daemon — click to attach'; - badge.textContent = 'bg'; - summaryEl.prepend(badge); - } -``` - -`public/style.css`, after `.remote-badge { … }`: - -```css -.bg-badge { - display: inline-block; margin-right: 5px; padding: 0 5px; - border: 1px solid #5a4a7a; border-radius: 3px; color: #b39ddb; - font-size: 10px; line-height: 15px; vertical-align: middle; -} -``` - -- [ ] **Step 4: Run the sidebar tests** - -Run: `node --test test/dom-sidebar-bg-badge.test.js test/dom-sidebar-icon-slot.test.js && npx eslint public/sidebar.js` -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -/usr/bin/git add public/sidebar.js public/style.css test/dom-sidebar-bg-badge.test.js -/usr/bin/git commit -m "(sidebar): badge the sessions the daemon runs in the background" -``` - ---- - -### Task 9: The dispatch dialog - -**Files:** -- Modify: `public/dialogs.js` (append `showDispatchAgentDialog`) -- Create: `test/dom-dispatch-dialog.test.js` - -**Interfaces:** -- Consumes: `window.api.dispatchBgAgent(fields)` (Task 5), `selectAgentsRow(id)` (Task 7), `cachedAllProjects`, `PERMISSION_MODES`, `SETTING_DEFAULTS`, `escapeHtml`, `shortProjectPath`. -- Produces: `showDispatchAgentDialog(project | null)`. - -- [ ] **Step 1: Write the failing test** - -```js -// test/dom-dispatch-dialog.test.js — the fields become exactly the dispatch payload. See .ai/contexts/bg-agents.md. -'use strict'; -const test = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const vm = require('node:vm'); -const { JSDOM } = require('jsdom'); - -const PUBLIC = path.join(__dirname, '..', 'public'); -const tick = () => new Promise(r => setTimeout(r, 0)); - -function setup({ dispatchResult = { ok: true, id: 'cccccccc' } } = {}) { - const dom = new JSDOM('', { url: 'http://localhost/', runScripts: 'outside-only', pretendToBeVisual: true }); - const { window } = dom; - const calls = { dispatched: [], selected: [] }; - window.api = { - platform: 'linux', - getEffectiveSettings: async () => ({ permissionMode: 'auto', addDirs: '' }), - dispatchBgAgent: async (fields) => { calls.dispatched.push(fields); return dispatchResult; }, - }; - const g = { - cachedAllProjects: [{ projectPath: '/w/one' }, { projectPath: '/w/two' }], - selectAgentsRow: (id) => calls.selected.push(id), - launchNewSession: () => {}, cachedProjects: [], sessionMap: new Map(), pendingSessions: new Map(), - openSessions: new Map(), activePtyIds: new Set(), refreshSidebar: () => {}, pollActiveSessions: () => {}, - }; - for (const [k, v] of Object.entries(g)) Object.defineProperty(window, k, { value: v, writable: true, configurable: true }); - for (const f of ['setting-defaults.js', 'utils.js', 'icons.js', 'dialogs.js']) { - vm.runInContext(fs.readFileSync(path.join(PUBLIC, f), 'utf8'), dom.getInternalVMContext(), { filename: f }); - } - return { window, document: window.document, calls, destroy: () => window.close() }; -} - -test('Start sends the trimmed fields with the chosen project and mode, then selects the new row', async (t) => { - const ctx = setup(); t.after(ctx.destroy); - await ctx.window.showDispatchAgentDialog({ projectPath: '/w/two' }); - const d = ctx.document; - assert.equal(d.querySelector('#dad-project').value, '/w/two', 'the given project is preselected'); - d.querySelector('#dad-prompt').value = ' review the backlog '; - d.querySelector('#dad-name').value = 'em-1'; - d.querySelector('#dad-agent').value = 'fleet:em'; - d.querySelector('#dad-add-dirs').value = '/srv/a'; - d.querySelector('.permission-option[data-mode="plan"]').click(); - d.querySelector('.new-session-start-btn').click(); - await tick(); await tick(); - assert.deepEqual(ctx.calls.dispatched, [{ prompt: 'review the backlog', name: 'em-1', agent: 'fleet:em', cwd: '/w/two', permissionMode: 'plan', dangerouslySkipPermissions: false, addDirs: '/srv/a' }]); - assert.deepEqual(ctx.calls.selected, ['cccccccc']); - assert.equal(d.querySelector('.new-session-overlay'), null, 'the dialog closed'); -}); - -test('an empty prompt never reaches main, and a failed dispatch keeps the dialog open with the error', async (t) => { - const ctx = setup({ dispatchResult: { ok: false, error: 'daemon said no' } }); t.after(ctx.destroy); - await ctx.window.showDispatchAgentDialog(null); - const d = ctx.document; - d.querySelector('.new-session-start-btn').click(); - await tick(); - assert.equal(ctx.calls.dispatched.length, 0); - assert.match(d.querySelector('#dad-error').textContent, /prompt/); - d.querySelector('#dad-prompt').value = 'go'; - d.querySelector('.new-session-start-btn').click(); - await tick(); await tick(); - assert.equal(ctx.calls.dispatched.length, 1); - assert.equal(ctx.calls.dispatched[0].cwd, '/w/one', 'first project by default'); - assert.match(d.querySelector('#dad-error').textContent, /daemon said no/); - assert.ok(d.querySelector('.new-session-overlay'), 'still open'); -}); - -test('Enter inside the prompt does not start; Escape closes', async (t) => { - const ctx = setup(); t.after(ctx.destroy); - await ctx.window.showDispatchAgentDialog(null); - const d = ctx.document; - const prompt = d.querySelector('#dad-prompt'); - prompt.value = 'x'; - prompt.dispatchEvent(new ctx.window.KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); - await tick(); - assert.equal(ctx.calls.dispatched.length, 0); - d.dispatchEvent(new ctx.window.KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); - assert.equal(d.querySelector('.new-session-overlay'), null); -}); -``` - -- [ ] **Step 2: Run it to verify it fails** - -Run: `node --test test/dom-dispatch-dialog.test.js` -Expected: FAIL, `showDispatchAgentDialog is not a function`. - -- [ ] **Step 3: Implement** - -Append to `public/dialogs.js`: - -```js -// --- Dispatch a background agent — see .ai/contexts/bg-agents.md ("Dispatch") --- -async function showDispatchAgentDialog(project) { - const projects = (typeof cachedAllProjects !== 'undefined' ? cachedAllProjects : []) - .map(p => p && p.projectPath).filter(Boolean); - const requested = project && project.projectPath; - if (requested && !projects.includes(requested)) projects.unshift(requested); - const defaultPath = requested || projects[0] || ''; - let effective = {}; - if (defaultPath) { - try { effective = await window.api.getEffectiveSettings(defaultPath); } catch { effective = {}; } - } - - const overlay = document.createElement('div'); - overlay.className = 'new-session-overlay'; - const dialog = document.createElement('div'); - dialog.className = 'new-session-dialog'; - - let selectedMode = effective.permissionMode || null; - let dangerousSkip = !!effective.dangerouslySkipPermissions; - - function renderModeGrid() { - return PERMISSION_MODES.map(m => { - const isSelected = !dangerousSkip && selectedMode === m.value; - return ``; - }).join('') + - ``; - } - - dialog.innerHTML = ` -

    New background agent

    -
    -
    - Prompt -
    The task the agent runs, in the background, under the claude daemon
    -
    -
    - -
    -
    -
    -
    - Project -
    Working directory of the agent
    -
    -
    - -
    -
    -
    -
    - Name -
    --name; empty lets the CLI pick one
    -
    -
    - -
    -
    -
    -
    - Agent -
    --agent, e.g. fleet:em; empty for none
    -
    -
    - -
    -
    -
    -
    Permission Mode
    -
    ${renderModeGrid()}
    -
    -
    -
    - Additional Directories -
    Extra directories to include (comma-separated)
    -
    -
    - -
    -
    -
    -
    - - -
    - `; - overlay.appendChild(dialog); - document.body.appendChild(overlay); - - const modeGrid = dialog.querySelector('#dad-mode-grid'); - modeGrid.addEventListener('click', (e) => { - const btn = e.target.closest('.permission-option'); - if (!btn) return; - const mode = btn.dataset.mode; - if (mode === 'dangerous-skip') { - dangerousSkip = !dangerousSkip; - if (dangerousSkip) selectedMode = null; - } else { - dangerousSkip = false; - selectedMode = mode === 'null' ? null : mode; - } - modeGrid.innerHTML = renderModeGrid(); - }); - - const errorEl = dialog.querySelector('#dad-error'); - const startBtn = dialog.querySelector('.new-session-start-btn'); - - function close() { - overlay.remove(); - document.removeEventListener('keydown', onKey); - } - - async function start() { - const prompt = dialog.querySelector('#dad-prompt').value.trim(); - if (!prompt) { errorEl.textContent = 'A prompt is required.'; return; } - const fields = { - prompt, - name: dialog.querySelector('#dad-name').value.trim(), - agent: dialog.querySelector('#dad-agent').value.trim(), - cwd: dialog.querySelector('#dad-project').value, - permissionMode: dangerousSkip ? null : selectedMode, - dangerouslySkipPermissions: dangerousSkip, - addDirs: dialog.querySelector('#dad-add-dirs').value.trim(), - }; - errorEl.textContent = ''; - startBtn.disabled = true; - let result; - try { result = await window.api.dispatchBgAgent(fields); } catch (err) { result = { ok: false, error: err.message }; } - startBtn.disabled = false; - if (!result || result.ok === false) { - errorEl.textContent = (result && result.error) || 'unknown error'; - return; - } - close(); - if (result.id && typeof selectAgentsRow === 'function') selectAgentsRow(result.id); - } - - dialog.querySelector('.new-session-cancel-btn').onclick = close; - startBtn.onclick = start; - overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); - - function onKey(e) { - if (e.key === 'Escape') close(); - if (e.key === 'Enter' && !e.target.matches('input, textarea, select')) start(); - } - document.addEventListener('keydown', onKey); - dialog.querySelector('#dad-prompt').focus(); -} -``` - -- [ ] **Step 4: Run the test and the lint** - -Run: `node --test test/dom-dispatch-dialog.test.js && npx eslint public/dialogs.js` -Expected: PASS, 3 tests, 0 lint errors: `cachedAllProjects` is already declared in `eslint.config.js` (line 89), and `selectAgentsRow` / `showDispatchAgentDialog` were added there in Task 7. - -- [ ] **Step 5: Commit** - -```bash -/usr/bin/git add public/dialogs.js test/dom-dispatch-dialog.test.js -/usr/bin/git commit -m "(agents): dispatch a new background agent from a dialog" -``` - ---- - -### Task 10: Documentation and context engineering - -**Files:** -- Create: `docs/background-agents.md`, `.ai/contexts/bg-agents.md` -- Modify: `README.md` (feature table), `docs/README.md`, `docs/keyboard-shortcuts.md`, `docs/settings.md` (if it lists `localStorage` keys; otherwise skip), `.ai/contexts/ipc-bridge.md`, `.ai/contexts/README.md`, `.ai/contexts/cli-session-state.md`, `.ai/shared-guidelines.md` - -- [ ] **Step 1: Write `docs/background-agents.md`** - -```markdown -# Background agents - -The Agents view is Switchboard's replacement for the `claude agents` TUI: it -lists the sessions the Claude CLI daemon runs in the background -(`claude --bg`) and the interactive `claude` sessions running outside this -Switchboard, and acts on them without a terminal. - -## Opening it - -The people icon in the sidebar's filter row, or `Ctrl+Shift+A` (`Cmd+Shift+A` -on macOS; [rebindable](keyboard-shortcuts.md)). The same toggle closes it and -brings back whatever was there: the grid, the active session, or the -placeholder. Whether the view is open is remembered across restarts. - -## The list - -One row per session: a state glyph (the same rungs as the sidebar: spinner -while busy, orange while waiting, green when idle, grey when finished), its -name, its `--agent`, `state · status`, its directory, and its age. Working -sessions and external interactive sessions come first, newest first. -**Finished** shows or hides `done` and `stopped` sessions; the choice is -remembered. - -Selecting a row opens its detail: the daemon's one-line status, tokens, -model, start time, pid, the subagents it ran, the links it produced (merge -requests open in the browser), its last result, and the verbs: - -| Verb | Runs | Available | -|---|---|---| -| Attach | `claude attach ` in a terminal tab | while the session is `working` | -| Transcript | the read-only transcript viewer | whenever the transcript exists | -| Stop | `claude stop `; the conversation is kept | while `working` | -| Respawn | `claude respawn ` | any background session | -| Delete | `claude rm `, after confirmation; the worktree goes too when that is safe | when not `working` | - -An external interactive session offers Transcript only. - -## Attaching - -An attach tab is an ordinary terminal tab running `claude attach`. Its stop -button reads **Detach**: closing the tab sends Ctrl+Z, the attach client -leaves, and the session keeps running under the daemon. Stopping the session -is only offered in the Agents view. Attach tabs are not reopened by -[session restore](session-restore.md). - -A click in the sidebar on a session the daemon is running attaches to it -instead of asking to resume it; the row carries a `bg` badge once the Agents -view has been opened. A finished background session resumes like any other. - -## New agent - -**New agent** opens a dialog: prompt, project, name (`--name`), agent -(`--agent`), permission mode or Dangerous Skip, additional directories. It -runs `claude --bg …` in the project directory and selects the new row. - -## When the daemon does not answer - -The view reads two files the CLI writes for itself, `~/.claude/jobs//state.json` -and `~/.claude/sessions/.json`, and asks `claude agents --json --all` -which sessions exist. When that command fails, a banner says so, the list -comes from the files alone, and every verb but Transcript is disabled. -Neither file is a documented interface; a CLI upgrade may change them, and -`test/canary-bg-agents-files.test.js` says so when it happens. -``` - -- [ ] **Step 2: Write `.ai/contexts/bg-agents.md`** - -```markdown -# Context: bg-agents - -**Purpose**: The Agents view — a graphical replacement for the `claude agents` -TUI. Lists the daemon's `--bg` sessions and the external interactive ones, -attaches/stops/respawns/deletes/dispatches through the CLI. Design: -`docs/superpowers/specs/2026-09-30-background-agents-view-design.md`. - -## Key files - -| File | Role | -|---|---| -| `bg-agents-roster.js` | Pure: `parseJobState`, `parseCliList`, `mergeRoster`, `dispatchArgs`, `parseDispatchOutput` | -| `bg-agents.js` | Watchers over `~/.claude/jobs/*/state.json`, descriptor subscription, `reconcile()` through `claude agents --json --all`, `runVerb`, `dispatch`, `onChange` | -| `bg-agents-ipc.js` | `get-bg-agents`, `bg-agent-verb`, `dispatch-bg-agent`, the `bg-agents-changed` push | -| `cli-session-state.js` | `onDescriptorsChanged`, `readAllDescriptors`, `kind`/`jobId` on live-elsewhere | -| `pty-ops.js` | `detachPty` | -| `main.js` | `runClaudeCommand`; the `type: 'attach'` branch of `open-terminal`; detach in `stop-session` | -| `public/agents-view.js` | The view; `bgAgentSessionIds` for the sidebar badge | -| `public/resume-guard.js` | A live `kind: 'bg'` descriptor answers `{ attach }` | -| `public/dialogs.js` | `showDispatchAgentDialog` | - -## Invariants - -1. Never `--resume` or `--fork-session` a session whose job is `working`. - `claude attach` is the only path to a live job (`guardResume` turns a - `bg` verdict into attach options; `open-terminal` builds `claude attach`). -2. Every call to the CLI goes through the login shell with an argv quoted by - `quoteArgvForShell` (`runClaudeCommand`, the scheduler's path). Never a - command string built by hand. The daemon's control socket and - `control.key` are never touched. -3. Closing an attach tab detaches (`\x1a`, 2 s grace, then kill — - `detachPty`). `claude stop` is the only stop. App quit kills the attach - client outright; the CLI documents that the session survives either way. -4. No steady-state cost before the view is first opened: `bgAgents.start()` - runs on the first `get-bg-agents`. Closing the view keeps the watchers so - the sidebar badge stays current; the window's `closed` handler releases - them. -5. `jobs/` and the `kind: "bg"` descriptor are undocumented. Failure is - silence: an unreadable `state.json` keeps the previous value; a CLI that - fails leaves a file-only roster with `daemonReachable: false`. Canaries: - `test/canary-bg-agents-files.test.js`, `test/canary-cli-session-state.test.js`. -6. A verb's id is validated against `JOB_ID_RE` before any spawn; a prompt - starting with `-` is refused by `dispatchArgs`. - -## Data flow - -`jobs//state.json` (fs.watch, per directory) and `sessions/.json` -(through `cli-session-state`'s flush) both call `scheduleRebuild()`, -coalesced at `FLUSH_MS` (250 ms). `rebuild()` = `mergeRoster(cli, jobs, -readAllDescriptors())`. The CLI list is the authority for which jobs exist -and their `state`; the file supplies `detail`, `tokens`, `fan`, `children`, -`result`, `--agent`/`--model`/`--name`; the descriptor supplies `status`, -`pid`, `agent`. `reconcile()` runs on every `get-bg-agents` (the renderer -calls it on open and every 30 s while visible) and after every verb. - -## Non-obvious behaviors - -- The view is a sibling of `#jsonl-viewer`, shown by hiding - `#terminal-area` (as the Stats tab does), so the grid's state survives. - `hideAllViewers()` calls `hideAgentsView({ restore: false })`; only the - toggle restores the terminal area. -- `claude --bg` prints its id in a format nobody measured (2026-09-30); - `parseDispatchOutput` takes the first eight-hex token and `dispatch` - reports `ok: true, id: null` otherwise — the row arrives through the files. -- An attach tab's `cli-session-state` status comes from the daemon worker's - descriptor (same `sessionId`), so busy/idle needs no special path. -- Attach tabs are excluded from the working set (`entry.attach`). - -## Measured facts (CLI 2.1.285, Linux, 2026-09-30) - -- `claude agents --json --all`: ~0.15 s CPU; array of `{id, sessionId, name, - cwd, kind, startedAt, pid?, state?, status?}`. -- `claude attach ` in a pty: Ctrl+Z detaches, client exits 0, session - stays `working`. -- `claude logs ` prints screen ANSI, unusable without xterm — not used. - -## If you change this, also check - -- `.ai/contexts/ipc-bridge.md` (the three handlers, the event) -- `.ai/contexts/cli-session-state.md` (the descriptor hooks) -- `docs/background-agents.md`, `docs/keyboard-shortcuts.md` -``` - -- [ ] **Step 3: Rows in the shared docs** - -`README.md`, feature table, after the Subagents row: - -``` -| The sessions the claude daemon runs in the background: list, attach, stop, dispatch | [Background agents](docs/background-agents.md) | -``` - -`docs/README.md`, after the Subagents row: - -``` -| [Background agents](background-agents.md) | The Agents view: the daemon's `--bg` sessions, attach in a tab, stop, respawn, delete, dispatch | -``` - -`docs/keyboard-shortcuts.md`, in the rebindable table after the grid row: - -``` -| Toggle agents view | Primary+Shift+`A` | Show or hide the [background agents](background-agents.md) view | -``` - -and change "how to rebind the three that can be" in `docs/README.md` to "the four". - -`docs/settings.md` does not list `localStorage` keys (checked 2026-09-30: no `gridViewActive` in it), so it is not touched; the two keys are named in `docs/background-agents.md` ("remembered"). - -`.ai/contexts/ipc-bridge.md`: a new subsection before "Misc": - -``` -### Background agents (see `.ai/contexts/bg-agents.md`) - -| IPC | Args | Returns | Notes | -|---|---|---|---| -| `get-bg-agents` | — | `{roster, daemonReachable}` | Arms the watchers on first 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`. | -| `dispatch-bg-agent` | `(fields)` | `{ok, id?, error?}` | `claude --bg …` in `fields.cwd`. | - -`open-terminal` accepts `sessionOptions = {type: 'attach', jobId, cwd}` and runs `claude attach `; `stop-session` on such a session detaches (`{ok, detached: true}`). -``` - -and add `bg-agents-changed` to the events list. Add `bg-agents-ipc.js` to the "Key files" table with the same warning as `schedule-ipc.js`. - -`.ai/contexts/README.md`, in the "When to read which" table: - -``` -| The Agents view, the daemon's job files, attach/detach, dispatch | [bg-agents](bg-agents.md) | -``` - -`.ai/contexts/cli-session-state.md`: a short section "Descriptor hooks for the agents view": `onDescriptorsChanged` fires once per flushed batch; `readAllDescriptors` returns the live descriptors' subset; `liveElsewhere` results carry `kind`/`jobId`; pointer to `bg-agents.md`. - -`.ai/shared-guidelines.md`: an orientation row ("Change the Agents view, the daemon's job files, attach/detach, dispatch | [contexts/bg-agents.md]") and a fork-feature bullet ("**Background agents view** — the daemon's `--bg` sessions listed, attached, stopped, dispatched; see [contexts/bg-agents.md]"). - -- [ ] **Step 4: Lint the markdown links by reading them once** - -Run: `ls docs/background-agents.md .ai/contexts/bg-agents.md && grep -n "background-agents\|bg-agents" README.md docs/README.md docs/keyboard-shortcuts.md .ai/contexts/README.md .ai/contexts/ipc-bridge.md .ai/shared-guidelines.md` -Expected: each file lists at least one hit. - -- [ ] **Step 5: Commit** - -```bash -/usr/bin/git add README.md docs/ .ai/ -/usr/bin/git commit -m "(docs): document the background agents view and its context" -``` - ---- - -### Task 11: Comment sweep, full check, live check, PR - -**Files:** every file touched above. - -- [ ] **Step 1: Comment sweep** - -Run: `/usr/bin/git diff main --stat && /usr/bin/git diff main -- '*.js' | grep -n "^+.*//" | grep -v "see .ai/contexts" ` -Expected: only one-line pointers remain. Move any rationale that survived into `.ai/contexts/bg-agents.md` and delete it from the code. - -- [ ] **Step 2: Full check** - -Run: `task check` -Expected: 0 errors (pre-existing warnings are fine), every test passing including the canaries (or skipping where the files are absent). - -- [ ] **Step 3: Live check against the isolated instance** - -Follow `docs/testing-a-pr.md` with this branch. In the test instance: - -1. In a shell, `cd` to any project and run `claude --bg --name plan-check "say hello and wait"`. Note the printed id and its exact output format; if `parseDispatchOutput` would not have found it, fix the regex in `bg-agents-roster.js` and its test, and record the format in `.ai/contexts/bg-agents.md`. -2. `Ctrl+Shift+A`: the row shows `working`, its detail line, and the sidebar row of that session carries `bg`. -3. Attach: a tab opens on the session; the header button reads Detach; close it; the Agents view still says `working`. -4. Click the same session in the sidebar: it attaches without a "Resume anyway?" dialog. -5. Stop from the view: the state goes `stopped`; Delete asks and removes it. -6. New agent with a prompt and the same project: a new row appears and is selected. -7. Quit the daemon-less path: `mv ~/.claude/daemon.lock ~/.claude/daemon.lock.bak`, reopen the view: the banner shows and the list still lists the jobs; restore the file. - -Record anything that differs from the plan in `.ai/contexts/bg-agents.md` before the PR. - -- [ ] **Step 4: Squash to clear commits and open the PR** - -Keep one commit per task if they read well, or squash into: roster + watchers (main), attach/detach (main + renderer), the view, the dialog, docs. Then: - -```bash -/usr/bin/git push -u origin worktree-background-agents-view -gh pr create --repo devsuitup/switchboard --base main --title "(agents): a view of the sessions the claude daemon runs in the background" --body-file - <<'EOF' -A graphical replacement for the `claude agents` TUI. - -- Lists the daemon's `--bg` sessions and the interactive sessions running outside this Switchboard, from `~/.claude/jobs/*/state.json` and the session descriptors, reconciled by `claude agents --json --all`. -- Attach opens `claude attach ` in a terminal tab keyed by the session's real id; closing it detaches (Ctrl+Z), never kills. -- Stop, respawn, delete and dispatch go through the CLI with a quoted argv. -- A click in the sidebar on a session the daemon runs attaches instead of asking to resume. - -Design: docs/superpowers/specs/2026-09-30-background-agents-view-design.md -Context: .ai/contexts/bg-agents.md -EOF -``` - -The review loop and the reviewer request (`gh api -X POST repos/devsuitup/switchboard/pulls//requested_reviewers -f 'reviewers[]=devsuitup'`) follow `.ai/shared-guidelines.md` "When you finish work", step 6, once the review converges. diff --git a/docs/superpowers/specs/2026-09-30-background-agents-view-design.md b/docs/superpowers/specs/2026-09-30-background-agents-view-design.md deleted file mode 100644 index d1a0f6f5..00000000 --- a/docs/superpowers/specs/2026-09-30-background-agents-view-design.md +++ /dev/null @@ -1,308 +0,0 @@ -# Background agents view — design - -Date: 2026-09-30. Status: approved in conversation, awaiting implementation plan. - -## Purpose - -Give Switchboard a graphical replacement for the `claude agents` TUI: one -place to see every session the Claude CLI daemon runs in the background -(`claude --bg`), read what each one is doing, and act on it — attach to it in -a terminal tab, stop it, delete it, respawn it, or dispatch a new one — without -opening a terminal and the TUI. - -The user's fleet of agents (the fleet plugin's EM/PM/developer roles) runs -entirely as `--bg` sessions, so this view is their control room. - -## What the CLI provides (measured, CLI 2.1.285, Linux, 2026-09-30) - -None of this is a documented interface. Every use below is best-effort and -must degrade to silence, exactly as `.ai/contexts/cli-session-state.md` -prescribes for the session descriptors. - -- `claude --bg [--name n] [--agent a] [--permission-mode m] [--add-dir d] ` - starts a session under the daemon and prints its short id. -- `claude agents --json [--all]` prints a JSON array, no TTY needed, in - ~0.15 s CPU. Without `--all`: live sessions only (background `working` plus - every live interactive session, including Switchboard's own). With `--all`: - also `done` and `stopped` background sessions. Entry shape: - `{id, sessionId, name, cwd, kind: 'background'|'interactive', startedAt, - pid?, state?: 'working'|'done'|'stopped', status?: 'busy'|'idle'|'waiting'}`. -- `claude attach ` opens the session in the current terminal. Verified in - a pty: Ctrl+Z detaches, the attach client exits 0, the session stays - `working`. `claude stop `, `claude rm ` (also deletes the worktree - when safe), `claude respawn `, `claude logs ` (raw screen ANSI, not - used here). -- `~/.claude/jobs//state.json`, written by the daemon per job: - `state`, `detail` (one human-readable line), `tempo`, `tokens`, `inFlight`, - `fan[]` (`{id, kind: 'agent'|'shell', label, startedAt, doneAt}`), - `children[]` (links the job produced, e.g. merge requests: `{id, href, - kind}`), `output.result`, `template`, `respawnFlags[]` (the original - `--agent`, `--model`, `--name`, `--permission-mode`), `intent`, - `linkScanPath` (the transcript path, which carries the session id). - `timeline.jsonl` beside it is not read. -- `~/.claude/sessions/.json` for a background worker carries - `kind: "bg"`, `jobId` (the short id), `agent`, `name`, `cwd`, `status`, - `procStart`, `startedAt`. Interactive sessions carry `kind: "interactive"`. - Switchboard already watches this directory (`cli-session-state.js`). -- A stopped or done background session has no live pid; `claude --resume` - on it is legitimate (the CLI documents it). A `working` one must never be - resumed: two CLIs would write one transcript. - -## Scope - -In: - -- A dedicated **Agents view**, a sibling of the grid, listing background - sessions (all states, with a filter for finished ones) and interactive - sessions that run outside this Switchboard instance. -- Per session: attach, read the transcript, stop, respawn, delete. -- A dispatch dialog to start a new background session. -- The sidebar's click on a live background session attaches instead of - asking "Resume anyway?". - -Out (deliberately): - -- Live terminals inside the view (attach opens a real tab). -- A `claude logs` tail (screen ANSI; the JSONL transcript covers reading). -- Restoring attach tabs across restarts. -- Pre-launch command and sandbox in the dispatch dialog (they wrap a process - Switchboard holds; here the daemon holds it). -- Configurable sort or grouping; interactive sessions of this instance - (the sidebar already shows them). -- Any use of the daemon's control socket or `control.key`. - -## Architecture - -### Main process: `bg-agents.js` - -One new module beside `cli-session-state.js`, with one responsibility: keep a -roster of daemon jobs and external interactive sessions, and push it to the -renderer. - -Sources: - -1. `~/.claude/jobs/`: one `fs.watch` on the directory (new job directories) - and one per job directory (rewrites of `state.json`). Each read goes - through a pure `parseJobState(text)` that keeps only what the view shows: - `state, detail, tempo, tokens, fan, children, result, template, agent, - model, name` (the last three derived from `respawnFlags`), and `sessionId` - extracted from `linkScanPath`. An unreadable or truncated file leaves the - previous value in place and logs at debug. -2. `~/.claude/sessions/.json`: `cli-session-state.js` gains an - `onDescriptor(listener)` hook that emits every parsed descriptor it reads - (`pid, sessionId, kind, jobId, agent, name, cwd, status, startedAt, - procStart`), without changing its existing matching or transitions. - `bg-agents.js` keeps `kind: "bg"` descriptors (joined to a job by `jobId`) - and `kind: "interactive"` descriptors that `ownProcessFilter()` does not - claim for this instance. - -Reconciliation: `claude agents --json --all` via `execFile` (no shell, -5 s timeout), run by every `get-bg-agents` call and after every verb. The -renderer calls `get-bg-agents` when the view opens and every 30 s while it -is visible, so that is the reconciliation cadence. Its list is the authority for which jobs exist and their -`state`; the files supply everything else. A job directory absent from the -CLI's list is not shown. If the CLI fails (missing, no `agents` subcommand, -timeout), the roster is built from files alone and carries -`daemonReachable: false`. - -Roster entry: - -``` -{ id, sessionId, name, cwd, kind: 'background'|'interactive', - state: 'working'|'done'|'stopped'|null, status: 'busy'|'idle'|'waiting'|null, - pid, startedAt, agent, model, detail, tempo, tokens, fan, children, result, - attachedHere: boolean } -``` - -`mergeRoster(cliList, jobs, descriptors, ownPids)` is pure and unit-tested. - -IPC (add to `.ai/contexts/ipc-bridge.md`): - -| IPC | Args | Returns | -|---|---|---| -| `get-bg-agents` | — | `{roster: Entry[], daemonReachable}` — snapshot; arms the watchers on first call | -| `bg-agent-verb` | `(verb: 'stop'\|'respawn'\|'rm', id)` | `{ok, error?}` | -| `dispatch-bg-agent` | `({prompt, name, agent, cwd, permissionMode, dangerouslySkipPermissions, addDirs})` | `{ok, id?, error?}` | -| event `bg-agents-changed` | `{roster, daemonReachable}` | coalesced at 250 ms | - -Guards: liveness by `process.kill(pid, 0)` and `procStart` reuse from -`cli-session-state`; `MAX_JOBS` (200) bounds the initial scan; watchers are -armed on the first `get-bg-agents` and released in the window's `closed` -handler with the other watchers. Nothing runs before the view is first -opened (ADR 0002: no added steady-state cost). - -### Renderer: `agents-view.js` - -A plain script like the others. Depends on `escapeHtml`, the roster from -IPC, and two callbacks from `app.js`: open a terminal tab, open the JSONL -viewer. Renders with `morphdom` from an in-memory model so a roster update -keeps the selection and the scroll. - -Container `#agents-viewer`, a sibling of `#jsonl-viewer` outside -`#terminal-area`, shown the way the Stats tab shows its viewer (hide -`#terminal-area`, which keeps the grid's state intact) and hidden by -restoring whichever of grid, active session or placeholder was there. Toggle button in the sidebar filter row next to -the grid button; shortcut `agentsToggle` (default Ctrl+Shift+A, Cmd on macOS) -registered in `shortcuts.js` and listed in `docs/keyboard-shortcuts.md`. Open -state persists in `localStorage.agentsViewActive`. Closing the view does not -release the watchers. - -Layout: a master list and a detail pane. - -``` -┌ Agents ──────────────────── 3 running · 2 done ─── [New agent] [Finished ☑] ┐ -│ ● em-platform-2026… fleet:em working·idle lvds/…/em-platform 2d 6h ⋯ │ -│ ● fleet-0f — working·busy lvds/internal/fleet 12 min ⋯ │ -│ ○ spike-target — done lvds/internal/fleet 1 h ⋯ │ -│ ◌ lvds-1b external busy lvds/.claude/worktr… 3 h │ -├───────────────────────────────────────────────────────────────────────────┤ -│ em-platform-20260928075800-49fd [Attach] [Transcript] │ -│ backlog reviewed; awaiting !196 merge or apiClient.ts diff │ -│ 173k tokens · sonnet-5 · started 28/09 07:58 · pid 346590 │ -│ Subagents: Spawn developer for platform squad (26 s, done) │ -│ Produced: !195 platform-admin-dossiers-nav · !196 … │ -│ Last result: no new action needed; session idle pending !196 merge… │ -└───────────────────────────────────────────────────────────────────────────┘ -``` - -- List row: state glyph reusing the rungs of `session-state.js` (busy - spinner, waiting orange, idle green, done/stopped grey, external - interactive as a hollow circle), name, `--agent`, `state·status`, - abbreviated project path, age. Sort: `working` first, then - `startedAt` descending. The "Finished" filter (on by default) shows or - hides `done`/`stopped`; persisted in `localStorage.agentsShowFinished`. -- Detail pane for the selected row: `detail`, tokens, model, start time, - pid, `fan[]` with duration and state, `children[]` as clickable links - (`shell.openExternal`, already exposed), `output.result`. For an external - interactive session: name, cwd, status, and only the Transcript action. -- A row click selects it; the detail pane carries the verbs. -- "New agent" opens the dispatch dialog. -- Banner under the header when `daemonReachable` is false: "The daemon is - not answering; state comes from files only." Verbs other than Transcript - are disabled then. Empty state: "No background agents. `claude --bg` - starts one, or New agent." - -### Verbs - -**Attach.** An ordinary terminal tab whose pty runs `claude attach ` in -the session's cwd, through `open-terminal` with `sessionOptions.type = -'attach'` and the `jobId`. The tab is keyed by the session's real -`sessionId`, so the sidebar row (already indexed from the transcript) and -the tab coincide, and `cli-session-state` feeds its busy/idle state from the -daemon worker's descriptor with no change. No `--resume`, no fork, ever. - -- Detach: closing the tab writes `\x1a` (Ctrl+Z) to the pty, waits up to - 2 s for the attach client to exit, and kills the pty only as a last - resort. The terminal header's Stop button reads "Detach" on an attach tab - and does exactly this; stopping the background session is only offered in - the Agents view. This is the detach/stop pair `.ai/contexts/session-state.md` - already defines. -- An attach tab is not part of the restore working set: after a restart it - does not come back; the Agents view is the way to reopen it. -- If `claude attach` exits at once (the job stopped between the click and the - spawn), the tab shows the CLI's output and the header goes to "exited", - like any pty. - -**Stop, Respawn, Delete.** `execFile('claude', [verb, id])` in the session's -cwd, 15 s timeout, through the user's login shell with an argv quoted by -`quoteArgvForShell`, the scheduler's existing path in `main.js`; never a -command string built by hand. Each returns `{ok, error}` (stderr verbatim) -and triggers a reconciliation. Delete asks for confirmation with the CLI's -own wording: the conversation and its worktree go, when that is safe. Stop -or Delete on a session attached here detaches first. - -**Dispatch.** `showDispatchAgentDialog()` in `dialogs.js`, built from the -same pieces as the New Session dialog: - -| Field | Passed as | -|---|---| -| Prompt (textarea, required) | last positional argument | -| Name | `--name `; empty = the CLI picks one | -| Project (select over the sidebar's projects, preselected to the active session's) | the `cwd` of the `execFile` | -| Agent (free text) | `--agent `; empty = none | -| Permission mode / Dangerous Skip (as in New Session) | `--permission-mode ` or `--dangerously-skip-permissions` | -| Additional directories | one `--add-dir` per entry, through the existing `parseAddDirs` | - -Command: `claude --bg [options] ` via `execFile`, through the user's -login shell with an argv quoted by `quoteArgvForShell`, the scheduler's -existing path in `main.js`; never a command string built by hand. The -printed id is parsed; on success the roster is reconciled and the new row -selected. If the id does not parse, the result is `{ok: true, id: null}`; -the row appears through the files. - -### Sidebar - -No roster in the sidebar. The existing resume guard is extended by two -fields: `session-live-elsewhere` and `sessions-live-elsewhere` also return -the descriptor's `kind` and `jobId`. In `guardResume`, `kind === 'bg'` with a -live pid no longer asks "Resume anyway?": the click attaches. A `done` or -`stopped` background session has no live pid, the guard says nothing, and -`--resume` proceeds as today. External interactive sessions keep today's -confirmation. Once the Agents view has been opened at least once, the -`bg-agents-changed` event also reaches the sidebar, which puts a small "bg" -badge on the rows whose session id is in the roster; before that there is no -badge and no cost, and the guard's protection does not depend on it. - -## Failure handling - -- CLI missing, without `agents`, or timing out: file-only roster, banner, - verbs disabled except Transcript. Nothing else in the app is affected. -- `state.json` unreadable or mid-rewrite: previous value kept, debug log. -- Dead descriptor pid: same liveness as `cli-session-state`; the entry falls - back to the CLI's state alone. -- A failing verb: `{ok: false, error}` in the detail pane; the roster is - reconciled regardless. -- Dispatch whose id does not parse: see above. - -## Invariants (to be written to `.ai/contexts/bg-agents.md`, with a row in -`.ai/shared-guidelines.md` and `.ai/contexts/README.md`) - -1. Never `--resume` or `--fork-session` a session whose job is `working`. - `claude attach` is the only path to a live job. -2. Every write to the daemon goes through the CLI through the user's login - shell with an argv quoted by `quoteArgvForShell`, the scheduler's existing - path in `main.js`; never a command string built by hand. The control socket - and `control.key` are never touched. -3. Closing an attach tab detaches; it never kills the session. `claude stop` - is the only stop. -4. No steady-state cost before the view is first opened (ADR 0002). -5. `jobs/` and the `kind: "bg"` descriptor are undocumented interfaces: - failure is silence, and a canary test pins their observed shape. - -## Testing - -`node:test`, as the rest of the suite; renderer tests through -`test/dom-setup.js` and `vm.runInContext`. - -- `test/bg-agents-parse.test.js`: `parseJobState()` and `mergeRoster()`. - Cases: a job with `fan` and `children`; a `done` job without a pid; a bg - descriptor without a job (ignored); an interactive descriptor owned by this - instance (excluded); the CLI's `state` winning over the file's; the session - id extracted from `linkScanPath`. -- `test/bg-agents-watch.test.js`: a temporary `jobs/` directory; creating a - job directory and rewriting `state.json` yields one coalesced event; `stop()` - releases the watchers. -- `test/canary-bg-agents-files.test.js`: pins the observed shape of - `state.json` and of the bg descriptor (fields, `state` and `status` - vocabularies), with the CLI version and date in the test name. -- `test/dom-agents-view.test.js`: rendering a roster; sort; the Finished - filter; selection kept across a morphdom update; buttons disabled by state; - the daemon banner; the empty state. -- `test/resume-guard.test.js`, extended: `kind: 'bg'` with a live pid - attaches without confirmation; without a live pid the resume path is - taken. -- `test/dom-dispatch-dialog.test.js`: the fields produce exactly the expected - argument list (no shell, prompt last, absent options when empty). -- Main-side: `open-terminal` with `type: 'attach'` builds `claude attach - ` in the given cwd, and closing writes `\x1a` before any kill. - -Live verification runs against the isolated test instance -(`task test-pr`, see `docs/testing-a-pr.md`) with a `claude --bg` started by -hand. - -## Documentation to ship with the change - -- `docs/background-agents.md` (new page) and a row in the README's feature - table and `docs/README.md`. -- `docs/keyboard-shortcuts.md`: the new shortcut. -- `.ai/contexts/bg-agents.md`, and the IPC rows in `.ai/contexts/ipc-bridge.md`. diff --git a/main.js b/main.js index 16021c41..9e1baf2c 100644 --- a/main.js +++ b/main.js @@ -1676,6 +1676,7 @@ ipcMain.handle('stop-session', (_event, sessionId) => { if (!session || session.exited) return { ok: false, error: 'not running' }; // see .ai/contexts/bg-agents.md ("Detach") if (session.isAttach) { + if (session.stopRequested) return { ok: true, detached: true }; session.stopRequested = true; detachPty(session, sessionId); return { ok: true, detached: true }; @@ -2259,13 +2260,23 @@ function wireSessionPty(session, sessionId, ptyProcess) { }); } +function killProcessTree(child) { + try { + if (isWindows) spawnChild('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true }); + else process.kill(-child.pid, 'SIGKILL'); + } catch { try { child.kill('SIGKILL'); } catch {} } +} + // Run `claude ` to completion through the login shell -- see .ai/contexts/bg-agents.md function runClaudeCommand(claudeArgv, { cwd, timeout }) { return new Promise((resolve) => { const globalSettings = getSetting('global') || {}; const profile = resolveShell(globalSettings.shellProfile || SETTING_DEFAULTS.shellProfile); const shell = profile.path; - const args = shellArgs(shell, 'claude ' + quoteArgvForShell(shell, claudeArgv), profile.args || []); + // cmd.exe and PowerShell mangle quotes, `&` and newlines in a prompt: run claude itself + const direct = isWindows && !isWslShell(shell) && !/bash|zsh|fish|^sh$|^nu$/.test(path.basename(shell, path.extname(shell)).toLowerCase()); + const program = direct ? 'claude' : shell; + const args = direct ? claudeArgv : shellArgs(shell, 'claude ' + quoteArgvForShell(shell, claudeArgv), profile.args || []); let stdout = ''; let stderr = ''; let settled = false; @@ -2276,21 +2287,22 @@ function runClaudeCommand(claudeArgv, { cwd, timeout }) { }; let child; try { - child = spawnChild(shell, args, { + child = spawnChild(program, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'], env: { ...cleanPtyEnv, FORCE_COLOR: '0' }, windowsHide: true, + detached: !isWindows, }); } catch (err) { finish(null, err); return; } const timer = setTimeout(() => { - try { child.kill('SIGKILL'); } catch {} + killProcessTree(child); finish(null, new Error(`claude ${claudeArgv[0]} timed out after ${timeout} ms`)); }, timeout); child.stdout.on('data', (d) => { stdout += d.toString(); }); child.stderr.on('data', (d) => { stderr += d.toString(); }); child.on('error', (err) => { clearTimeout(timer); finish(null, err); }); - child.on('exit', (code) => { clearTimeout(timer); finish(code); }); + child.on('close', (code) => { clearTimeout(timer); finish(code); }); }); } diff --git a/pty-ops.js b/pty-ops.js index 9846be86..30a34bba 100644 --- a/pty-ops.js +++ b/pty-ops.js @@ -48,7 +48,9 @@ function writePty(session, data, sessionId) { // see .ai/contexts/bg-agents.md function detachPty(session, sessionId, { graceMs = 2000, schedule = setTimeout } = {}) { - const wrote = withPty(session, 'detach', (pty) => pty.write('\x1a'), sessionId); + if (session.detaching) return true; + session.detaching = true; + const wrote = writePty(session, '\x1a', sessionId); if (!wrote) return killPty(session, sessionId); const timer = schedule(() => { if (!session.exited) killPty(session, sessionId); diff --git a/public/index.html b/public/index.html index b73cf780..c4abb86e 100644 --- a/public/index.html +++ b/public/index.html @@ -176,7 +176,7 @@ - + diff --git a/public/resume-guard.js b/public/resume-guard.js index 3bb0a0e1..90b36830 100644 --- a/public/resume-guard.js +++ b/public/resume-guard.js @@ -28,7 +28,7 @@ async function guardResume(session, { automatic = false, api, confirm, live } = } if (!live) return true; if (automatic) return false; - // A session the claude daemon runs is attached, never resumed -- see .ai/contexts/bg-agents.md + // see .ai/contexts/bg-agents.md if (live.kind === 'bg') { if (typeof live.jobId === 'string' && live.jobId) return { attach: live.jobId, cwd: live.cwd || null }; return false; diff --git a/public/terminal-manager.js b/public/terminal-manager.js index 9a176254..b66ebdbe 100644 --- a/public/terminal-manager.js +++ b/public/terminal-manager.js @@ -1144,6 +1144,7 @@ function showSession(sessionId) { lruTouch(sessionId); if (gridViewActive) { + if (typeof hideAgentsView === 'function' && agentsViewActive) hideAgentsView(); // Ensure grid layout is set up (e.g. on first session after startup restore) if (!terminalsEl.classList.contains('grid-layout')) { showGridView(); diff --git a/test/bg-agents-roster.test.js b/test/bg-agents-roster.test.js index 2c5310e9..1507f9f0 100644 --- a/test/bg-agents-roster.test.js +++ b/test/bg-agents-roster.test.js @@ -101,12 +101,12 @@ function fixture() { return { cli, jobs, descriptors, isOwnPid: (pid) => pid === 20, isAttachedHere: (id) => id === 'aaaaaaaa' }; } -test('mergeRoster: the CLI list decides which jobs exist and their state; the file and the descriptor enrich', () => { +test('mergeRoster: the CLI list decides which jobs exist; the job file state, being live, wins over the CLI snapshot', () => { const roster = mergeRoster(fixture()); const ids = roster.map(e => e.kind === 'background' ? e.id : e.sessionId); assert.deepEqual(ids, ['aaaaaaaa', 'bbbbbbbb', 's-ext']); const a = roster[0]; - assert.equal(a.state, 'working', 'the CLI state wins over the file'); + assert.equal(a.state, 'done', 'the live state.json wins over the cached CLI state'); assert.equal(a.status, 'busy', 'the descriptor status wins over the CLI snapshot'); assert.equal(a.detail, 'stale detail'); assert.equal(a.tokens, 5); @@ -132,9 +132,9 @@ test('mergeRoster without the CLI lists the jobs on disk instead', () => { test('dispatchArgs builds the argv in a fixed order and omits empty options', () => { const r = dispatchArgs({ prompt: ' do the thing ', name: 'n1', agent: 'fleet:em', permissionMode: 'auto', addDirs: '/a, /b', cwd: '/proj' }); - assert.deepEqual(r, { ok: true, cwd: '/proj', args: ['--bg', '--name', 'n1', '--agent', 'fleet:em', '--permission-mode', 'auto', '--add-dir', '/a', '--add-dir', '/b', 'do the thing'] }); + assert.deepEqual(r, { ok: true, cwd: '/proj', args: ['--bg', '--name', 'n1', '--agent', 'fleet:em', '--permission-mode', 'auto', '--add-dir', '/a', '--add-dir', '/b', '--', 'do the thing'] }); const bare = dispatchArgs({ prompt: 'p', cwd: '/proj', name: '', agent: ' ', dangerouslySkipPermissions: true, permissionMode: 'auto' }); - assert.deepEqual(bare.args, ['--bg', '--dangerously-skip-permissions', 'p']); + assert.deepEqual(bare.args, ['--bg', '--dangerously-skip-permissions', '--', 'p']); }); test('dispatchArgs refuses an empty prompt, a missing cwd, and a prompt that looks like a flag', () => { diff --git a/test/bg-agents.test.js b/test/bg-agents.test.js index e7d23b1b..10dc49c4 100644 --- a/test/bg-agents.test.js +++ b/test/bg-agents.test.js @@ -248,7 +248,7 @@ test('dispatch runs `claude --bg …` in the project directory and returns the p const r = await bgAgents.dispatch({ prompt: 'hello', name: 'n', cwd: dir }); assert.deepEqual(r, { ok: true, id: 'cccccccc' }); const call = cli.calls.find(c => c.argv[0] === '--bg'); - assert.deepEqual(call.argv, ['--bg', '--name', 'n', 'hello']); + assert.deepEqual(call.argv, ['--bg', '--name', 'n', '--', 'hello']); assert.equal(call.opts.cwd, dir); const missing = await bgAgents.dispatch({ prompt: 'hello', cwd: path.join(dir, 'nope') }); assert.equal(missing.ok, false); diff --git a/test/canary-bg-agents-files.test.js b/test/canary-bg-agents-files.test.js index fcf328bc..07830bd5 100644 --- a/test/canary-bg-agents-files.test.js +++ b/test/canary-bg-agents-files.test.js @@ -38,8 +38,9 @@ test('CANARY: the Claude CLI daemon still writes jobs//state.json in the sha let raw; try { raw = JSON.parse(fs.readFileSync(file, 'utf8')); } catch { continue; } const seen = `(${file})`; - assert.ok(JOB_STATES.has(raw.state), - `PINNED ASSUMPTION BROKEN: "state" used to be one of ${[...JOB_STATES].join(', ')} ${seen}`); + if (!JOB_STATES.has(raw.state)) { + t.diagnostic(`unknown job state "${raw.state}" (known: ${[...JOB_STATES].join(', ')}) ${seen}; the view shows it as Unknown`); + } assert.ok(raw.detail === undefined || raw.detail === null || typeof raw.detail === 'string', `PINNED ASSUMPTION BROKEN: "detail" used to be a string, the one-line status the view shows ${seen}`); assert.ok(raw.respawnFlags === undefined || Array.isArray(raw.respawnFlags), diff --git a/test/canary-cli-session-state.test.js b/test/canary-cli-session-state.test.js index aa355a86..784f811d 100644 --- a/test/canary-cli-session-state.test.js +++ b/test/canary-cli-session-state.test.js @@ -68,7 +68,7 @@ test('CANARY: the Claude CLI still publishes per-session state we can read', (t) } }); -test('CANARY: a background worker descriptor still carries kind "bg" and its short job id (CLI 2.1.285, 2026-09-30)', (t) => { +test('CANARY: a background worker descriptor still carries kind "bg" and its short job id', (t) => { const bg = []; for (const name of listStateFiles()) { try { diff --git a/test/pty-ops-detach.test.js b/test/pty-ops-detach.test.js index 0b5fd20f..1348ab8c 100644 --- a/test/pty-ops-detach.test.js +++ b/test/pty-ops-detach.test.js @@ -38,3 +38,15 @@ test('detach on a pty that refuses the write falls back to a kill', () => { assert.equal(ok, true); assert.equal(calls.kills, 1); }); + +test('a second detach on the same session neither writes nor arms another timer', () => { + const { session, calls } = fakeSession(); + const timers = []; + const schedule = (fn) => { timers.push(fn); return {}; }; + detachPty(session, 's1', { schedule }); + detachPty(session, 's1', { schedule }); + assert.deepEqual(calls.writes, ['\x1a']); + assert.equal(timers.length, 1); + timers[0](); + assert.equal(calls.kills, 1); +}); diff --git a/test/remote-ssh-spawn-sites.test.js b/test/remote-ssh-spawn-sites.test.js index 2070bb7d..ed417eca 100644 --- a/test/remote-ssh-spawn-sites.test.js +++ b/test/remote-ssh-spawn-sites.test.js @@ -32,7 +32,7 @@ const UNRESOLVED_ALLOWED = { 'main.js': { spawnPty: 'node-pty wrapper: the shell of a local session, or the attach adapter\'s resolved ssh', runScheduleCommand: 'the shell of the schedule\'s shell profile', - runClaudeCommand: 'the shell profile running a local claude verb (agents, attach-less bg commands)', + runClaudeCommand: 'the shell profile running a local claude command (agents, --bg, stop, respawn, rm)', }, 'run-to-exit.js': { runToExit: 'exported wrapper; its callers are covered by the program-name test below', From 784e5b9573fbf5c58154137693934d437b8613cb Mon Sep 17 00:00:00 2001 From: pjay Date: Fri, 2 Oct 2026 10:56:00 +0200 Subject: [PATCH 37/38] (agents): resolve claude.cmd on Windows, keep the daemon on a --bg timeout claude is found on PATH and run as the .exe, as node plus the cli.js an npm shim points to, or through cmd.exe. A timed-out --bg kills only the client, and a call settles shortly after exit even if a daemon holds the pipes. The bg-agents tests stop the module and retry before removing their temp dirs. Claude-Session: https://claude.ai/code/session_01WriJRPX84KSTHVY9wuKWyo --- .ai/contexts/bg-agents.md | 13 ++++++--- claude-binary.js | 58 ++++++++++++++++++++++++++++++++++++++ main.js | 28 ++++++++++++++---- test/bg-agents.test.js | 42 ++++++++++++++------------- test/claude-binary.test.js | 55 ++++++++++++++++++++++++++++++++++++ 5 files changed, 168 insertions(+), 28 deletions(-) create mode 100644 claude-binary.js create mode 100644 test/claude-binary.test.js diff --git a/.ai/contexts/bg-agents.md b/.ai/contexts/bg-agents.md index e1870ebe..ce5c5b9a 100644 --- a/.ai/contexts/bg-agents.md +++ b/.ai/contexts/bg-agents.md @@ -351,10 +351,15 @@ Agents view. profile so `PATH` matches a terminal; the login shell makes a call slow (a `claude agents --json --all` took 12 s under bash on Windows), hence `LIST_TIMEOUT_MS` 20 s and `VERB_TIMEOUT_MS` 60 s. Under cmd.exe or PowerShell -the shell would mangle quotes, `&` and newlines of a prompt, so `claude` is -spawned directly with an argv array. A timeout kills the whole process tree -(`taskkill /T` on Windows, the process group elsewhere), and the call resolves -on `close` so stdout is drained. `dispatchArgs` puts `--` before the prompt: +the shell would mangle quotes, `&` and newlines of a prompt, so +`claude-binary.js` finds `claude` on `PATH` and spawns it without a shell: the +`.exe` itself, or, for an npm `claude.cmd` shim (libuv does not resolve +`.cmd`), the `node` + `cli.js` the shim points to, or `cmd.exe /d /s /c` with +escaped arguments as a last resort (a multi-line argument is refused there). +A timeout kills the whole process tree (`taskkill /T` on Windows, the process +group elsewhere), except for `--bg`: it may have started the daemon, so only +the client is killed. The call resolves on `close` so stdout is drained, or +1 s after `exit` when a daemon holding the pipes keeps `close` from firing. `dispatchArgs` puts `--` before the prompt: `--add-dir` is variadic and would otherwise swallow it. A job's own `state.json` state wins over the cached CLI list's. diff --git a/claude-binary.js b/claude-binary.js new file mode 100644 index 00000000..13d47aeb --- /dev/null +++ b/claude-binary.js @@ -0,0 +1,58 @@ +// see .ai/contexts/bg-agents.md ("Running the CLI") +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const CMD_META = /([()\][%!^"`<>&|;, *?])/g; + +function findOnPath(name, { pathEnv, pathExt, exists, sep }) { + const exts = ['', ...String(pathExt || '').split(';').filter(Boolean)]; + for (const dir of String(pathEnv || '').split(sep).filter(Boolean)) { + for (const ext of exts) { + const candidate = path.win32.join(dir, name + ext.toLowerCase()); + if (exists(candidate)) return candidate; + } + } + return null; +} + +function escapeForCmd(arg) { + let s = String(arg).replace(/(\\*)"/g, '$1$1\\"').replace(/(\\*)$/, '$1$1'); + s = `"${s}"`; + return s.replace(CMD_META, '^$1').replace(CMD_META, '^$1'); +} + +// How to run `claude ` on Windows without a shell profile: the .exe itself, the node + cli.js an npm +// .cmd shim points to, or cmd.exe over the shim. Returns { program, args, verbatim } or { error }. +function resolveWindowsClaude(argv, env = {}, deps = {}) { + const exists = deps.exists || ((p) => fs.existsSync(p)); + const readFile = deps.readFile || ((p) => fs.readFileSync(p, 'utf8')); + const found = findOnPath('claude', { + pathEnv: env.PATH || env.Path, pathExt: env.PATHEXT || '.COM;.EXE;.BAT;.CMD', exists, sep: ';', + }); + if (!found) return { error: 'claude was not found on PATH' }; + const ext = path.win32.extname(found).toLowerCase(); + if (ext === '.exe' || ext === '.com') return { program: found, args: argv, verbatim: false }; + if (ext !== '.cmd' && ext !== '.bat') return { error: `cannot run ${found}` }; + + let shim = ''; + try { shim = readFile(found); } catch {} + const m = /"%dp0%\\([^"]+\.js)"/i.exec(shim); + if (m) { + const dir = path.win32.dirname(found); + const script = path.win32.join(dir, m[1]); + const bundled = path.win32.join(dir, 'node.exe'); + const node = exists(bundled) ? bundled : findOnPath('node', { + pathEnv: env.PATH || env.Path, pathExt: '.EXE', exists, sep: ';', + }); + if (node && exists(script)) return { program: node, args: [script, ...argv], verbatim: false }; + } + if (argv.some((a) => /[\r\n]/.test(a))) { + return { error: 'a multi-line argument cannot go through a claude.cmd shim; install claude with its native installer or pick a bash profile' }; + } + const line = [found, ...argv].map(escapeForCmd).join(' '); + return { program: env.ComSpec || 'cmd.exe', args: ['/d', '/s', '/c', `"${line}"`], verbatim: true }; +} + +module.exports = { resolveWindowsClaude, escapeForCmd }; diff --git a/main.js b/main.js index 9e1baf2c..5ba457a3 100644 --- a/main.js +++ b/main.js @@ -74,6 +74,7 @@ const { scanMdFiles, acceptMdFile } = require('./scan-md-files'); const { isSensitivePath, isSensitivePathAsync, isAllowedMemoryPath: _isAllowedMemoryPath, resolveAllowedMemoryPath: _resolveAllowedMemoryPath, isKnownProjectRoot: _isKnownProjectRoot } = require('./ipc-path-validator'); const { validatePreLaunchCmd } = require('./pre-launch-cmd-guard'); const { normalizePtySize } = require('./pty-size'); +const { resolveWindowsClaude } = require('./claude-binary'); const { setPtyOpLogger, resizePty, killPty, detachPty, ptyExitSignalName } = require('./pty-ops'); const { JOB_ID_RE } = require('./bg-agents-roster'); const { createComposerState } = require('./composer-state'); @@ -2275,8 +2276,19 @@ function runClaudeCommand(claudeArgv, { cwd, timeout }) { const shell = profile.path; // cmd.exe and PowerShell mangle quotes, `&` and newlines in a prompt: run claude itself const direct = isWindows && !isWslShell(shell) && !/bash|zsh|fish|^sh$|^nu$/.test(path.basename(shell, path.extname(shell)).toLowerCase()); - const program = direct ? 'claude' : shell; - const args = direct ? claudeArgv : shellArgs(shell, 'claude ' + quoteArgvForShell(shell, claudeArgv), profile.args || []); + const childEnv = { ...cleanPtyEnv, FORCE_COLOR: '0' }; + let program = shell; + let args; + let verbatim = false; + if (direct) { + const plan = resolveWindowsClaude(claudeArgv, childEnv); + if (plan.error) { resolve({ code: null, stdout: '', stderr: plan.error }); return; } + ({ program, args, verbatim } = plan); + } else { + args = shellArgs(shell, 'claude ' + quoteArgvForShell(shell, claudeArgv), profile.args || []); + } + // a timed-out --bg may have started the daemon: kill the client only, never its group + const killTree = claudeArgv[0] !== '--bg'; let stdout = ''; let stderr = ''; let settled = false; @@ -2288,21 +2300,27 @@ function runClaudeCommand(claudeArgv, { cwd, timeout }) { let child; try { child = spawnChild(program, args, { - cwd, stdio: ['ignore', 'pipe', 'pipe'], env: { ...cleanPtyEnv, FORCE_COLOR: '0' }, windowsHide: true, - detached: !isWindows, + cwd, stdio: ['ignore', 'pipe', 'pipe'], env: childEnv, windowsHide: true, + windowsVerbatimArguments: verbatim, detached: killTree && !isWindows, }); } catch (err) { finish(null, err); return; } const timer = setTimeout(() => { - killProcessTree(child); + if (killTree) killProcessTree(child); + else { try { child.kill('SIGKILL'); } catch {} } finish(null, new Error(`claude ${claudeArgv[0]} timed out after ${timeout} ms`)); }, timeout); child.stdout.on('data', (d) => { stdout += d.toString(); }); child.stderr.on('data', (d) => { stderr += d.toString(); }); child.on('error', (err) => { clearTimeout(timer); finish(null, err); }); child.on('close', (code) => { clearTimeout(timer); finish(code); }); + // a daemon that inherited the pipes keeps `close` from firing: settle shortly after the client exits + child.on('exit', (code) => { + const grace = setTimeout(() => { clearTimeout(timer); finish(code); }, 1000); + if (typeof grace.unref === 'function') grace.unref(); + }); }); } diff --git a/test/bg-agents.test.js b/test/bg-agents.test.js index 10dc49c4..5b9a38ea 100644 --- a/test/bg-agents.test.js +++ b/test/bg-agents.test.js @@ -11,6 +11,10 @@ const bgAgents = require('../bg-agents'); function mkTmp() { return fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'sw-bg-agents-'))); } +function rmTmp(dir) { + bgAgents.stop(); + fs.rmSync(dir, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 }); +} const silentLog = { info() {}, warn() {}, error() {}, debug() {} }; const delay = (ms) => new Promise(r => setTimeout(r, ms)); function waitFor(fn, maxMs = 4000) { @@ -80,7 +84,7 @@ test('reconcile runs `claude agents --json --all`, merges the job files, and rep assert.deepEqual(snap.roster.map(e => e.id), ['aaaaaaaa', 'bbbbbbbb']); assert.equal(snap.roster[0].detail, 'reading rules'); assert.equal(snap.roster[0].tokens, 42); - } finally { fs.rmSync(dir, { recursive: true, force: true }); } + } finally { rmTmp(dir); } }); test('a state.json rewrite reaches listeners once, coalesced, without another CLI call', async () => { @@ -98,7 +102,7 @@ test('a state.json rewrite reaches listeners once, coalesced, without another CL await delay(bgAgents.FLUSH_MS * 2); assert.deepEqual(seen, ['two']); assert.equal(cli.calls.length, callsBefore, 'a file change never spawns the CLI'); - } finally { fs.rmSync(dir, { recursive: true, force: true }); } + } finally { rmTmp(dir); } }); test('a job directory that appears after start is watched too', async () => { @@ -111,7 +115,7 @@ test('a job directory that appears after start is watched too', async () => { bgAgents.onChange((snap) => seen.push((snap.roster.find(e => e.id === 'bbbbbbbb') || {}).detail)); writeJob(dir, 'bbbbbbbb', { state: 'done', detail: 'late' }); await waitFor(() => seen.includes('late')); - } finally { fs.rmSync(dir, { recursive: true, force: true }); } + } finally { rmTmp(dir); } }); test('an empty state.json (mid-rewrite) keeps the previous value', async () => { @@ -124,7 +128,7 @@ test('an empty state.json (mid-rewrite) keeps the previous value', async () => { fs.writeFileSync(path.join(dir, 'aaaaaaaa', 'state.json'), '', 'utf8'); await delay(bgAgents.FLUSH_MS * 3); assert.equal(bgAgents.getSnapshot().roster[0].detail, 'kept'); - } finally { fs.rmSync(dir, { recursive: true, force: true }); } + } finally { rmTmp(dir); } }); test('a descriptor change rebuilds the roster from readAllDescriptors', async () => { @@ -140,7 +144,7 @@ test('a descriptor change rebuilds the roster from readAllDescriptors', async () descriptors.push({ pid: 30, sessionId: 's-ext', kind: 'interactive', jobId: null, agent: null, name: 'ext', cwd: '/e', status: 'busy', startedAt: 3 }); sessionState.fire(); await waitFor(() => seen.some(s => s.includes('s-ext'))); - } finally { fs.rmSync(dir, { recursive: true, force: true }); } + } finally { rmTmp(dir); } }); test('when the CLI fails the roster comes from the files and the daemon is reported unreachable', async () => { @@ -152,7 +156,7 @@ test('when the CLI fails the roster comes from the files and the daemon is repor const snap = await bgAgents.reconcile(); assert.equal(snap.daemonReachable, false); assert.deepEqual(snap.roster.map(e => e.id), ['cccccccc']); - } finally { fs.rmSync(dir, { recursive: true, force: true }); } + } finally { rmTmp(dir); } }); test('runVerb spawns `claude ` in the session cwd when it exists, then reconciles', async () => { @@ -169,7 +173,7 @@ test('runVerb spawns `claude ` in the session cwd when it exists, the assert.equal(verbCall.opts.cwd, dir); assert.equal(verbCall.opts.timeout, bgAgents.VERB_TIMEOUT_MS); assert.equal(cli.calls[cli.calls.length - 1].argv[0], 'agents', 'a verb is followed by a reconcile'); - } finally { fs.rmSync(dir, { recursive: true, force: true }); } + } finally { rmTmp(dir); } }); test('runVerb runs rm from the home directory, never from the job cwd it may delete; respawn keeps the job cwd', async () => { @@ -186,8 +190,8 @@ test('runVerb runs rm from the home directory, never from the job cwd it may del assert.deepEqual(await bgAgents.runVerb('respawn', 'bbbbbbbb'), { ok: true }); assert.equal(cli.calls.find(c => c.argv[0] === 'respawn').opts.cwd, dir); } finally { - fs.rmSync(dir, { recursive: true, force: true }); - fs.rmSync(home, { recursive: true, force: true }); + rmTmp(dir); + rmTmp(home); } }); @@ -200,7 +204,7 @@ test('runVerb refuses an unknown verb or a malformed id before spawning anything assert.equal((await bgAgents.runVerb('stop', '--all')).ok, false); assert.equal((await bgAgents.runVerb('rm', 'AAAAAAAA')).ok, false); assert.equal(cli.calls.length, 0); - } finally { fs.rmSync(dir, { recursive: true, force: true }); } + } finally { rmTmp(dir); } }); test('runVerb reports the CLI stderr when it fails', async () => { @@ -211,7 +215,7 @@ test('runVerb reports the CLI stderr when it fails', async () => { const r = await bgAgents.runVerb('rm', 'aaaaaaaa'); assert.equal(r.ok, false); assert.equal(r.error, 'boom'); - } finally { fs.rmSync(dir, { recursive: true, force: true }); } + } finally { rmTmp(dir); } }); test('runVerb strips the login-shell job-control noise from the CLI stderr it reports', async () => { @@ -226,7 +230,7 @@ test('runVerb strips the login-shell job-control noise from the CLI stderr it re const r = await bgAgents.runVerb('rm', 'bbbbbbbb'); assert.equal(r.ok, false); assert.equal(r.error, 'Error: no such session'); - } finally { fs.rmSync(dir, { recursive: true, force: true }); } + } finally { rmTmp(dir); } }); test('a reconcile tolerates login-shell noise printed before the JSON list', async () => { @@ -237,7 +241,7 @@ test('a reconcile tolerates login-shell noise printed before the JSON list', asy bgAgents.start(); const snap = await bgAgents.reconcile(); assert.equal(snap.daemonReachable, true); - } finally { fs.rmSync(dir, { recursive: true, force: true }); } + } finally { rmTmp(dir); } }); test('dispatch runs `claude --bg …` in the project directory and returns the printed id', async () => { @@ -253,7 +257,7 @@ test('dispatch runs `claude --bg …` in the project directory and returns the p const missing = await bgAgents.dispatch({ prompt: 'hello', cwd: path.join(dir, 'nope') }); assert.equal(missing.ok, false); assert.match(missing.error, /no longer exists/); - } finally { fs.rmSync(dir, { recursive: true, force: true }); } + } finally { rmTmp(dir); } }); function trackWatchers(t) { @@ -280,7 +284,7 @@ test('stop closes every watcher it opened', async (t) => { assert.equal(open.size, 3); bgAgents.stop(); assert.equal(open.size, 0); - } finally { fs.rmSync(dir, { recursive: true, force: true }); } + } finally { rmTmp(dir); } }); test('a reconcile still running when stop() is called arms nothing and restores nothing', async (t) => { @@ -303,7 +307,7 @@ test('a reconcile still running when stop() is called arms nothing and restores assert.equal(snap.daemonReachable, false); assert.equal(bgAgents.getSnapshot().daemonReachable, false); assert.deepEqual(bgAgents.getSnapshot().roster, []); - } finally { fs.rmSync(dir, { recursive: true, force: true }); } + } finally { rmTmp(dir); } }); for (const liveState of ['working', 'blocked']) { @@ -319,7 +323,7 @@ for (const liveState of ['working', 'blocked']) { assert.equal((await bgAgents.runVerb('respawn', 'aaaaaaaa')).ok, false); assert.equal(cli.calls.length, before); assert.equal((await bgAgents.runVerb('stop', 'aaaaaaaa')).ok, true); - } finally { fs.rmSync(dir, { recursive: true, force: true }); } + } finally { rmTmp(dir); } }); } @@ -360,7 +364,7 @@ test('roster entries carry projectRoot and worktreeRoot: the pattern or the cwd await delay(bgAgents.FLUSH_MS * 2); assert.equal(resolved.length, before, 'a known cwd is not resolved again'); assert.equal(new Set(resolved).size, resolved.length, 'each cwd resolved once'); - } finally { fs.rmSync(dir, { recursive: true, force: true }); } + } finally { rmTmp(dir); } }); test('a resolver that throws or answers nothing leaves the cwd as the root, and is not retried', async () => { @@ -378,5 +382,5 @@ test('a resolver that throws or answers nothing leaves the cwd as the root, and await delay(20); assert.equal(calls, n); assert.equal(bgAgents.getSnapshot().roster.find(e => e.id === 'bbbbbbbb').projectRoot, '/b'); - } finally { fs.rmSync(dir, { recursive: true, force: true }); } + } finally { rmTmp(dir); } }); diff --git a/test/claude-binary.test.js b/test/claude-binary.test.js new file mode 100644 index 00000000..f39acf4a --- /dev/null +++ b/test/claude-binary.test.js @@ -0,0 +1,55 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { resolveWindowsClaude, escapeForCmd } = require('../claude-binary'); + +const NPM = 'C:\\Users\\u\\AppData\\Roaming\\npm'; +const SHIM = '@ECHO off\r\nSET dp0=%~dp0\r\n"%_prog%" "%dp0%\\node_modules\\@anthropic-ai\\claude-code\\cli.js" %*\r\n'; + +function deps(files) { + return { exists: (p) => Object.prototype.hasOwnProperty.call(files, p), readFile: (p) => files[p] }; +} + +test('a claude.exe on PATH is run directly', () => { + const env = { PATH: 'C:\\bin;C:\\tools' }; + const r = resolveWindowsClaude(['agents', '--json'], env, deps({ 'C:\\tools\\claude.exe': '' })); + assert.deepEqual(r, { program: 'C:\\tools\\claude.exe', args: ['agents', '--json'], verbatim: false }); +}); + +test('an npm claude.cmd shim runs node on the cli.js it points to', () => { + const env = { PATH: `${NPM};C:\\node` }; + const files = { + [`${NPM}\\claude.cmd`]: SHIM, + [`${NPM}\\node_modules\\@anthropic-ai\\claude-code\\cli.js`]: '', + 'C:\\node\\node.exe': '', + }; + const r = resolveWindowsClaude(['--bg', 'say "hi" & bye\nline two'], env, deps(files)); + assert.equal(r.program, 'C:\\node\\node.exe'); + assert.deepEqual(r.args, [`${NPM}\\node_modules\\@anthropic-ai\\claude-code\\cli.js`, '--bg', 'say "hi" & bye\nline two']); + assert.equal(r.verbatim, false); +}); + +test('a .cmd shim that cannot be unwrapped goes through cmd.exe with escaped arguments', () => { + const env = { PATH: NPM, ComSpec: 'C:\\Windows\\System32\\cmd.exe' }; + const r = resolveWindowsClaude(['stop', 'aaaaaaaa'], env, deps({ [`${NPM}\\claude.cmd`]: 'unknown shim' })); + assert.equal(r.program, 'C:\\Windows\\System32\\cmd.exe'); + assert.deepEqual(r.args.slice(0, 3), ['/d', '/s', '/c']); + assert.equal(r.verbatim, true); + assert.match(r.args[3], /claude\.cmd/); +}); + +test('through cmd.exe a multi-line argument is refused instead of being cut', () => { + const env = { PATH: NPM }; + const r = resolveWindowsClaude(['--bg', 'a\nb'], env, deps({ [`${NPM}\\claude.cmd`]: 'unknown shim' })); + assert.match(r.error, /multi-line/); +}); + +test('no claude on PATH is reported', () => { + assert.match(resolveWindowsClaude(['agents'], { PATH: 'C:\\bin' }, deps({})).error, /not found/); +}); + +test('escapeForCmd quotes the argument and escapes the cmd metacharacters', () => { + assert.ok(escapeForCmd('a&b').includes('^&')); + assert.ok(!/(^|[^^])&/.test(escapeForCmd('a&b'))); +}); From 930a0e11f3ab31d0be13fa95bf5089de95eb1280 Mon Sep 17 00:00:00 2001 From: pjay Date: Fri, 2 Oct 2026 12:00:51 +0200 Subject: [PATCH 38/38] (agents): find the current npm claude shim, keep root resolution out of the tests Only PATHEXT names are looked up, so npm's extensionless sh shim never wins, and a shim that points to bin/claude.exe runs that exe directly. The bg-agents tests inject a no-op project-root resolver by default, so no git child is still running in a temp dir when it is removed. Claude-Session: https://claude.ai/code/session_01WriJRPX84KSTHVY9wuKWyo --- claude-binary.js | 9 +++++---- test/bg-agents.test.js | 2 +- test/claude-binary.test.js | 15 +++++++++++++++ 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/claude-binary.js b/claude-binary.js index 13d47aeb..bcd0f346 100644 --- a/claude-binary.js +++ b/claude-binary.js @@ -7,7 +7,7 @@ const path = require('path'); const CMD_META = /([()\][%!^"`<>&|;, *?])/g; function findOnPath(name, { pathEnv, pathExt, exists, sep }) { - const exts = ['', ...String(pathExt || '').split(';').filter(Boolean)]; + const exts = String(pathExt || '').split(';').filter(Boolean); for (const dir of String(pathEnv || '').split(sep).filter(Boolean)) { for (const ext of exts) { const candidate = path.win32.join(dir, name + ext.toLowerCase()); @@ -23,8 +23,8 @@ function escapeForCmd(arg) { return s.replace(CMD_META, '^$1').replace(CMD_META, '^$1'); } -// How to run `claude ` on Windows without a shell profile: the .exe itself, the node + cli.js an npm -// .cmd shim points to, or cmd.exe over the shim. Returns { program, args, verbatim } or { error }. +// How to run `claude ` on Windows without a shell profile: the .exe itself, the .exe or node + cli.js an npm +// .cmd shim points to, or cmd.exe over the shim. Only PATHEXT names count: npm also leaves an extensionless sh shim. Returns { program, args, verbatim } or { error }. function resolveWindowsClaude(argv, env = {}, deps = {}) { const exists = deps.exists || ((p) => fs.existsSync(p)); const readFile = deps.readFile || ((p) => fs.readFileSync(p, 'utf8')); @@ -38,10 +38,11 @@ function resolveWindowsClaude(argv, env = {}, deps = {}) { let shim = ''; try { shim = readFile(found); } catch {} - const m = /"%dp0%\\([^"]+\.js)"/i.exec(shim); + const m = /"%dp0%\\([^"]+\.(?:js|exe))"/i.exec(shim); if (m) { const dir = path.win32.dirname(found); const script = path.win32.join(dir, m[1]); + if (/\.exe$/i.test(script) && exists(script)) return { program: script, args: argv, verbatim: false }; const bundled = path.win32.join(dir, 'node.exe'); const node = exists(bundled) ? bundled : findOnPath('node', { pathEnv: env.PATH || env.Path, pathExt: '.EXE', exists, sep: ';', diff --git a/test/bg-agents.test.js b/test/bg-agents.test.js index 5b9a38ea..81527cad 100644 --- a/test/bg-agents.test.js +++ b/test/bg-agents.test.js @@ -61,7 +61,7 @@ function fakeSessionState(descriptors = []) { }; } -function boot(dir, { cli = fakeCli(), sessionState = fakeSessionState(), attached = () => false, homeDir, resolveProjectRoots } = {}) { +function boot(dir, { cli = fakeCli(), sessionState = fakeSessionState(), attached = () => false, homeDir, resolveProjectRoots = async () => null } = {}) { bgAgents.init({ jobsDir: dir, log: silentLog, runClaude: cli.runClaude, cliSessionState: sessionState, homeDir, makeIsOwnPid: () => () => false, isAttachedHere: attached, resolveProjectRoots, diff --git a/test/claude-binary.test.js b/test/claude-binary.test.js index f39acf4a..ee4edd61 100644 --- a/test/claude-binary.test.js +++ b/test/claude-binary.test.js @@ -53,3 +53,18 @@ test('escapeForCmd quotes the argument and escapes the cmd metacharacters', () = assert.ok(escapeForCmd('a&b').includes('^&')); assert.ok(!/(^|[^^])&/.test(escapeForCmd('a&b'))); }); + +test('the current npm shim, which points to bin\claude.exe, runs that exe directly', () => { + const shim = '@ECHO off\r\nSET dp0=%~dp0\r\n"%dp0%\\node_modules\\@anthropic-ai\\claude-code\\bin\\claude.exe" %*\r\n'; + const exe = `${NPM}\\node_modules\\@anthropic-ai\\claude-code\\bin\\claude.exe`; + const r = resolveWindowsClaude(['--bg', 'a\nb'], { PATH: NPM }, deps({ [`${NPM}\\claude.cmd`]: shim, [exe]: '' })); + assert.deepEqual(r, { program: exe, args: ['--bg', 'a\nb'], verbatim: false }); +}); + +test('the extensionless sh shim npm leaves next to claude.cmd is never picked', () => { + const shim = '@ECHO off\r\nSET dp0=%~dp0\r\n"%dp0%\\node_modules\\@anthropic-ai\\claude-code\\bin\\claude.exe" %*\r\n'; + const exe = `${NPM}\\node_modules\\@anthropic-ai\\claude-code\\bin\\claude.exe`; + const files = { [`${NPM}\\claude`]: '#!/bin/sh', [`${NPM}\\claude.cmd`]: shim, [exe]: '' }; + const r = resolveWindowsClaude(['agents'], { PATH: NPM }, deps(files)); + assert.equal(r.program, exe); +});