diff --git a/README.md b/README.md index b8465b0..7bbe525 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ See what needs attention, resume the right conversation, and work on a paired PC -> **1.48.0** makes DevDeck lighter at rest — a quiet terminal is no longer re-scanned every second, the process listing that detects a tile's agent runs only after that tile printed something, git is re-read every two minutes for tiles you are not looking at, and the background-session listing slows to a minute when the group is empty and two when the window is not focused ([release notes](docs/releases/1.48.0.md)). 1.47.1 makes closing a Claude Code background session from the sidebar work when Claude Code's background service is not running — DevDeck starts it as Claude Code does and tries again, and a failure names the PC the command has to run on ([release notes](docs/releases/1.47.1.md)). 1.47.0 moves to Electron 44 (Chromium 152, Node.js 24); macOS 13 or later is now required, and screenshot paste / image copy use Electron's new clipboard API ([release notes](docs/releases/1.47.0.md)). 1.46.0 lets you close Claude Code's background sessions from the sidebar — the row's ⋯ menu or the group's bulk close runs `claude stop` and `claude rm` for you, so a finished or failed session no longer sits in the Background group forever; its conversation is kept ([release notes](docs/releases/1.46.0.md)). 1.45.2 stops an update from doubling the sessions on a paired PC — the relaunch no longer restores remote tiles itself, it adopts them from that PC, and no restore starts a second conversation for one that is already open here ([release notes](docs/releases/1.45.2.md)). 1.45.1 stops offering Claude background sessions whose folder was deleted, and says so plainly when a session's folder is gone ([release notes](docs/releases/1.45.1.md)). 1.45.0 sends any file to a session on another PC — any kind, up to 4 GB, in pieces with its progress shown ([release notes](docs/releases/1.45.0.md)). 1.44.1 fixes the "Assertion failed: remove_pty_baton" dialog (and the occasional hang or crash) when quitting or running Update all with several sessions open ([release notes](docs/releases/1.44.1.md)). 1.44.0 lists Claude Code's own background sessions (`claude --bg`, the agent view) in the sidebar, marks the ones waiting for you, and opens one in a tile by attaching to it ([release notes](docs/releases/1.44.0.md)). 1.43.0 runs parallel sessions in separate workspaces — each in its own git worktree and branch, so two agents never edit the same files — with its changes in the header and a clean-up that warns before unmerged work is lost ([release notes](docs/releases/1.43.0.md)). 1.42.0 shows what changed — a What's new window after each update, with Show me, and NEW dots on new buttons until you try them — and, as an experimental setting on Windows, lets Claude Code report its own state (working, needs approval, asking, done) instead of DevDeck reading the screen ([release notes](docs/releases/1.42.0.md)). 1.41.0 adds quick replies (one click or Ctrl+Shift+1…9 for the phrases you keep typing), a session timeline of your prompts with their times, replies and images, red boxes and arrows drawn on a screenshot and sent straight back to the session, and text, log and PDF attachments for sessions on another PC ([release notes](docs/releases/1.41.0.md)). 1.40.0 ties each screenshot to the terminal row it appeared on — hover a highlighted `[Image]` for a thumbnail, click for the full view, and jump from the viewer back to the row — says what led to each image, previews new screenshots, copies images, and adds Ctrl+↑/↓ between your prompts ([release notes](docs/releases/1.40.0.md)). 1.39.0 shows the screenshots and images a Claude session worked with — Claude Desktop shows them inline; DevDeck puts them in a strip beside the terminal, also for sessions on another PC — adds 📎 / drag-and-drop attachments (images travel to a remote PC), **Update all** for Claude Code on Windows, staggered session restores that no longer stall a PC, and ends the daily false "Login expired" ([release notes](docs/releases/1.39.0.md)). 1.38.0 made closing a session reliable across paired PCs ([notes](docs/releases/1.38.0.md)). See [the maintenance review](docs/quality-review-2026-09-08.md) and [session synchronization review](docs/session-sync-review-2026-09-08.md). +> **1.49.0** closes several sessions at once — a Select button above the sidebar's session groups (and Select… in every row's ⋯ menu) turns on a selection mode with checkboxes on rows and group headings; local, remote, background and saved rows close after one question that says what is on which PC, a tile attached to a Claude background session can end the session too, and the Background listing no longer starts a Claude Code process when its job records have not changed. Also fixes from a review of the sidebar and close flows: a confirmation that an unrelated Enter could answer, a row's ⋯ menu clipped on short lists, keyboard focus lost when rows reorder, Undo for forgetting a saved entry, one notice instead of a stack for bulk flows ([release notes](docs/releases/1.49.0.md)). 1.48.0 makes DevDeck lighter at rest — a quiet terminal is no longer re-scanned every second, the process listing that detects a tile's agent runs only after that tile printed something, git is re-read every two minutes for tiles you are not looking at, and the background-session listing slows to a minute when the group is empty and two when the window is not focused ([release notes](docs/releases/1.48.0.md)). 1.47.1 makes closing a Claude Code background session from the sidebar work when Claude Code's background service is not running — DevDeck starts it as Claude Code does and tries again, and a failure names the PC the command has to run on ([release notes](docs/releases/1.47.1.md)). 1.47.0 moves to Electron 44 (Chromium 152, Node.js 24); macOS 13 or later is now required, and screenshot paste / image copy use Electron's new clipboard API ([release notes](docs/releases/1.47.0.md)). 1.46.0 lets you close Claude Code's background sessions from the sidebar — the row's ⋯ menu or the group's bulk close runs `claude stop` and `claude rm` for you, so a finished or failed session no longer sits in the Background group forever; its conversation is kept ([release notes](docs/releases/1.46.0.md)). 1.45.2 stops an update from doubling the sessions on a paired PC — the relaunch no longer restores remote tiles itself, it adopts them from that PC, and no restore starts a second conversation for one that is already open here ([release notes](docs/releases/1.45.2.md)). 1.45.1 stops offering Claude background sessions whose folder was deleted, and says so plainly when a session's folder is gone ([release notes](docs/releases/1.45.1.md)). 1.45.0 sends any file to a session on another PC — any kind, up to 4 GB, in pieces with its progress shown ([release notes](docs/releases/1.45.0.md)). 1.44.1 fixes the "Assertion failed: remove_pty_baton" dialog (and the occasional hang or crash) when quitting or running Update all with several sessions open ([release notes](docs/releases/1.44.1.md)). 1.44.0 lists Claude Code's own background sessions (`claude --bg`, the agent view) in the sidebar, marks the ones waiting for you, and opens one in a tile by attaching to it ([release notes](docs/releases/1.44.0.md)). 1.43.0 runs parallel sessions in separate workspaces — each in its own git worktree and branch, so two agents never edit the same files — with its changes in the header and a clean-up that warns before unmerged work is lost ([release notes](docs/releases/1.43.0.md)). 1.42.0 shows what changed — a What's new window after each update, with Show me, and NEW dots on new buttons until you try them — and, as an experimental setting on Windows, lets Claude Code report its own state (working, needs approval, asking, done) instead of DevDeck reading the screen ([release notes](docs/releases/1.42.0.md)). 1.41.0 adds quick replies (one click or Ctrl+Shift+1…9 for the phrases you keep typing), a session timeline of your prompts with their times, replies and images, red boxes and arrows drawn on a screenshot and sent straight back to the session, and text, log and PDF attachments for sessions on another PC ([release notes](docs/releases/1.41.0.md)). 1.40.0 ties each screenshot to the terminal row it appeared on — hover a highlighted `[Image]` for a thumbnail, click for the full view, and jump from the viewer back to the row — says what led to each image, previews new screenshots, copies images, and adds Ctrl+↑/↓ between your prompts ([release notes](docs/releases/1.40.0.md)). 1.39.0 shows the screenshots and images a Claude session worked with — Claude Desktop shows them inline; DevDeck puts them in a strip beside the terminal, also for sessions on another PC — adds 📎 / drag-and-drop attachments (images travel to a remote PC), **Update all** for Claude Code on Windows, staggered session restores that no longer stall a PC, and ends the daily false "Login expired" ([release notes](docs/releases/1.39.0.md)). 1.38.0 made closing a session reliable across paired PCs ([notes](docs/releases/1.38.0.md)). See [the maintenance review](docs/quality-review-2026-09-08.md) and [session synchronization review](docs/session-sync-review-2026-09-08.md).
diff --git a/docs/releases/1.49.0.md b/docs/releases/1.49.0.md
new file mode 100644
index 0000000..d27cb5d
--- /dev/null
+++ b/docs/releases/1.49.0.md
@@ -0,0 +1,29 @@
+# DevDeck 1.49.0
+
+## Close several sessions at once
+
+The sidebar could already close a hand-picked set of sessions — after a Ctrl+click that nothing on screen mentioned. So most decks were closed one row at a time, and a deck of twenty sessions stayed a deck of twenty sessions.
+
+- **A Select button above the session groups** turns on a selection mode. Every row shows a checkbox, a plain click picks a row instead of opening it, the box on a group heading picks the whole group (opening it in full first, so nothing is picked that cannot be seen), Shift+click picks a range, and **Select all** opens every group and picks everything. **Close** closes the picked rows after one question; **Done** or Esc leaves the mode; Delete closes the picked rows from the keyboard. Every row's ⋯ menu also has **Select…**, which picks that row and starts the mode. Ctrl+click and Shift+click work as before, in and out of the mode.
+- **Local, remote, background and saved rows mix freely**, and the one question says what the set holds — *2 on this PC · 3 on SIHYEONG-4080 · 1 background · 4 saved* — because a close on another PC stops an agent in front of someone else. The tiles go in one batch and the pane settles once at the end (it used to re-select and re-render after each one, a visible stall for a dozen). Closes on other PCs answer later; a group of refusals or owed closes is one line, not a stack. Background sessions close with one progress line and one summary, machines side by side and one after another within a machine, so the first refusal starts Claude Code's background service once and the rest find it running.
+- **A tile attached to a Claude background session** (one opened from the Background group) used to leave the session running when closed; its row came straight back under Background, to be closed a second time. The question now offers to end the session as well, ticked by default. The conversation is kept either way.
+- Closing one picked row asks that row's own question (*Close session 'x'?*) rather than *Close 1 selected sessions?*.
+
+## The Background listing no longer starts a process on every poll
+
+`claude agents --json --all` is a Claude Code process — about 340 ms of a 300 MB executable — that every poll started, on this PC and on every paired one, nearly always to answer what it had answered the time before. DevDeck now looks first at what that command reads: the job records under `~/.claude/jobs`, one folder per background session with its `state.json` (a readdir and a stat per record, about a millisecond). No records means no sessions and no process at all; records unchanged since the last listing means the last answer, for up to five minutes; only a change runs the command. On a PC with no background sessions the process never starts.
+
+## From a review of the sidebar and the close flows
+
+- **The confirmation could be answered by an Enter that was not aimed at it.** Ctrl+Shift+P pressed while a question was open moved focus to the quick-open field behind it, and the Enter meant for that field closed the sessions. Chords no longer leave the question, an Enter from outside it only brings focus back to it, a held key's auto-repeat is ignored, and a second question is never stacked on the first — a row's ⋯ menu stayed open above the dialog and could ask one; it closes first now, and the dialog sits above every menu. After Cancel the keyboard goes back to where it was, so the next Delete or Enter acts on the same thing.
+- **A row's ⋯ menu was clipped when the list was short.** The session list is its own scrolling box, sized to its content; a menu dropped from the last row ran past its bottom and one flipped up from the first row ran past its top. On a fresh install with one session only the bottom item (Close) showed. The menu is placed against the window now, below the ⋯ when there is room and above it otherwise, and closes when the list scrolls.
+- **Keyboard focus survived a re-render only on a row.** Every publish that reorders rows (every 30 s with two active sessions) blurred the ⋯ button, an open menu's item or a group heading's controls; a keyboard user lost the menu they were in. They are put back now.
+- **Forgetting a saved entry from its ⋯ menu** — the one destructive row action with no question, right under Pin — offers Undo.
+- **Bulk flows toasted once per row**: restoring saved entries for a PC that is offline, or closing a group on a PC that refuses, stacked a notice per session past the top of the window. They show one line for the set; the diagnostics log keeps the rest.
+- **A pinned row wears its pin.** The groups that come before Pinned (needs you, working, another PC) keep their rows, so pinning one moved nothing and read as having done nothing.
+- Smaller: the Korean group confirmations use the same register as every other question; a group section is named for screen readers by its name and count alone, not by its buttons as well; rows carry a pressed state only while picking; the selection bar's Close is drawn as the destructive action it is.
+
+## Also in this release
+
+- `npm run qa:multi-close` drives the selection mode end to end in the real app — 24 checks, no agent needed.
+- The selection itself lives in `src/shared/sessionSelection.ts` as pure functions, with tests; the job-record signature in `src/main/backgroundAgents.ts` is tested against a real temporary folder.
diff --git a/package-lock.json b/package-lock.json
index bb72fe1..065f462 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "devdeck",
- "version": "1.48.0",
+ "version": "1.49.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "devdeck",
- "version": "1.48.0",
+ "version": "1.49.0",
"license": "MIT",
"dependencies": {
"@fontsource-variable/geist": "5.3.0",
diff --git a/package.json b/package.json
index 20eebe5..bc57ed0 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "devdeck",
- "version": "1.48.0",
+ "version": "1.49.0",
"description": "Project command deck — at-a-glance state + Claude Code, Codex, and Antigravity resume",
"main": "dist/main/main.js",
"type": "commonjs",
@@ -35,6 +35,7 @@
"qa:audit": "npm run build && node qa/audit.mjs",
"qa:link": "npm run build && node qa/link.mjs",
"qa:close": "npm run build && node qa/close.mjs",
+ "qa:multi-close": "npm run build && node qa/multi-close.mjs",
"qa:resilience": "npm run build && node qa/resilience.mjs",
"qa:perf": "npm run build && node qa/perf.mjs",
"qa:perf-gate": "npm run build && node qa/perf-gate.mjs",
diff --git a/qa/multi-close.mjs b/qa/multi-close.mjs
new file mode 100644
index 0000000..4add198
--- /dev/null
+++ b/qa/multi-close.mjs
@@ -0,0 +1,134 @@
+// Closing several sidebar sessions at once, driven the way a person drives it.
+//
+// What this guards: the selection MODE (the Select button above the groups, "Select…" in a row's ⋯
+// menu, a plain click picking a row while the mode is on, the group heading's checkbox, Shift+click
+// ranges, Escape leaving, Delete closing), the one confirmation for the whole set, and the rows
+// actually leaving afterwards. It uses saved ("previous") entries, which need no agent: closing them
+// is a forget, so the whole flow runs without a `claude` on the PC. Also checked: a row's ⋯ menu is
+// positioned inside the window even when the list is short (it used to be clipped by the list box).
+//
+// Run with `npm run qa:multi-close`. One Electron instance; do not run alongside `npm run qa`.
+import { _electron as electron } from 'playwright';
+import { mkdtempSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { execFileSync } from 'node:child_process';
+import { closeElectron } from './electron-lifecycle.mjs';
+
+const root = dirname(dirname(fileURLToPath(import.meta.url)));
+const projectRoot = mkdtempSync(join(tmpdir(), 'devdeck-multiclose-fixture-'));
+writeFileSync(join(projectRoot, 'README.md'), '# QA fixture\n');
+execFileSync('git', ['init', '-q', '-b', 'qa-fixture'], { cwd: projectRoot });
+execFileSync('git', ['add', '.'], { cwd: projectRoot });
+execFileSync('git', ['-c', 'user.name=QA', '-c', 'user.email=qa@devdeck', 'commit', '-qm', 'fixture'], { cwd: projectRoot });
+
+const qaUserData = mkdtempSync(join(tmpdir(), 'devdeck-multiclose-'));
+const saved = ['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo'];
+writeFileSync(join(qaUserData, 'state.json'), JSON.stringify({
+ projects: {},
+ settings: {
+ language: 'ko',
+ folders: [{ path: projectRoot, kind: 'repo' }],
+ cockpitSessions: saved.map((label) => ({ projectPath: projectRoot, name: 'fixture', sessionId: null, agentId: 'claude', label })),
+ },
+}, null, 2));
+
+const consoleErrors = [];
+const pageErrors = [];
+const results = [];
+const check = (name, ok, detail = '') => { results.push({ name, ok, detail }); console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`); };
+
+const app = await electron.launch({
+ args: ['.', `--user-data-dir=${qaUserData}`, '--no-sandbox', '--disable-gpu'],
+ cwd: root,
+ env: { ...process.env, CLAUDE_CODE_SSE_PORT: '', CLAUDECODE: '', CLAUDE_CODE_ENTRYPOINT: '' },
+});
+try {
+ const win = await app.firstWindow();
+ win.on('console', (m) => { if (m.type() === 'error') consoleErrors.push(m.text()); });
+ win.on('pageerror', (e) => pageErrors.push(String(e)));
+ await win.waitForSelector('#shell-session-groups', { timeout: 20000 });
+ await win.waitForFunction((n) => document.querySelectorAll('.shell-session-wrap[data-previous="true"]').length >= n, saved.length, { timeout: 20000 });
+ const previousRows = () => win.locator('.shell-session-wrap[data-previous="true"]');
+ const rowByLabel = (label) => previousRows().filter({ hasText: label }).locator('.shell-session');
+ const selectedCount = () => win.evaluate(() => document.querySelectorAll('.shell-session-wrap.is-selected').length);
+ const inMode = () => win.evaluate(() => document.getElementById('shell-session-groups').classList.contains('is-selecting'));
+
+ // 1. The Select button is on screen and turns the mode on with nothing picked.
+ const toggle = win.locator('#shell-select-toggle');
+ check('select button visible', await toggle.isVisible());
+ await toggle.click();
+ check('mode on after Select', await inMode());
+ check('bar visible, close disabled with nothing picked', await win.locator('.shell-selection').isVisible() && await win.locator('.shell-selection-close').isDisabled());
+ check('checkboxes drawn in the mode', await win.evaluate(() => getComputedStyle(document.querySelector('.shell-session-check')).display !== 'none'));
+
+ // 2. Plain clicks pick (and do not open) while the mode is on.
+ await rowByLabel('Alpha').click();
+ await rowByLabel('Bravo').click();
+ check('two plain clicks picked two rows', (await selectedCount()) === 2, `selected=${await selectedCount()}`);
+ check('picked rows carry aria-pressed', await rowByLabel('Alpha').getAttribute('aria-pressed') === 'true');
+ check('count says 2', (await win.locator('.shell-selection-count').textContent()).includes('2'));
+
+ // 3. The group checkbox picks the whole group (opening the "show more" cut first).
+ const groupCheck = win.locator('.shell-group.group-previous .shell-group-check');
+ await groupCheck.click();
+ check('group checkbox picked every saved row', (await selectedCount()) === saved.length, `selected=${await selectedCount()}`);
+ check('group checkbox reads checked', await groupCheck.getAttribute('aria-checked') === 'true');
+ await groupCheck.click();
+ check('group checkbox again un-picked them', (await selectedCount()) === 0);
+
+ // 4. Escape leaves the mode.
+ await win.keyboard.press('Escape');
+ check('Escape left the mode', !(await inMode()));
+ check('toggle is back', await toggle.isVisible());
+
+ // 5. "Select…" in a row's ⋯ menu starts a selection with that row; the menu was inside the window.
+ const charlie = previousRows().filter({ hasText: 'Charlie' });
+ await charlie.locator('.shell-session-actions').click();
+ const menuBox = await charlie.locator('.shell-session-menu').boundingBox();
+ const viewport = win.viewportSize() ?? await win.evaluate(() => ({ width: innerWidth, height: innerHeight }));
+ check('row menu is placed inside the window', !!menuBox && menuBox.y >= 0 && menuBox.y + menuBox.height <= viewport.height && menuBox.x >= 0, JSON.stringify(menuBox));
+ await charlie.locator('.shell-session-pick').click();
+ check('Select… picked that row and turned the mode on', (await inMode()) && (await selectedCount()) === 1);
+
+ // 6. Shift+click ranges from the last pick.
+ await rowByLabel('Echo').click({ modifiers: ['Shift'] });
+ check('Shift+click picked the range Charlie..Echo', (await selectedCount()) === 3, `selected=${await selectedCount()}`);
+
+ // 7. Close asks once; Enter answers; the rows leave and the mode ends.
+ await win.locator('.shell-selection-close').click();
+ await win.waitForSelector('.ck-confirm', { timeout: 5000 });
+ const question = await win.locator('.ck-confirm-msg').textContent();
+ check('one confirmation for the set', question.includes('3'), question);
+ await win.keyboard.press('Enter');
+ await win.waitForFunction((n) => document.querySelectorAll('.shell-session-wrap[data-previous="true"]').length === n, saved.length - 3, { timeout: 5000 });
+ check('three rows left the list', (await previousRows().count()) === saved.length - 3);
+ check('mode ended after the close', !(await inMode()));
+
+ // 8. Delete closes the picked rows (after the question); Cancel keeps them.
+ await toggle.click();
+ await rowByLabel('Alpha').click();
+ await win.keyboard.press('Delete');
+ await win.waitForSelector('.ck-confirm', { timeout: 5000 });
+ await win.locator('.ck-confirm-cancel').click();
+ check('Cancel kept the row and the pick', (await previousRows().count()) === saved.length - 3 && (await selectedCount()) === 1);
+ // The question keeps the keyboard: the quick-open chord must not move focus behind it, where a
+ // later Enter would still answer it.
+ await win.keyboard.press('Delete');
+ await win.waitForSelector('.ck-confirm', { timeout: 5000 });
+ await win.keyboard.press('Control+Shift+P');
+ check('a chord cannot leave the question', await win.evaluate(() => document.activeElement?.closest('.ck-confirm') != null));
+ check('one confirmation on screen', (await win.locator('.ck-confirm').count()) === 1);
+ await win.keyboard.press('Enter');
+ await win.waitForFunction((n) => document.querySelectorAll('.shell-session-wrap[data-previous="true"]').length === n, saved.length - 4, { timeout: 5000 });
+ check('Delete + Enter closed the picked row', (await previousRows().count()) === saved.length - 4);
+} finally {
+ await closeElectron(app);
+}
+
+check('no console errors', consoleErrors.length === 0, consoleErrors.join(' | '));
+check('no page errors', pageErrors.length === 0, pageErrors.join(' | '));
+const failed = results.filter((r) => !r.ok);
+console.log(`\n${results.length - failed.length}/${results.length} checks passed`);
+if (failed.length) process.exit(1);
diff --git a/src/main/backgroundAgents.test.ts b/src/main/backgroundAgents.test.ts
index 2873070..66fbfce 100644
--- a/src/main/backgroundAgents.test.ts
+++ b/src/main/backgroundAgents.test.ts
@@ -1,4 +1,7 @@
import { EventEmitter } from 'node:events';
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
const spawn = vi.fn();
@@ -86,3 +89,62 @@ describe('closing a background session when the background service is not runnin
expect(spawn).not.toHaveBeenCalled();
});
});
+
+describe('listing off the job records before starting a claude process', () => {
+ const LISTING = JSON.stringify([{ id: '8e64da40', cwd: 'C:/g/shipwright', kind: 'background', startedAt: 1, sessionId: '8e64da40-4c50-4e80-ae72-bddba02e6bcb', name: 'triage', state: 'blocked' }]);
+ const dirs: string[] = [];
+ const jobsDir = (): string => { const d = mkdtempSync(join(tmpdir(), 'devdeck-jobs-')); dirs.push(d); return d; };
+ const record = (dir: string, id: string, body = '{}'): void => { mkdirSync(join(dir, id), { recursive: true }); writeFileSync(join(dir, id, 'state.json'), body); };
+ afterEach(async () => {
+ const m = await import('./backgroundAgents.js'); m._clearBackgroundAgentCache(); spawn.mockReset();
+ for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true });
+ });
+
+ it('answers an empty list without starting claude when there are no records (or no folder)', async () => {
+ fakeClaude(() => ({ code: 0 }));
+ const m = await import('./backgroundAgents.js');
+ const dir = jobsDir();
+ writeFileSync(join(dir, 'pins.json'), '[]'); // not a record
+ expect(await m.listBackgroundAgents(Date.now(), { jobsDir: dir })).toEqual([]);
+ m._clearBackgroundAgentCache();
+ expect(await m.listBackgroundAgents(Date.now(), { jobsDir: join(dir, 'missing') })).toEqual([]);
+ expect(spawn).not.toHaveBeenCalled();
+ });
+
+ it('runs the listing when a record exists, and not again while the records are unchanged', async () => {
+ spawn.mockImplementation((_file: string, args: string[]) => {
+ const child = Object.assign(new EventEmitter(), { stdout: new EventEmitter(), stderr: new EventEmitter(), pid: 1, kill: () => true, unref: () => undefined });
+ setTimeout(() => { if (args[0] === 'agents') child.stdout.emit('data', Buffer.from(LISTING)); child.emit('close', 0); }, 0);
+ return child;
+ });
+ const m = await import('./backgroundAgents.js');
+ const dir = jobsDir();
+ record(dir, '8e64da40');
+ const t0 = Date.now();
+ const first = await m.listBackgroundAgents(t0, { jobsDir: dir });
+ expect(first.map((b) => b.id)).toEqual(['8e64da40']);
+ expect(calls()).toEqual(['agents --json --all']);
+ // Past the short cache, same records: the last answer, no process.
+ expect(await m.listBackgroundAgents(t0 + 60_000, { jobsDir: dir })).toBe(first);
+ expect(calls()).toHaveLength(1);
+ // A record changed (the session moved on, or one was added): the listing runs again.
+ record(dir, '8e64da40', '{"state":"idle"}');
+ expect(await m.listBackgroundAgents(t0 + 120_000, { jobsDir: dir })).toEqual(first);
+ expect(calls()).toHaveLength(2);
+ // Unchanged for longer than the reuse window: listed again regardless.
+ expect(await m.listBackgroundAgents(t0 + 120_000 + 6 * 60_000, { jobsDir: dir })).toEqual(first);
+ expect(calls()).toHaveLength(3);
+ });
+
+ it('signs the records by name, change time and size, ignoring other files', async () => {
+ const m = await import('./backgroundAgents.js');
+ const dir = jobsDir();
+ writeFileSync(join(dir, 'pins.json'), '[]');
+ expect(await m.jobRecordsSignature(dir)).toBe('');
+ record(dir, 'b54cc363', '{"a":1}');
+ record(dir, '09644674', '{"a":1}');
+ const sig = await m.jobRecordsSignature(dir);
+ expect(sig).toMatch(/^09644674:\d+:7\|b54cc363:\d+:7$/);
+ expect(await m.jobRecordsSignature(join(dir, 'nope'))).toBe('');
+ });
+});
diff --git a/src/main/backgroundAgents.ts b/src/main/backgroundAgents.ts
index 1984e3a..cd2ea2b 100644
--- a/src/main/backgroundAgents.ts
+++ b/src/main/backgroundAgents.ts
@@ -2,14 +2,20 @@
// Read Claude Code's background sessions from `claude agents --json --all` (~200 ms; cached briefly,
// since the sidebar asks every 30 s and a paired machine may ask too).
import { spawn, type ChildProcess } from 'node:child_process';
+import { readdir, stat } from 'node:fs/promises';
import { homedir } from 'node:os';
+import { join } from 'node:path';
import { isBackgroundAgentId, parseBackgroundAgents, type BackgroundAgent } from '../shared/backgroundAgents';
import { cliInvocation, killChildTree } from './aiSummary';
import { resolveAgentCliPath } from './cliExecutable';
const CACHE_MS = 15_000;
const TIMEOUT_MS = 10_000;
-let cached: { at: number; list: BackgroundAgent[] } | null = null;
+/** With the records unchanged, the last answer is reused this long before the listing runs anyway. */
+const UNCHANGED_RELIST_MS = 5 * 60_000;
+/** Claude Code keeps one folder per background session here (its `state.json` inside); `claude rm` deletes it. */
+const JOBS_DIR = join(homedir(), '.claude', 'jobs');
+let cached: { at: number; listedAt: number; list: BackgroundAgent[]; records: string | null } | null = null;
let inFlight: Promise