diff --git a/.ai/contexts/README.md b/.ai/contexts/README.md index 44f49992..c2c93fca 100644 --- a/.ai/contexts/README.md +++ b/.ai/contexts/README.md @@ -22,6 +22,7 @@ without re-reading `main.js`, now ~2600 LOC. | Paths in terminal output becoming links: the matcher, the openability check, `path:line` | [terminal-path-links](terminal-path-links.md) | | What the right mouse button does in a terminal: the four modes, and keeping the press from the application | [terminal-right-click](terminal-right-click.md) | | The frameless window: the strip that replaces the title bar, its drag regions, the window controls, the menu's accelerators | [window-frame](window-frame.md) | +| The Agents view, the daemon's job files, attach/detach, dispatch | [bg-agents](bg-agents.md) | ## Reading order for a new contributor (~30 min) diff --git a/.ai/contexts/bg-agents.md b/.ai/contexts/bg-agents.md new file mode 100644 index 00000000..ce5c5b9a --- /dev/null +++ b/.ai/contexts/bg-agents.md @@ -0,0 +1,419 @@ +# 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. User doc: +`docs/background-agents.md`. + +## Key files + +| File | Role | +|---|---| +| `bg-agents-roster.js` | Pure: `parseJobState`, `parseCliList`, `mergeRoster`, `dispatchArgs`, `parseDispatchOutput`, `JOB_STATES`, `JOB_ID_RE` | +| `bg-agents.js` | Watchers over `~/.claude/jobs/*/state.json`, descriptor subscription, `reconcile()` through `claude agents --json --all`, `runVerb`, `dispatch`, `onChange`; `projectRoot`/`worktreeRoot` on every entry, cached | +| `project-root.js` | `resolveProjectRoots(cwd)`: the `.claude/worktrees` pattern, else one `git rev-parse`; never throws | +| `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`; `bgAgents.init` and `bg-agents-ipc` wiring; `bgAgents.stop()` in the window's `closed` handler | +| `public/agents-view.js` | The view; `agentJobIsLive`; `groupAgentEntries` (Group by); `bgAgentSessionIds` for the sidebar badge | +| `public/resume-guard.js` | A live `kind: 'bg'` descriptor answers `{ attach, cwd }` | +| `public/dialogs.js` | `showDispatchAgentDialog` | +| `public/shortcuts.js` | The rebindable `agentsToggle` (default Primary+Shift+`A`) | + +## Invariants + +1. Never `--resume` or `--fork-session` a live job. A job is live when its + state is `working` or `blocked` (see "Job states"). `claude attach` is the + only path to a live job (`guardResume` turns a `bg` verdict into attach + options; `open-terminal` builds `claude attach`). Nothing ever resumes one. +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. +4. No steady-state cost before the view is first opened: the watchers are + armed by the first `get-bg-agents`. Closing the view keeps them 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`. +7. The attach options are exactly `{ type: 'attach', jobId, cwd }`. Never + `sandbox`, `preLaunchCmd` or MCP emulation: the attach branch of + `open-terminal` skips them, and the client only talks to the daemon. + +## Job states + +`JOB_STATES` in `bg-agents-roster.js` is `working`, `blocked`, `done`, +`stopped`, `failed`. `blocked` is a live job waiting on input (observed on CLI +2.1.285, 2026-09-30; the first design listed three states). `failed` is a job +that ended in error (observed on CLI 2.1.285, 2026-10-01, two jobs); before it +was in the set such a job parsed as `null` and its row read `?`. `failed` is +finished, like `done` and `stopped`: not live, hidden by the Finished filter, +counted as finished, Respawn and Delete enabled. `agentJobIsLive` +(`public/agents-view.js`) — `working` or `blocked` on a background entry — is +the single live predicate in the renderer, so every state outside those two +is finished without a list to keep in sync. + +| Verb | Live job | Not live | +|---|---|---| +| Attach | allowed | disabled (a finished session resumes like any other) | +| Stop | allowed | disabled | +| Respawn | refused | allowed | +| Delete (`rm`) | refused | allowed | + +The UI disables the buttons (`agentVerbAvailability`); `runVerb` refuses +`respawn`/`rm` on a live roster entry with the same rule, so a stale renderer +cannot bypass it. A blocked row draws as waiting (orange), not as a spinner. + +## 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 and +dispatch. The push `bg-agents-changed` carries `{roster, daemonReachable}`. + +### Lifecycle + +- `reconcile()` is a no-op until `start()`: it returns the current (empty) + snapshot without spawning the CLI. +- The first `get-bg-agents` calls `start()`, which arms the jobs-directory + watcher, one watcher per job directory, and the descriptor subscription. + Later calls find `started` already true and only reconcile. +- Every `get-bg-agents` also re-subscribes the `bg-agents-changed` push + (`subscribe()` in `bg-agents-ipc.js`). `stop()` clears the listener set; on + macOS the app outlives its window, the `closed` handler calls `stop()`, and + the window created next would otherwise never receive an update. +- A `reconcile()` in flight when `stop()` runs is dropped: `stop()` bumps + `generation`, and the reconcile compares the value it captured before + awaiting the CLI and returns without touching the (now reset) state. +- `bgAgents.init()` calls `stop()` first, so a re-init starts clean. + +## 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 })`, as do the + Agent Files / Work Files tabs, Settings and the grid; only the toggle + restores the terminal area. +- The view persists `localStorage.agentsViewActive` (`'1'`/`'0'`). + `initAgentsView()` reads it into `agentsOpenAtStartup`; `app.js` calls + `restoreAgentsViewAtStartup()` after the working set is restored, so the + view is shown once the restored sessions are open. + `agentsShowFinished` persists under `localStorage.agentsShowFinished` + (`'0'` hides finished sessions); the grouping under + `localStorage.agentsGroupBy` (see "Group by"). +- `claude --bg` prints its id wrapped in ANSI colour codes (see "Measured + facts"); `parseDispatchOutput` takes the first standalone eight-hex token, + which the colour codes do not hide, and `dispatch` reports + `ok: true, id: null` when there is none — the row then arrives through the + files. +- `claude agents --json --all` runs through an interactive login shell + (`runClaudeCommand`), so rc files may print to stdout around the JSON. + `parseCliList` first tries a strict parse, then the candidate arrays that + start with `[` at the start of a line and end with `]` at the end of a line + (at most 32 of each), and returns `null` when none parses. A verb's error + text goes through `stripShellNoise`, which drops only the leading + `bash: no job control in this shell` / `cannot set terminal process group` + lines; the CLI's own stderr is kept verbatim. +- `claude rm` runs from the home directory, never from the job's cwd: that + directory may be the worktree `rm` deletes, and Windows refuses to remove a + live process's cwd. `stop` and `respawn` run in the job cwd (respawn's brief + needs it), falling back to home when it no longer exists. +- An attach tab's `cli-session-state` status comes from the daemon worker's + descriptor (same `sessionId`), so busy/idle needs no special path. +- When a row is attached here and the user runs Stop or Delete on it, the + renderer stops the local attach pty first (`stopSession`), so the client + does not outlive the job. +- Narrow widths: a row's grid columns add up to ~670 px of minimum width. + Without `min-width: 0` on `#main` that minimum became `#main`'s own, so + with a narrow window or a wide sidebar `#main` ran past the window edge + and the header's right end (New agent) was clipped by `body`'s + `overflow: hidden`. `#main` now shrinks to the space left, the list + scrolls sideways, and the header wraps its controls (`flex-wrap`, New + agent `flex-shrink: 0`) onto extra rows. +- Dispatch dialog height: it reuses `.new-session-dialog`, which has no + height limit, so on a short screen the bottom (Start / Cancel) left the + window. The dispatch dialog adds `dispatch-agent-dialog` + (`max-height: calc(100vh - 32px)`, `overflow-y: auto`) and scrolls inside + the window. The class is scoped on purpose: the other dialogs share + `.new-session-dialog` and may hold popovers that an `overflow` would clip. +- Window controls: the frameless window draws the system buttons over the + top-right corner, and `#agents-viewer-header` is the top row of `#main`. + It is in the `window-frameless` header lists of `style.css` (right inset + `--strip-inset-right`, left inset for a collapsed sidebar), and its labels + are `no-drag`, so New agent stays clear of the controls and a click on + "Finished" or "Group" does not start a window drag. A new view that + reaches the top of `#main` must join those lists; see + [window-frame.md](window-frame.md). + +## Group by + +The header's `#agents-group-by` select sets `agentsGroupBy` (`none`, `state`, +`project`), persisted under `localStorage.agentsGroupBy`. `readAgentsGroupBy` +wraps the read in try/catch and `normalizeAgentsGroupBy` maps anything else +(unset included) to `state`, the default; an explicitly stored `none` or +`project` is kept. The default is not written back, so it only lands in +storage once the user picks something. The write is wrapped too, so a storage +that throws still lets the choice apply for the session. The ` Finished + + + + +
+ + +`; + +function evalFile(dom, file) { + vm.runInContext(fs.readFileSync(file, 'utf8'), dom.getInternalVMContext(), { filename: file }); +} + +function setup({ storage = { agentsGroupBy: 'none' }, settingsPanel = false } = {}) { + const dom = new JSDOM(HTML, { url: 'http://localhost/', runScripts: 'outside-only', pretendToBeVisual: true }); + const { window } = dom; + for (const [k, v] of Object.entries(storage)) window.localStorage.setItem(k, v); + 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 }; }, + readMemory: async () => 'memory text', + readWorkFile: async () => 'work file text', + getSetting: async () => ({}), + setSetting: async () => ({ ok: true }), + getShellProfiles: async () => [], + getAppVersion: async () => '0.0.0', + onUpdaterEvent: () => {}, + platform: 'linux', + }; + 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'), + memoryContent: window.document.getElementById('memory-content'), + workFilesContent: window.document.getElementById('work-files-content'), + CSS: { escape: (s) => String(s).replace(/["\\]/g, '\\$&') }, + memoryPanel: { open: () => {} }, + workFilesPanel: { open: () => {} }, + 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')); + if (settingsPanel) { + evalFile(dom, path.join(PUBLIC, 'setting-defaults.js')); + evalFile(dom, path.join(PUBLIC, 'shortcuts.js')); + evalFile(dom, path.join(PUBLIC, 'terminal-themes.js')); + evalFile(dom, path.join(PUBLIC, 'settings-panel.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 puts only the live jobs in 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']); + assert.equal(ctx.calls.sidebarRefreshes, 1); + ctx.emitChanged({ roster: ROSTER, daemonReachable: true }); + assert.equal(ctx.calls.sidebarRefreshes, 1); + ctx.emitChanged({ roster: [ROSTER[0], { ...ROSTER[1], state: 'blocked' }, ROSTER[2]], daemonReachable: true }); + assert.deepEqual([...ctx.read('bgAgentSessionIds')].sort(), ['s-a', 's-b']); + assert.equal(ctx.calls.sidebarRefreshes, 2); + ctx.emitChanged({ roster: [{ ...ROSTER[0], state: 'done' }, ROSTER[1], ROSTER[2]], daemonReachable: true }); + assert.deepEqual([...ctx.read('bgAgentSessionIds')], []); + assert.equal(ctx.calls.sidebarRefreshes, 3); +}); + +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' }); +}); + +test('a failed job reads "failed", counts as finished and is hidden by the Finished filter', async (t) => { + const ctx = setup(); t.after(() => ctx.destroy()); + const failed = { ...ROSTER[1], id: 'ffffffff', sessionId: 's-f', name: 'broke', state: 'failed' }; + ctx.setSnapshot({ roster: [...ROSTER, failed], daemonReachable: true }); + await ctx.window.showAgentsView(); + const row = ctx.document.querySelector('.agents-row[data-key="bg:ffffffff"]'); + assert.equal(row.querySelector('.agents-row-state').textContent, '❌ failed'); + assert.equal(ctx.document.getElementById('agents-viewer-count').textContent, '1 running · 2 finished'); + row.click(); + const detail = ctx.document.getElementById('agents-detail'); + assert.equal(detail.querySelector('[data-verb="rm"]').disabled, false); + assert.equal(detail.querySelector('[data-verb="respawn"]').disabled, false); + assert.equal(detail.querySelector('[data-verb="attach"]').disabled, true); + const box = ctx.document.getElementById('agents-show-finished'); + box.checked = false; + box.dispatchEvent(new ctx.window.Event('change', { bubbles: true })); + assert.equal(ctx.document.querySelector('.agents-row[data-key="bg:ffffffff"]'), null); +}); + +function chooseGroupBy(ctx, mode) { + const sel = ctx.document.getElementById('agents-group-by'); + sel.value = mode; + sel.dispatchEvent(new ctx.window.Event('change', { bubbles: true })); +} + +function headText(el) { + return el.textContent.replace(/^[▾▸] /, ''); +} + +function listLayout(ctx) { + return [...ctx.document.getElementById('agents-list').children].map(el => + el.classList.contains('agents-group-header') ? '# ' + headText(el) + : el.classList.contains('agents-subgroup-header') ? '## ' + headText(el) + : el.querySelector('.agents-row-name').textContent); +} + +test('group by state: a header per non-empty group with its count, each row once, no header without the mode', async (t) => { + const ctx = setup(); t.after(() => ctx.destroy()); + const failed = { ...ROSTER[1], id: 'ffffffff', sessionId: 's-f', name: 'broke', state: 'failed' }; + ctx.setSnapshot({ roster: [...ROSTER, failed], daemonReachable: true }); + await ctx.window.showAgentsView(); + assert.equal(ctx.document.querySelectorAll('.agents-group-header').length, 0); + chooseGroupBy(ctx, 'state'); + assert.deepEqual(listLayout(ctx), ['# ⚙️ Working · 1', 'em-platform', '# ✅ Done · 1', 'spike', '# ❌ Failed · 1', 'broke', '# 🖥️ External · 1', 'lvds-1b']); + assert.equal(ctx.document.querySelectorAll('.agents-row').length, 4); + assert.equal(ctx.window.localStorage.getItem('agentsGroupBy'), 'state'); + chooseGroupBy(ctx, 'none'); + assert.deepEqual(listLayout(ctx), ['lvds-1b', 'em-platform', 'spike', 'broke']); +}); + +test('group by project: headers labelled by the last segment with the full path as title, live groups first', async (t) => { + const ctx = setup(); t.after(() => ctx.destroy()); + const second = { ...ROSTER[0], id: 'cccccccc', sessionId: 's-c', name: 'em-two', state: 'done', startedAt: Date.now() - 120_000 }; + const homeless = { ...ROSTER[1], id: 'dddddddd', sessionId: 's-d', name: 'nowhere', cwd: null }; + ctx.setSnapshot({ roster: [...ROSTER, second, homeless], daemonReachable: true }); + await ctx.window.showAgentsView(); + chooseGroupBy(ctx, 'project'); + assert.deepEqual(listLayout(ctx), ['# em · 2', 'em-platform', 'em-two', '# l · 1', 'lvds-1b', '# f · 1', 'spike', '# No project · 1', 'nowhere']); + const head = ctx.document.querySelector('.agents-group-header'); + assert.equal(head.getAttribute('title'), '/w/em'); + assert.equal(ctx.window.localStorage.getItem('agentsGroupBy'), 'project'); +}); + +test('the Finished filter applies before grouping: a group left empty is not shown', async (t) => { + const ctx = setup(); t.after(() => ctx.destroy()); + ctx.setSnapshot({ roster: ROSTER, daemonReachable: true }); + await ctx.window.showAgentsView(); + chooseGroupBy(ctx, 'state'); + const box = ctx.document.getElementById('agents-show-finished'); + box.checked = false; + box.dispatchEvent(new ctx.window.Event('change', { bubbles: true })); + assert.deepEqual(listLayout(ctx), ['# ⚙️ Working · 1', 'em-platform', '# 🖥️ External · 1', 'lvds-1b']); + ctx.emitChanged({ roster: [], daemonReachable: true }); + assert.equal(ctx.document.querySelectorAll('.agents-group-header').length, 0); + assert.match(ctx.document.getElementById('agents-list').textContent, /No background agents/); +}); + +test('the selected row stays selected across a regroup, and a click on a header toggles it without selecting a row', 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:bbbbbbbb"]').click(); + chooseGroupBy(ctx, 'project'); + assert.ok(ctx.document.querySelector('.agents-row[data-key="bg:bbbbbbbb"]').classList.contains('selected')); + chooseGroupBy(ctx, 'state'); + assert.ok(ctx.document.querySelector('.agents-row[data-key="bg:bbbbbbbb"]').classList.contains('selected')); + ctx.document.querySelector('.agents-group-header').click(); + assert.equal(ctx.document.querySelector('.agents-group-header').getAttribute('aria-expanded'), 'false'); + assert.equal(ctx.read('agentsSelectedKey'), 'bg:bbbbbbbb'); + assert.equal(ctx.document.querySelectorAll('.agents-row.selected').length, 1); + assert.match(ctx.document.getElementById('agents-detail').textContent, /spike/); + assert.equal(ctx.calls.verbs.length, 0); +}); + +function header(ctx, collapseKey) { + return [...ctx.document.querySelectorAll('[data-collapse]')].find(h => h.dataset.collapse === collapseKey); +} + +function storedCollapsed(ctx) { + return JSON.parse(ctx.window.localStorage.getItem('agentsCollapsedGroups') || '[]'); +} + +test('collapse: a header click folds its rows, keeps label and count, flips the chevron and aria-expanded, and is remembered', async (t) => { + const ctx = setup({ storage: {} }); t.after(() => ctx.destroy()); + ctx.setSnapshot({ roster: ROSTER, daemonReachable: true }); + await ctx.window.showAgentsView(); + const done = header(ctx, 'state:done'); + assert.equal(done.getAttribute('role'), 'button'); + assert.equal(done.getAttribute('tabindex'), '0'); + assert.equal(done.getAttribute('aria-expanded'), 'true'); + assert.equal(done.querySelector('.agents-group-chevron').textContent, '▾'); + assert.equal(done.querySelector('.agents-group-chevron').getAttribute('aria-hidden'), 'true'); + done.click(); + assert.deepEqual(listLayout(ctx), ['# ⚙️ Working · 1', 'em-platform', '# ✅ Done · 1', '# 🖥️ External · 1', 'lvds-1b']); + const folded = header(ctx, 'state:done'); + assert.equal(folded.getAttribute('aria-expanded'), 'false'); + assert.equal(folded.querySelector('.agents-group-chevron').textContent, '▸'); + assert.deepEqual(storedCollapsed(ctx), ['state:done']); + folded.click(); + assert.deepEqual(listLayout(ctx), ['# ⚙️ Working · 1', 'em-platform', '# ✅ Done · 1', 'spike', '# 🖥️ External · 1', 'lvds-1b']); + assert.deepEqual(storedCollapsed(ctx), []); +}); + +test('collapse: Enter and Space on a focused header toggle it', async (t) => { + const ctx = setup({ storage: {} }); t.after(() => ctx.destroy()); + ctx.setSnapshot({ roster: ROSTER, daemonReachable: true }); + await ctx.window.showAgentsView(); + const key = (k) => { + const ev = new ctx.window.KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true }); + header(ctx, 'state:working').dispatchEvent(ev); + return ev; + }; + const ev = key('Enter'); + assert.equal(ev.defaultPrevented, true); + assert.equal(header(ctx, 'state:working').getAttribute('aria-expanded'), 'false'); + key(' '); + assert.equal(header(ctx, 'state:working').getAttribute('aria-expanded'), 'true'); + key('a'); + assert.equal(header(ctx, 'state:working').getAttribute('aria-expanded'), 'true'); +}); + +test('collapse: restored from storage, survives a roster push, a mode switch and the Finished filter; selection kept', async (t) => { + const ctx = setup({ storage: { agentsCollapsedGroups: '["state:done"]' } }); t.after(() => ctx.destroy()); + ctx.setSnapshot({ roster: ROSTER, daemonReachable: true }); + await ctx.window.showAgentsView(); + assert.equal(ctx.document.querySelector('.agents-row[data-key="bg:bbbbbbbb"]'), null); + assert.equal(header(ctx, 'state:done').textContent.includes('· 1'), true); + ctx.emitChanged({ roster: [...ROSTER], daemonReachable: true }); + assert.equal(ctx.document.querySelector('.agents-row[data-key="bg:bbbbbbbb"]'), null); + chooseGroupBy(ctx, 'project'); + assert.ok(ctx.document.querySelector('.agents-row[data-key="bg:bbbbbbbb"]'), 'state:done does not fold a project'); + ctx.document.querySelector('.agents-row[data-key="bg:aaaaaaaa"]').click(); + header(ctx, 'project:/w/em').click(); + assert.equal(ctx.document.querySelector('.agents-row[data-key="bg:aaaaaaaa"]'), null); + assert.equal(ctx.read('agentsSelectedKey'), 'bg:aaaaaaaa'); + assert.match(ctx.document.getElementById('agents-detail').textContent, /em-platform/); + ctx.emitChanged({ roster: [{ ...ROSTER[0], detail: 'pushed' }, ROSTER[1], ROSTER[2]], daemonReachable: true }); + assert.equal(ctx.read('agentsSelectedKey'), 'bg:aaaaaaaa'); + assert.match(ctx.document.getElementById('agents-detail').textContent, /pushed/); + chooseGroupBy(ctx, 'state'); + assert.equal(ctx.document.querySelector('.agents-row[data-key="bg:bbbbbbbb"]'), null); + const box = ctx.document.getElementById('agents-show-finished'); + box.checked = false; + box.dispatchEvent(new ctx.window.Event('change', { bubbles: true })); + box.checked = true; + box.dispatchEvent(new ctx.window.Event('change', { bubbles: true })); + assert.equal(header(ctx, 'state:done').getAttribute('aria-expanded'), 'false'); + assert.deepEqual(storedCollapsed(ctx).sort(), ['project:/w/em', 'state:done']); +}); + +test('collapse: invalid stored value collapses nothing; a throwing storage still toggles', async (t) => { + for (const bad of ['{nope', '{"a":1}', '42']) { + const ctx = setup({ storage: { agentsCollapsedGroups: bad } }); t.after(() => ctx.destroy()); + ctx.setSnapshot({ roster: ROSTER, daemonReachable: true }); + await ctx.window.showAgentsView(); + assert.equal(ctx.document.querySelectorAll('[aria-expanded="false"]').length, 0, bad); + assert.equal(ctx.document.querySelectorAll('.agents-row').length, 3, bad); + } + const ctx = setup({ storage: {} }); t.after(() => ctx.destroy()); + ctx.setSnapshot({ roster: ROSTER, daemonReachable: true }); + await ctx.window.showAgentsView(); + Object.defineProperty(ctx.window, 'localStorage', { configurable: true, get() { throw new Error('denied'); } }); + header(ctx, 'state:done').click(); + assert.equal(header(ctx, 'state:done').getAttribute('aria-expanded'), 'false'); +}); + +test('collapse: a project folds its worktree sub-groups; a sub-group folds only its rows', async (t) => { + const ctx = setup({ storage: { agentsGroupBy: 'project' } }); t.after(() => ctx.destroy()); + ctx.setSnapshot({ roster: APP_ROSTER, daemonReachable: true }); + await ctx.window.showAgentsView(); + const subKey = 'worktree:' + APP + '|' + APP + '/.claude/worktrees/x'; + const sub = header(ctx, subKey); + assert.ok(sub && sub.classList.contains('agents-subgroup-header')); + assert.equal(sub.getAttribute('role'), 'button'); + sub.click(); + assert.deepEqual(listLayout(ctx), ['# app · 2', '## main · 1', 'on-main', '## x · 1', '# other · 1', 'other-repo']); + header(ctx, 'project:' + APP).click(); + assert.deepEqual(listLayout(ctx), ['# app · 2', '# other · 1', 'other-repo']); + header(ctx, 'project:' + APP).click(); + assert.deepEqual(listLayout(ctx), ['# app · 2', '## main · 1', 'on-main', '## x · 1', '# other · 1', 'other-repo']); + toggleWorktrees(ctx, false); + assert.deepEqual(listLayout(ctx), ['# app · 2', 'on-main', 'in-wt', '# other · 1', 'other-repo']); +}); + +test('collapse: quotes and class= payloads in a project path used as a collapse key inject nothing and round-trip', async (t) => { + const ctx = setup({ storage: { agentsGroupBy: 'project' } }); t.after(() => ctx.destroy()); + const payload = 'x" class="agents-row" data-key="bg:aaaaaaaa" data-verb="stop" y=\'z'; + const root = '/w/' + payload; + ctx.setSnapshot({ roster: [{ ...ROSTER[1], id: 'cccccccc', cwd: root, projectRoot: root, worktreeRoot: root }], daemonReachable: true }); + await ctx.window.showAgentsView(); + const head = header(ctx, 'project:' + root); + assert.ok(head); + assert.equal(head.className, 'agents-group-header'); + assert.equal(head.hasAttribute('data-verb'), false); + assert.equal(head.hasAttribute('data-key'), false); + head.click(); + assert.equal(ctx.read('agentsSelectedKey'), null); + assert.equal(ctx.calls.verbs.length, 0); + assert.deepEqual(storedCollapsed(ctx), ['project:' + root]); + assert.equal(ctx.document.querySelectorAll('.agents-row').length, 0); + const again = setup({ storage: { agentsGroupBy: 'project', agentsCollapsedGroups: ctx.window.localStorage.getItem('agentsCollapsedGroups') } }); + t.after(() => again.destroy()); + again.setSnapshot({ roster: [{ ...ROSTER[1], id: 'cccccccc', cwd: root, projectRoot: root, worktreeRoot: root }], daemonReachable: true }); + await again.window.showAgentsView(); + assert.equal(header(again, 'project:' + root).getAttribute('aria-expanded'), 'false'); +}); + +test('collapse: the header looks clickable, focusable and does not select text', () => { + const css = fs.readFileSync(path.join(PUBLIC, 'style.css'), 'utf8'); + assert.match(css, /\.agents-group-header,\s*\.agents-subgroup-header\s*\{[^}]*cursor:\s*pointer[^}]*user-select:\s*none/); + assert.match(css, /\.agents-group-header:focus-visible/); +}); + +test('the grouping is restored from storage; an invalid stored value falls back to state', async (t) => { + const ctx = setup({ storage: { agentsGroupBy: 'project' } }); t.after(() => ctx.destroy()); + ctx.setSnapshot({ roster: ROSTER, daemonReachable: true }); + await ctx.window.showAgentsView(); + assert.equal(ctx.document.getElementById('agents-group-by').value, 'project'); + assert.equal(ctx.document.querySelectorAll('.agents-group-header').length, 3); + const bad = setup({ storage: { agentsGroupBy: 'evil" onclick="x' } }); t.after(() => bad.destroy()); + bad.setSnapshot({ roster: ROSTER, daemonReachable: true }); + await bad.window.showAgentsView(); + assert.equal(bad.document.getElementById('agents-group-by').value, 'state'); + assert.equal(bad.document.querySelectorAll('.agents-group-header').length, 3); + assert.equal(bad.read('agentsGroupBy'), 'state'); +}); + +test('a throwing localStorage leaves the grouping at state and choosing one still works', async (t) => { + const ctx = setup(); t.after(() => ctx.destroy()); + ctx.setSnapshot({ roster: ROSTER, daemonReachable: true }); + await ctx.window.showAgentsView(); + Object.defineProperty(ctx.window, 'localStorage', { configurable: true, get() { throw new Error('denied'); } }); + assert.equal(ctx.window.readAgentsGroupBy(), 'state'); + chooseGroupBy(ctx, 'project'); + assert.equal(ctx.read('agentsGroupBy'), 'project'); + assert.equal(ctx.document.querySelectorAll('.agents-group-header').length, 3); +}); + +test('quotes and attribute payloads in a cwd used as a project group title cannot inject attributes', async (t) => { + const ctx = setup(); t.after(() => ctx.destroy()); + const payload = 'x" class="agents-verb-btn" data-verb="stop" y=\'z'; + const evil = { ...ROSTER[1], id: 'cccccccc', cwd: '/w/' + payload }; + ctx.setSnapshot({ roster: [evil], daemonReachable: true }); + await ctx.window.showAgentsView(); + chooseGroupBy(ctx, 'project'); + const head = ctx.document.querySelector('.agents-group-header'); + assert.equal(head.getAttribute('title'), '/w/' + payload); + assert.equal(head.className, 'agents-group-header'); + assert.equal(head.hasAttribute('data-verb'), false); + assert.equal(head.hasAttribute('y'), false); + assert.equal(headText(head), payload + ' · 1'); + assert.equal(ctx.document.querySelectorAll('[data-verb]').length, 0); + head.click(); + assert.equal(ctx.calls.verbs.length, 0); +}); + +test('with nothing stored the list is grouped by state, headers carrying emoji, label and count', async (t) => { + const ctx = setup({ storage: {} }); t.after(() => ctx.destroy()); + const blocked = { ...ROSTER[0], id: 'cccccccc', sessionId: 's-c', name: 'asks', state: 'blocked', startedAt: Date.now() - 5_000 }; + const stopped = { ...ROSTER[1], id: 'dddddddd', sessionId: 's-d', name: 'halted', state: 'stopped' }; + const odd = { ...ROSTER[1], id: 'eeeeeeee', sessionId: 's-e', name: 'odd', state: null }; + ctx.setSnapshot({ roster: [...ROSTER, blocked, stopped, odd], daemonReachable: true }); + await ctx.window.showAgentsView(); + assert.equal(ctx.read('agentsGroupBy'), 'state'); + assert.equal(ctx.document.getElementById('agents-group-by').value, 'state'); + assert.deepEqual(listLayout(ctx), ['# ⚙️ Working · 1', 'em-platform', '# ✋ Blocked · 1', 'asks', '# ✅ Done · 1', 'spike', + '# ⏹️ Stopped · 1', 'halted', '# 🖥️ External · 1', 'lvds-1b', '# ❓ Unknown · 1', 'odd']); + const emoji = ctx.document.querySelector('.agents-group-header .agents-group-emoji'); + assert.equal(emoji.getAttribute('aria-hidden'), 'true'); + assert.equal(emoji.textContent, '⚙️'); + assert.equal(ctx.document.querySelector('.agents-group-header .agents-group-label').textContent, 'Working'); + assert.equal(ctx.window.localStorage.getItem('agentsGroupBy'), null, 'the default is not written back'); +}); + +test('a stored none or project is respected over the state default', async (t) => { + for (const mode of ['none', 'project']) { + const ctx = setup({ storage: { agentsGroupBy: mode } }); t.after(() => ctx.destroy()); + ctx.setSnapshot({ roster: ROSTER, daemonReachable: true }); + await ctx.window.showAgentsView(); + assert.equal(ctx.document.getElementById('agents-group-by').value, mode); + assert.equal(ctx.document.querySelectorAll('.agents-group-header').length, mode === 'none' ? 0 : 3); + assert.equal(ctx.document.querySelectorAll('.agents-group-emoji').length, 0); + } +}); + +test('every row starts its state column with its state emoji in every grouping mode', async (t) => { + const ctx = setup(); t.after(() => ctx.destroy()); + const failed = { ...ROSTER[1], id: 'ffffffff', sessionId: 's-f', name: 'broke', state: 'failed' }; + const odd = { ...ROSTER[1], id: 'eeeeeeee', sessionId: 's-e', name: 'odd', state: null }; + ctx.setSnapshot({ roster: [...ROSTER, failed, odd], daemonReachable: true }); + await ctx.window.showAgentsView(); + const expected = { 'em-platform': '⚙️ working · idle', spike: '✅ done', 'lvds-1b': '🖥️ external · busy', broke: '❌ failed', odd: '❓ ?' }; + for (const mode of ['none', 'state', 'project']) { + chooseGroupBy(ctx, mode); + const got = Object.fromEntries([...ctx.document.querySelectorAll('.agents-row')].map(r => + [r.querySelector('.agents-row-name').textContent, r.querySelector('.agents-row-state').textContent])); + assert.deepEqual(got, expected, mode); + assert.equal(ctx.document.querySelectorAll('.agents-row .agents-state-emoji[aria-hidden="true"]').length, 5, mode); + assert.equal(ctx.document.querySelectorAll('.agents-row .session-icon').length, 5, mode); + } +}); + +test('the group header is styled larger and semi-bold, its count secondary', () => { + const css = fs.readFileSync(path.join(PUBLIC, 'style.css'), 'utf8'); + const rule = (sel) => { + const m = css.match(new RegExp('(^|\\n)' + sel.replace(/[.#-]/g, '\\$&') + '\\s*\\{([^}]*)\\}')); + return m ? m[2] : ''; + }; + const head = rule('.agents-group-header'); + const size = head.match(/font-size:\s*([\d.]+)em/); + assert.ok(size && Number(size[1]) >= 1.15, 'header font-size of at least 1.15em'); + assert.match(head, /font-weight:\s*600/); + assert.match(head, /padding:\s*\d+px/); + assert.match(rule('.agents-group-count'), /font-weight:\s*400/); + const html = fs.readFileSync(path.join(PUBLIC, 'index.html'), 'utf8'); + assert.match(html, /