Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/releases/1.49.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ The sidebar could already close a hand-picked set of sessions — after a Ctrl+c

## 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.
`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). Records unchanged since the last listing — including none at all — mean the last answer holds, for up to five minutes; a change runs the command at once. The records only stretch the cache, they never replace the command, so a Claude Code that keeps its records elsewhere is still asked, at the slow cadence. At rest this is one process per five minutes instead of one per 30–120 s.

## From a review of the sidebar and the close flows

Expand Down
16 changes: 11 additions & 5 deletions src/main/backgroundAgents.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,15 +100,21 @@ describe('listing off the job records before starting a claude process', () => {
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 () => {
it('with no records (or no folder) still asks claude once, then reuses the answer for five minutes', async () => {
// The records only stretch the cache: a claude that keeps its records elsewhere (or a fake one
// in a harness) must still be asked — just not every poll.
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();
const t0 = Date.now();
expect(await m.listBackgroundAgents(t0, { jobsDir: dir })).toEqual([]);
expect(calls()).toEqual(['agents --json --all']);
expect(await m.listBackgroundAgents(t0 + 60_000, { jobsDir: dir })).toEqual([]);
expect(await m.listBackgroundAgents(t0 + 240_000, { jobsDir: join(dir, 'missing') })).toEqual([]);
expect(calls()).toHaveLength(1);
expect(await m.listBackgroundAgents(t0 + 6 * 60_000, { jobsDir: dir })).toEqual([]);
expect(calls()).toHaveLength(2);
});

it('runs the listing when a record exists, and not again while the records are unchanged', async () => {
Expand Down
7 changes: 4 additions & 3 deletions src/main/backgroundAgents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,16 +33,17 @@ export async function pauseBackgroundAgents(on: boolean): Promise<void> {
* Claude Code process (~340 ms, a 300 MB executable) started every poll, here and on every paired
* PC, almost always to answer what it answered last time. It reads the job records under
* `~/.claude/jobs`, so those are looked at first (a readdir and a stat per record, about a
* millisecond): no records means no sessions and no process; records unchanged since the last
* listing means the last answer, for up to five minutes; only a change runs the command.
* millisecond): records unchanged since the last listing — including none at all — mean the last
* answer holds, for up to five minutes; a change runs the command at once. The records only
* stretch the cache, they never replace the command: a `claude` that keeps its records elsewhere
* (or a fake one in a harness) is still asked, at the slow cadence.
*/
export async function listBackgroundAgents(nowMs = Date.now(), opts: { jobsDir?: string } = {}): Promise<BackgroundAgent[]> {
if (paused) return cached?.list ?? [];
if (cached && nowMs - cached.at < CACHE_MS) return cached.list;
if (inFlight) return inFlight;
inFlight = (async () => {
const records = await jobRecordsSignature(opts.jobsDir ?? JOBS_DIR);
if (records === '') return { list: [], records, listedAt: nowMs };
if (cached && records !== null && cached.records === records && nowMs - cached.listedAt < UNCHANGED_RELIST_MS) {
return { list: cached.list, records, listedAt: cached.listedAt };
}
Expand Down
Loading