diff --git a/README.md b/README.md index a893940..3e6566d 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.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.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).
DevDeck project overview with repository and session navigation diff --git a/docs/quality.md b/docs/quality.md index d81ebfa..dcadd7c 100644 --- a/docs/quality.md +++ b/docs/quality.md @@ -32,7 +32,7 @@ Results and screenshots are written to `qa/shots/maintenance/` and `qa/shots/res The CI performance gate is `qa:perf-gate` (Windows, every push): a fixture agent instead of a real one, and units that do not depend on the runner's speed — running CSS animations and DOM mutation records on an idle deck (both must be ~0), DOM mutations outside the terminals while two tiles repaint at 10fps (a per-chunk sidebar rebuild is ~8,000 against a limit of 400), and the main process's longest event-loop stall while working and while 20,000 lines stream. Each limit was checked by injecting the regression it guards against: an infinite CSS animation, a sidebar render per PTY chunk, and a 700ms synchronous block in the main process all fail it. Results go to `qa/shots/perf-gate/results.json`. -`qa:images` (Windows) drives the cockpit image strip, full-size viewer and 📎 attach against an isolated home holding a transcript with a tool screenshot, a pasted image and a written image file; its fixture `claude` echoes what it is sent, so an attached path is read back off the terminal. `qa:update` (Windows) checks "Update all": a fixture `claude` prints Claude Code's failed-update footer, and the harness asserts that `claude update` ran with no session process alive and that every conversation was resumed by id. `qa:images-remote` (Windows) pairs a host and a viewer over real TLS and checks the same from the viewer: thumbnails and the full view cross the link, an attached JPEG lands on the host as PNG with the host path typed, and a non-image named `.png` or an unindexed id is refused over the link. `qa:restore-pacing` (Windows) checks that restored agents start one at a time: a fixture that draws for 2s while booting must not see the next one launched before it finished (measured before the fix: 26–49ms apart). `qa:image-rows` (Windows) ties images to the terminal: a fixture `claude` draws a screen the way Claude Code does (`● server - tool (MCP)` calls, `⎿ [Image]` results, shaded `❯` prompts, a composer between rules) over a transcript holding the same screenshots, and the harness checks that each placeholder is marked and opens its own image, that the viewer shows what led to an image and scrolls the terminal to its row, that copying puts an image on the clipboard (the person's clipboard is restored afterwards), that a new screenshot is previewed while the strip is closed, and that Ctrl+Up/Down steps between sent prompts without entering the composer. `qa:cockpit-tools` (Windows) uses a fixture `claude` that records every byte typed into it: a quick reply from the header menu and from Ctrl+Shift+ arrives as the phrase followed by Enter, phrases edited in Settings are saved and offered, the timeline lists each prompt with its time, reply and image count and scrolls the terminal to it without resizing it, and a screenshot marked up in the viewer reaches the session as a PNG that has the red marks in it. It also starts as an install updated from 1.41.0 with the Claude status signal on: the What's new window lists only what is newer and Show me rings the setting, NEW dots go once a button is used, and the fixture asks for status the way Claude Code does (OSC 7501) — the host answers on the bundled ConPTY and the header shows working, needs approval and done. It also starts a separate workspace from the header, checks its branch and that the project stays clean, sees a change counted, and cleans it up through the warning. The release workflow runs it against the packaged app too. `qa:images-remote` also sends a log file from the viewer and checks it lands on the host under its own name. None of these starts a real agent. +`qa:images` (Windows) drives the cockpit image strip, full-size viewer and 📎 attach against an isolated home holding a transcript with a tool screenshot, a pasted image and a written image file; its fixture `claude` echoes what it is sent, so an attached path is read back off the terminal. `qa:update` (Windows) checks "Update all": a fixture `claude` prints Claude Code's failed-update footer, and the harness asserts that `claude update` ran with no session process alive and that every conversation was resumed by id. `qa:images-remote` (Windows) pairs a host and a viewer over real TLS and checks the same from the viewer: thumbnails and the full view cross the link, an attached JPEG lands on the host as PNG with the host path typed, and a non-image named `.png` or an unindexed id is refused over the link. `qa:restore-pacing` (Windows) checks that restored agents start one at a time: a fixture that draws for 2s while booting must not see the next one launched before it finished (measured before the fix: 26–49ms apart). `qa:image-rows` (Windows) ties images to the terminal: a fixture `claude` draws a screen the way Claude Code does (`● server - tool (MCP)` calls, `⎿ [Image]` results, shaded `❯` prompts, a composer between rules) over a transcript holding the same screenshots, and the harness checks that each placeholder is marked and opens its own image, that the viewer shows what led to an image and scrolls the terminal to its row, that copying puts an image on the clipboard (the person's clipboard is restored afterwards), that a new screenshot is previewed while the strip is closed, and that Ctrl+Up/Down steps between sent prompts without entering the composer. `qa:cockpit-tools` (Windows) uses a fixture `claude` that records every byte typed into it: a quick reply from the header menu and from Ctrl+Shift+ arrives as the phrase followed by Enter, phrases edited in Settings are saved and offered, the timeline lists each prompt with its time, reply and image count and scrolls the terminal to it without resizing it, and a screenshot marked up in the viewer reaches the session as a PNG that has the red marks in it. It also starts as an install updated from 1.41.0 with the Claude status signal on: the What's new window lists only what is newer and Show me rings the setting, NEW dots go once a button is used, and the fixture asks for status the way Claude Code does (OSC 7501) — the host answers on the bundled ConPTY and the header shows working, needs approval and done. It also starts a separate workspace from the header, checks its branch and that the project stays clean, sees a change counted, and cleans it up through the warning. Its fixture also answers `claude agents --json`, so the harness checks the Claude background sessions group, the waiting mark, and that a click opens the session with `claude attach `. The release workflow runs it against the packaged app too. `qa:images-remote` also sends a log file from the viewer and checks it lands on the host under its own name. None of these starts a real agent. Do not run these against a live development conversation. Do not equate a short loopback test with sleep/resume, VPN changes or multi-day field validation. diff --git a/docs/releases/1.44.0.md b/docs/releases/1.44.0.md new file mode 100644 index 0000000..16741f3 --- /dev/null +++ b/docs/releases/1.44.0.md @@ -0,0 +1,30 @@ +# DevDeck 1.44.0 + +## Claude's background sessions in the sidebar + +Claude Code can run sessions in the background: `claude --bg`, or the sessions you dispatch from its agent view. DevDeck never started them, so it never showed them, and a background session waiting on you could sit unseen for days. + +- **Listed:** the sidebar now has a **Claude background sessions** group. Each row shows its name, project and state: + - **waiting for you**, marked like any session that needs you + - running or working + - idle + - failed, listed for a week +- **Counted when collapsed:** the waiting ones count toward the "needs you" number on the collapsed rail. +- **Open one:** click it. It opens in a tile by attaching to it (`claude attach`), and the conversation and the session carry on exactly as they were. Once it is open in a tile it leaves the group. +- **Other PCs:** sessions running on a paired PC are listed too, from PCs on 1.44.0. +- **Not shown:** only sessions working in a folder DevDeck shows. Finished ones are not listed. + +The list comes from Claude Code itself (`claude agents --json`, about 0.2 s), read every 30 seconds while DevDeck is in front of you. If Claude Code is not installed, the group simply does not appear. + +## Also + +- **Browser screenshots open from another PC:** in a session on a paired PC, clicking a screenshot Claude took with Claude in Chrome (`[image] ~\AppData\Local\Temp\claude-chrome-screenshots-…\screenshot-….jpg`) said "Image unavailable". That folder was not among the temp folders an image may be sent from. It is now, and other applications' temp files still are not. The PC running the session needs 1.44.0. +- **Update all is undisturbed:** background sessions are not listed while Update all replaces Claude Code. Listing them starts `claude` briefly, which would hold the very file the update is replacing. The automated Update-all check caught this. +- **Release reliability:** the performance check in CI now exits as soon as it has printed its verdict. On the v1.42.0 release it passed, then kept the job open until it timed out, which cancelled the release until it was rerun. + +## Under the hood + +- **Command line:** the only value that reaches it is the short hex id Claude Code assigns. Anything else in the listing is dropped. +- **Tests:** + - New unit tests cover parsing the listing and the sidebar grouping. + - `qa:cockpit-tools` uses a fixture `claude` that answers `agents --json`. It checks the group, the waiting mark, and that clicking starts `claude attach ` in a tile. diff --git a/package-lock.json b/package-lock.json index dd8b7a4..d7cd039 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "devdeck", - "version": "1.43.0", + "version": "1.44.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "devdeck", - "version": "1.43.0", + "version": "1.44.0", "license": "MIT", "dependencies": { "@fontsource-variable/geist": "5.3.0", diff --git a/package.json b/package.json index b083107..d16f5f8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "devdeck", - "version": "1.43.0", + "version": "1.44.0", "description": "Project command deck — at-a-glance state + Claude Code, Codex, and Antigravity resume", "main": "dist/main/main.js", "type": "commonjs", diff --git a/qa/claude-update.mjs b/qa/claude-update.mjs index 9bb1725..5d66471 100644 --- a/qa/claude-update.mjs +++ b/qa/claude-update.mjs @@ -32,6 +32,8 @@ const log = ${JSON.stringify(log)}; const locks = ${JSON.stringify(sandbox)}; const running = (pid) => { try { process.kill(pid, 0); return true; } catch { return false; } }; const alive = () => fs.readdirSync(locks).filter((f) => f.startsWith('alive-') && running(Number(f.slice(6)))).length; const args = process.argv.slice(2); +// The sidebar's background-session listing: not a session, and never counted as one. +if (args[0] === 'agents') { fs.appendFileSync(log, 'list alive=' + alive() + '\\n'); process.stdout.write('[]'); process.exit(0); } if (args[0] === 'update') { fs.appendFileSync(log, 'update alive=' + alive() + '\\n'); console.log('Successfully updated from 2.1.1 to 2.1.2'); process.exit(0); } const me = path.join(locks, 'alive-' + process.pid); fs.writeFileSync(me, ''); const bye = () => { try { fs.unlinkSync(me); } catch {} process.exit(0); }; diff --git a/qa/cockpit-tools.mjs b/qa/cockpit-tools.mjs index f1d96ad..e0e6eb5 100644 --- a/qa/cockpit-tools.mjs +++ b/qa/cockpit-tools.mjs @@ -52,8 +52,19 @@ writeFileSync(transcript, [ const SHADE = '\x1b[48;5;237m'; const RESET = '\x1b[0m'; +const BG_ID = '0a1b2c3d'; +const BG_SESSION = '0a1b2c3d-1111-4222-8333-944455556666'; writeFileSync(join(bin, 'agent.js'), ` const fs = require('fs'); +// \`claude agents --json --all\`: one background session waiting on the person. +if (process.argv.includes('agents') && process.argv.includes('--json')) { + process.stdout.write(JSON.stringify([ + { id: ${JSON.stringify(BG_ID)}, cwd: ${JSON.stringify(repo)}, kind: 'background', startedAt: Date.now() - 60000, sessionId: ${JSON.stringify(BG_SESSION)}, name: '백그라운드 QA 작업', state: 'blocked' }, + { pid: 1, cwd: ${JSON.stringify(repo)}, kind: 'interactive', startedAt: Date.now(), sessionId: ${JSON.stringify(sid)}, name: 'x', status: 'busy' }, + ])); + process.exit(0); +} +if (process.argv.includes('attach')) process.stdout.write('ATTACHED:' + process.argv[process.argv.indexOf('attach') + 1] + '\\r\\n'); const out = (s) => process.stdout.write(s.replace(/\\n/g, '\\r\\n')); const filler = (tag, n) => Array.from({ length: n }, (_, i) => ' ' + tag + ' output line ' + i).join('\\n') + '\\n'; out(${JSON.stringify(SHADE)} + '❯ 로그인 화면 확인해줘' + ${JSON.stringify(RESET)} + '\\n'); @@ -127,7 +138,7 @@ try { await check('session opens with the fixture screen', async () => { await win.waitForSelector('#shell-session-groups .shell-session', { timeout: 30000 }); - await win.locator('#shell-session-groups .shell-session').first().click(); + await win.locator('#shell-session-groups .shell-session', { hasText: 'Tools QA' }).click(); await win.waitForFunction(() => [...document.querySelectorAll('.ck-term.show .xterm-rows')].some((r) => r.textContent.includes('FIXTURE_READY')), null, { timeout: 20000 }); await win.waitForFunction(() => document.querySelector('#ck-header .ck-img-count')?.textContent === '1'); }); @@ -178,7 +189,7 @@ try { await win.waitForFunction(() => document.querySelector('#settings-form .set-quick-replies')?.closest('.set-row')?.querySelector('.set-save-status')?.textContent === '저장됨'); const saved = JSON.parse(readFileSync(join(profile, 'state.json'), 'utf8')).settings.quickReplies; assert.deepEqual(saved, ['배포 전 체크리스트 확인해줘', '로그 다시 봐줘']); - await win.locator('#shell-session-groups .shell-session').first().click(); + await win.locator('#shell-session-groups .shell-session', { hasText: 'Tools QA' }).click(); await win.locator('#ck-header button[aria-label="빠른 답장"]').click(); assert.deepEqual(await win.locator('.ck-qr-item .ck-qr-text').allTextContents(), ['배포 전 체크리스트 확인해줘', '로그 다시 봐줘']); await win.keyboard.press('Escape'); @@ -246,7 +257,7 @@ try { assert.equal(execFileSync('git', ['-C', repo, 'status', '--porcelain'], { encoding: 'utf8' }).trim(), ''); // Work done in the workspace shows up in its header. writeFileSync(join(wt, 'feature.txt'), 'new work\n'); - await win.locator('#shell-session-groups .shell-session').first().click(); + await win.locator('#shell-session-groups .shell-session', { hasText: 'Tools QA' }).click(); await win.locator('#shell-session-groups .shell-session', { hasText: name }).click(); await win.waitForFunction(() => document.querySelector('#ck-header .ck-wt-pill')?.textContent?.includes('파일 1 '), null, { timeout: 15000 }); await win.screenshot({ path: join(shots, '4-workspace.png') }); @@ -263,6 +274,21 @@ try { assert.equal(execFileSync('git', ['-C', repo, 'branch', '--list', 'devdeck/' + name], { encoding: 'utf8' }).trim(), '', 'and its branch'); }); + await check('Claude\'s own background sessions are listed, the waiting one marked, and open by attaching', async () => { + const row = win.locator('#shell-session-groups .group-background .shell-session', { hasText: '백그라운드 QA 작업' }); + await row.waitFor({ timeout: 45000 }); + assert.match(await win.locator('#shell-session-groups .group-background').textContent(), /Claude 백그라운드 세션/); + assert.match(await row.textContent(), /응답 대기/); + await win.screenshot({ path: join(shots, '5-background.png') }); + await row.click(); + // The tile is that session, started by attaching to it (the fixture's own output scrolls past). + await win.waitForFunction(() => document.querySelector('#ck-header .title')?.textContent?.includes('백그라운드 QA 작업'), null, { timeout: 20000 }); + const log = readFileSync(join(profile, 'devdeck.log'), 'utf8'); + assert.match(log, /conversation=0a1b2c3d-1111-4222-8333-944455556666 .*cmd=claude attach 0a1b2c3d/); + // Open in a tile now, so it is no longer listed as a background session here. + await win.waitForFunction(() => !document.querySelector('#shell-session-groups .group-background'), null, { timeout: 10000 }); + }); + await check('a document sent from another machine lands in the temp directory under its own name', async () => { const landed = await win.evaluate(() => window.devdeck.cockpit.receiveFile('build.log', btoa('step 1 ok\nstep 2 failed\n'))); assert.match(landed, /devdeck-file-[0-9a-f-]{36}-build\.log$/); diff --git a/qa/image-rows.mjs b/qa/image-rows.mjs index ddd9612..89e0a38 100644 --- a/qa/image-rows.mjs +++ b/qa/image-rows.mjs @@ -57,6 +57,7 @@ writeFileSync(join(bin, 'more.jsonl'), [ const SHADE = '\x1b[48;5;237m'; const RESET = '\x1b[0m'; writeFileSync(join(bin, 'agent.js'), ` +if (process.argv.includes('agents')) { process.stdout.write('[]'); process.exit(0); } // DevDeck's background-session listing const fs = require('fs'); const out = (s) => process.stdout.write(s.replace(/\\n/g, '\\r\\n')); const filler = (tag, n) => Array.from({ length: n }, (_, i) => ' ' + tag + ' output line ' + i).join('\\n') + '\\n'; diff --git a/qa/images-remote.mjs b/qa/images-remote.mjs index 83f54d7..02f2bb0 100644 --- a/qa/images-remote.mjs +++ b/qa/images-remote.mjs @@ -4,7 +4,7 @@ // Never starts a real agent. Run with npm run qa:images-remote. import { _electron as electron } from 'playwright'; import assert from 'node:assert/strict'; -import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, copyFileSync } from 'node:fs'; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, copyFileSync, rmSync } from 'node:fs'; import { execFileSync } from 'node:child_process'; import { createServer } from 'node:net'; import { tmpdir } from 'node:os'; @@ -20,7 +20,7 @@ const repo = join(sandbox, 'host-workspace'); const bin = join(sandbox, 'bin'); for (const p of [repo, bin]) mkdirSync(p, { recursive: true }); execFileSync('git', ['init', '-q', '-b', 'qa-remote-images'], { cwd: repo }); -writeFileSync(join(bin, 'echo.js'), "process.stdout.write('FIXTURE_READY\\r\\n'); if (process.stdin.isTTY) process.stdin.setRawMode(true); process.stdin.on('data', (d) => process.stdout.write('GOT:' + d.toString().replace(/\\r/g, '') + '\\r\\n'));\n"); +writeFileSync(join(bin, 'echo.js'), "if (process.argv.includes('agents')) { process.stdout.write('[]'); process.exit(0); } process.stdout.write('FIXTURE_READY\\r\\n'); if (process.stdin.isTTY) process.stdin.setRawMode(true); process.stdin.on('data', (d) => process.stdout.write('GOT:' + d.toString().replace(/\\r/g, '') + '\\r\\n'));\n"); writeFileSync(join(bin, 'claude.cmd'), '@echo off\r\nnode "%~dp0echo.js"\r\n'); copyFileSync(join(root, 'build', 'icon.png'), join(repo, 'render.png')); writeFileSync(join(repo, 'secret.png'), 'not an image: TOKEN=abc'); @@ -144,6 +144,22 @@ try { assert.deepEqual(r, { real: true, fake: null, unindexed: null }); }); + await check('a Claude in Chrome screenshot in the host temp directory travels; other temp files do not', async () => { + const shotDir = mkdtempSync(join(tmpdir(), 'claude-chrome-screenshots-')); + const other = mkdtempSync(join(tmpdir(), 'devdeck-qa-other-')); + copyFileSync(join(root, 'build', 'icon.png'), join(shotDir, 'screenshot-1-1.jpg')); + copyFileSync(join(root, 'build', 'icon.png'), join(other, 'shot.png')); + try { + const r = await viewer.win.evaluate(async ([id, path, shot, stray]) => { + const m = window.devdeck.machine(id).cockpit; + return { shot: !!(await m.imageFile(path, shot, 'full')), stray: await m.imageFile(path, stray, 'full') }; + }, [machineId, repo, join(shotDir, 'screenshot-1-1.jpg'), join(other, 'shot.png')]); + assert.deepEqual(r, { shot: true, stray: null }); + } finally { + rmSync(shotDir, { recursive: true, force: true }); rmSync(other, { recursive: true, force: true }); + } + }); + assert.deepEqual(errors, [], 'no page/console errors'); console.log('done'); } catch (e) { diff --git a/qa/images.mjs b/qa/images.mjs index d6fb630..b89f7e7 100644 --- a/qa/images.mjs +++ b/qa/images.mjs @@ -22,7 +22,7 @@ const home = join(profile, 'home'); for (const p of [repo, bin, profile, home]) mkdirSync(p, { recursive: true }); execFileSync('git', ['init', '-q', '-b', 'qa-images'], { cwd: repo }); // An echoing agent: whatever is pasted into it shows up on its screen. -writeFileSync(join(bin, 'echo.js'), "process.stdout.write('FIXTURE_READY\\r\\n'); if (process.stdin.isTTY) process.stdin.setRawMode(true); process.stdin.on('data', (d) => process.stdout.write('GOT:' + d.toString().replace(/\\r/g, '') + '\\r\\n'));\n"); +writeFileSync(join(bin, 'echo.js'), "if (process.argv.includes('agents')) { process.stdout.write('[]'); process.exit(0); } process.stdout.write('FIXTURE_READY\\r\\n'); if (process.stdin.isTTY) process.stdin.setRawMode(true); process.stdin.on('data', (d) => process.stdout.write('GOT:' + d.toString().replace(/\\r/g, '') + '\\r\\n'));\n"); writeFileSync(join(bin, 'claude.cmd'), '@echo off\r\nnode "%~dp0echo.js"\r\n'); // A transcript with a tool screenshot, a pasted image and an image file the agent wrote. diff --git a/qa/perf-gate.mjs b/qa/perf-gate.mjs index 58ce555..0bc03be 100644 --- a/qa/perf-gate.mjs +++ b/qa/perf-gate.mjs @@ -51,6 +51,7 @@ execFileSync('git', ['init', '-q', '-b', 'qa-perf'], { cwd: repo }); // The fixture agent: a static screen at rest; "W" repaints the whole screen at 10fps for 8s with a // Claude spinner in it (so the deck shows the session as working); "S" prints 20,000 lines. writeFileSync(join(bin, 'agent.js'), ` +if (process.argv.includes('agents')) { process.stdout.write('[]'); process.exit(0); } // DevDeck's background-session listing const out = (s) => process.stdout.write(s); const E = '\\x1b'; const screen = (title) => out(E + '[H' + E + '[2J' + title + '\\r\\n' + Array.from({ length: 30 }, (_, i) => 'line ' + i + ' ' + 'x'.repeat(80)).join('\\r\\n') + '\\r\\n> '); diff --git a/qa/restore-pacing.mjs b/qa/restore-pacing.mjs index eae3ae3..efdbe0c 100644 --- a/qa/restore-pacing.mjs +++ b/qa/restore-pacing.mjs @@ -24,6 +24,7 @@ for (const p of [repo, bin, home]) mkdirSync(p, { recursive: true }); execFileSync('git', ['init', '-q', '-b', 'qa-pacing'], { cwd: repo }); const BOOT_MS = 2000; writeFileSync(join(bin, 'agent.js'), ` +if (process.argv.includes('agents')) { process.stdout.write('[]'); process.exit(0); } // DevDeck's background-session listing require('fs').appendFileSync(${JSON.stringify(log)}, Date.now() + ' ' + process.argv.slice(2).join(' ') + '\\n'); const t = setInterval(() => process.stdout.write('.'), 100); // busy drawing while it boots setTimeout(() => { clearInterval(t); process.stdout.write('\\r\\nPACING_READY\\r\\n> '); }, ${BOOT_MS}); diff --git a/src/main/api/deckApi.ts b/src/main/api/deckApi.ts index 265e3a1..9fc95ce 100644 --- a/src/main/api/deckApi.ts +++ b/src/main/api/deckApi.ts @@ -1,7 +1,7 @@ import { createSessionLabels } from '../sessionLabels'; import { dialog, shell, app, clipboard, nativeImage, type BrowserWindow } from 'electron'; import { homedir, release, tmpdir } from 'node:os'; -import path, { join, resolve } from 'node:path'; +import { join, resolve } from 'node:path'; import { readFile, realpath, stat } from 'node:fs/promises'; import { readFileSync, writeFileSync, existsSync } from 'node:fs'; import { randomUUID } from 'node:crypto'; @@ -12,9 +12,11 @@ import { applyOpenAtLogin, effectiveOpenAtLogin } from '../autostart'; import { scanFolders, isRepo } from '../scanner'; import { getGitInfo, getRepoUrl, getGitBranchDirty, getRecentCommits, createGitRunner, type GitRunner } from '../gitInfo'; import { createWorktree, removeWorktree, worktreeChanges, type Worktree } from '../worktrees'; +import { listBackgroundAgents, pauseBackgroundAgents } from '../backgroundAgents'; +import { isBackgroundAgentId } from '../../shared/backgroundAgents'; import { getProvider, availableAgents, resolveOpenSession, resolveProjectOpenCommand } from '../agents'; import { toAgentId, type AgentId, type CloseResult, type Folder, type OpenMode, type ProjectOpenIntent, type SessionMeta } from '../../shared/types'; -import { isAllowedPath, isAllowedFilePath, resolveAgentFilePath, AGENT_OPEN_EXT } from '../../shared/pathGuard'; +import { isAllowedPath, isAllowedFilePath, resolveAgentFilePath, tempImageRoot, AGENT_OPEN_EXT } from '../../shared/pathGuard'; import { basename, cwdKey } from '../../shared/paths'; import { isAllowedExternalUrl, isSafeRepoUrl, isOpenableTerminalLink } from '../../shared/externalUrl'; import { makeTtlCache } from '../../shared/ttlCache'; @@ -635,7 +637,7 @@ export function createDeckApi(cfg: DeckApiConfig): DeckApiBundle { return null; } }); - invoke('cockpit:open', allow('spawn'), async (req: { projectPath: string; sessionId: string | null; cols: number; rows: number; mode?: OpenMode; agentId?: AgentId }) => { + invoke('cockpit:open', allow('spawn'), async (req: { projectPath: string; sessionId: string | null; cols: number; rows: number; mode?: OpenMode; agentId?: AgentId; attachId?: string }) => { const folders = effFolders(); // A refusal travels back IN THE ANSWER. `sendError` reaches this machine's own window — which, for // an open asked over the link, is not where the person who pressed Open is looking: they saw a @@ -655,7 +657,10 @@ export function createDeckApi(cfg: DeckApiConfig): DeckApiBundle { // Resolve BOTH the launch command and the concrete session id to persist (so each session // restores to its OWN conversation — required once a project can hold several sessions). const history = forceNew ? [] : await providerHistory(a, req.projectPath); - const resolved = resolveOpenSession(a, { + // Opening one of Claude Code's own background sessions: attach to it by its short id. Nothing + // but that validated id reaches the command line. + const attach = a.id === 'claude' && isBackgroundAgentId(req.attachId) && typeof req.sessionId === 'string'; + const resolved = attach ? { command: `claude attach ${req.attachId}`, sessionId: req.sessionId } : resolveOpenSession(a, { fresh: forceNew, // count/latestId are consulted only on the automatic new/continue path. sessionCount: forceNew ? 1 : history.length, @@ -1098,6 +1103,16 @@ export function createDeckApi(cfg: DeckApiConfig): DeckApiBundle { } }); + /** + * Claude Code's own background sessions on this machine (`claude --bg`, the agent view), for the + * sidebar — only those working in a folder DevDeck is allowed to show. + */ + invoke('agents:background', allow('observe'), async () => { + if (!cfg.ptyAvailable) return []; + const folders = effFolders(); + return (await listBackgroundAgents()).filter((b) => isAllowedFilePath(folders, b.cwd)); + }); + invoke('cockpit:gitInfo', allow('observe'), (projectPath: string) => { // Same allowlist as every other path-taking handler — a compromised renderer must not be able // to point git at arbitrary filesystem locations. @@ -1321,13 +1336,15 @@ export function createDeckApi(cfg: DeckApiConfig): DeckApiBundle { const resolved = resolveAgentFilePath(projectPath, filePath, homedir()); if (!VIEWABLE_IMAGE_EXT.test(resolved)) return null; const tmp = tmpdir(); - const isPaste = path.dirname(resolved).toLowerCase() === tmp.toLowerCase() && path.basename(resolved).startsWith(PASTE_IMAGE_PREFIX); + const tempRoot = tempImageRoot(tmp, resolved, PASTE_IMAGE_PREFIX); const roots = [...folders.map((f) => f.path), join(tmp, 'claude')]; const lexicalRoots = roots.filter((r) => isAllowedFilePath([], resolved, [r])); - if (!isPaste && lexicalRoots.length === 0) return null; + if (!tempRoot && lexicalRoots.length === 0) return null; try { const real = await realpath(resolved); - const realRoots = isPaste ? [await realpath(tmp)] : await Promise.all(lexicalRoots.map((r) => realpath(r).catch(() => r))); + // A temp folder that is itself a link must still land inside the real temp directory. + if (tempRoot && !isAllowedFilePath([], real, [await realpath(tmp)])) return null; + const realRoots = tempRoot ? [await realpath(tempRoot)] : await Promise.all(lexicalRoots.map((r) => realpath(r).catch(() => r))); if (!isAllowedFilePath([], real, realRoots)) return null; const st = await stat(real); if (!st.isFile() || st.size > MAX_VIEW_IMAGE_FILE_BYTES) return null; @@ -1336,7 +1353,12 @@ export function createDeckApi(cfg: DeckApiConfig): DeckApiBundle { } /** Run `claude update` here; the cockpit has already stopped every Claude session holding the executable. */ - invoke('agent:updateClaude', localOnly, () => runClaudeUpdate()); + // Background-session polls start claude itself; one landing mid-update would hold the executable + // the update is replacing, so they pause until it is done. + invoke('agent:updateClaude', localOnly, async () => { + await pauseBackgroundAgents(true); + try { return await runClaudeUpdate(); } finally { void pauseBackgroundAgents(false); } + }); // One-shot idle shutdown (win32 only — cfg.shutdown is null elsewhere so none of this registers). // Lifecycle invokes return the fresh status so the renderer can update without a round-trip race. diff --git a/src/main/backgroundAgents.test.ts b/src/main/backgroundAgents.test.ts new file mode 100644 index 0000000..34be5d4 --- /dev/null +++ b/src/main/backgroundAgents.test.ts @@ -0,0 +1,15 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const spawn = vi.fn(); +vi.mock('node:child_process', () => ({ spawn: (...a: unknown[]) => spawn(...a) })); + +describe('listing background sessions while Claude Code is being updated', () => { + afterEach(async () => { const m = await import('./backgroundAgents.js'); await m.pauseBackgroundAgents(false); m._clearBackgroundAgentCache(); spawn.mockReset(); }); + + it('starts no claude at all while paused — the update is replacing that executable', async () => { + const m = await import('./backgroundAgents.js'); + await m.pauseBackgroundAgents(true); + expect(await m.listBackgroundAgents()).toEqual([]); + expect(spawn).not.toHaveBeenCalled(); + }); +}); diff --git a/src/main/backgroundAgents.ts b/src/main/backgroundAgents.ts new file mode 100644 index 0000000..0372356 --- /dev/null +++ b/src/main/backgroundAgents.ts @@ -0,0 +1,62 @@ +// src/main/backgroundAgents.ts +// 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 { homedir } from 'node:os'; +import { 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; +let inFlight: Promise | null = null; + +let paused = false; +/** + * Hold off starting `claude` while it is being updated: the update replaces the executable, and a + * listing started meanwhile would hold it open. The last list is answered in the meantime. + */ +export async function pauseBackgroundAgents(on: boolean): Promise { + paused = on; + if (on) await inFlight?.catch(() => undefined); // one already running finishes first +} + +export async function listBackgroundAgents(nowMs = Date.now()): Promise { + if (paused) return cached?.list ?? []; + if (cached && nowMs - cached.at < CACHE_MS) return cached.list; + if (inFlight) return inFlight; + inFlight = run().then((json) => parseBackgroundAgents(json, Date.now())).catch(() => [] as BackgroundAgent[]) + .then((list) => { cached = { at: Date.now(), list }; return list; }) + .finally(() => { inFlight = null; }); + return inFlight; +} + +export function _clearBackgroundAgentCache(): void { cached = null; } + +/** The listing child while one runs, so quitting can end it instead of orphaning it. */ +let current: ChildProcess | null = null; + +/** End a listing still running — on quit, before the process that started it goes away. */ +export function stopBackgroundAgentListing(): void { + if (current) killChildTree(current); + current = null; + paused = true; +} + +function run(): Promise { + return new Promise((resolve, reject) => { + const env = { ...process.env }; + // A DevDeck started from inside Claude Code must not make this look like a nested session. + for (const k of Object.keys(env)) if (/^CLAUDE/i.test(k)) delete env[k]; + const how = cliInvocation(resolveAgentCliPath('claude'), ['agents', '--json', '--all']); + const child = spawn(how.file, how.args, { cwd: homedir(), env, windowsHide: true, windowsVerbatimArguments: how.windowsVerbatimArguments, stdio: ['ignore', 'pipe', 'ignore'] }); + let out = ''; + const timer = setTimeout(() => { killChildTree(child); reject(new Error('claude agents timed out')); }, TIMEOUT_MS); + current = child; + child.on('close', () => { if (current === child) current = null; }); + child.stdout.on('data', (b: Buffer) => { if (out.length < 2_000_000) out += b.toString('utf8'); }); + child.on('error', (e) => { clearTimeout(timer); reject(e); }); + child.on('close', (code) => { clearTimeout(timer); if (code === 0) resolve(out); else reject(new Error(`claude agents exited ${code}`)); }); + }); +} diff --git a/src/main/main.ts b/src/main/main.ts index 520ce1a..1123ff1 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -6,6 +6,7 @@ import { uptime, homedir, tmpdir } from 'node:os'; import { Store } from './store'; import { registerIpc } from './ipc'; import { PtyHost, taskkillTree, withTreeKill, type PtyProcess, type PtySpawn } from './ptyHost'; +import { stopBackgroundAgentListing } from './backgroundAgents'; import { setupTray } from './tray'; import { registerUpdater } from './updater'; import { applyOpenAtLogin } from './autostart'; @@ -325,6 +326,8 @@ if (!gotLock) { event.preventDefault(); if (quitWork) return; // repeated Quit cannot bypass or duplicate native cleanup (app as typeof app & { isQuitting?: boolean }).isQuitting = true; + // A background-session listing still running would outlive the app (and hold its output pipes). + stopBackgroundAgentListing(); quitWork = Promise.allSettled([ ptyHost.shutdown(), withTimeout(Promise.resolve(linkService?.dispose()), 5_000, 'link shutdown'), diff --git a/src/preload/preload.ts b/src/preload/preload.ts index 44c4496..3057bd3 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -113,6 +113,7 @@ contextBridge.exposeInMainWorld('devdeck', { receiveImage: (base64: string) => ipcRenderer.invoke('cockpit:receiveImage', base64), receiveFile: (name: string, base64: string) => ipcRenderer.invoke('cockpit:receiveFile', name, base64), createWorktree: (repoPath: string) => ipcRenderer.invoke('cockpit:createWorktree', repoPath), + backgroundAgents: () => ipcRenderer.invoke('agents:background'), worktreeInfo: (path: string) => ipcRenderer.invoke('cockpit:worktreeInfo', path), removeWorktree: (path: string, force: boolean) => ipcRenderer.invoke('cockpit:removeWorktree', path, force), sessionTurns: (projectPath: string, sessionId: string, agentId?: string) => ipcRenderer.invoke('cockpit:sessionTurns', projectPath, sessionId, agentId), @@ -197,6 +198,7 @@ contextBridge.exposeInMainWorld('devdeck', { receiveImage: (base64: string) => ipcRenderer.invoke('link:call', machineId, 'cockpit:receiveImage', [base64]), receiveFile: (name: string, base64: string) => ipcRenderer.invoke('link:call', machineId, 'cockpit:receiveFile', [name, base64]), createWorktree: (repoPath: string) => ipcRenderer.invoke('link:call', machineId, 'cockpit:createWorktree', [repoPath]), + backgroundAgents: () => ipcRenderer.invoke('link:call', machineId, 'agents:background', []), worktreeInfo: (path: string) => ipcRenderer.invoke('link:call', machineId, 'cockpit:worktreeInfo', [path]), removeWorktree: (path: string, force: boolean) => ipcRenderer.invoke('link:call', machineId, 'cockpit:removeWorktree', [path, force]), sessionTurns: (projectPath: string, sessionId: string, agentId?: string) => ipcRenderer.invoke('link:call', machineId, 'cockpit:sessionTurns', [projectPath, sessionId, agentId]), diff --git a/src/renderer/cockpitView.ts b/src/renderer/cockpitView.ts index 46cd182..00ad22f 100644 --- a/src/renderer/cockpitView.ts +++ b/src/renderer/cockpitView.ts @@ -37,6 +37,7 @@ import { quickReplySlot } from '../shared/quickReplies'; import { activityFromProgramStatus, parseProgramStatus, type ProgramStatus } from '../shared/programStatus'; import { IDLE_MS } from '../shared/sessionStatus'; import { newDot } from './whatsNew'; +import type { BackgroundAgent } from '../shared/backgroundAgents'; /** What cockpit:sessionMeta answers with: the log-derived facts plus the ready-made summary line. */ type SessionMetaView = { model: string | null; activeMs: number; contextTokens: number; contextWindow?: number; summary: string | null }; @@ -91,7 +92,7 @@ interface Live { resizedAt: number; } /** The renderer's display metadata plus the explicit provider launch intent. */ -export interface OpenReq { path: string; name: string; staleLevel: StaleLevel; branch: string | null; dirty: number; tileId?: string; sessionId?: string | null; mode: OpenMode; label?: string | null; pinned?: boolean; agentId: AgentId; /** Which machine the project lives on; absent means this one. */ machineId?: string; /** Bind to a terminal that is ALREADY running under this id instead of starting one. */ adoptId?: string; instanceId?: string; labelRevision?: number; /** Picked up because a machine announced it, not because anybody here asked: build it without taking the screen or the keyboard. */ background?: boolean; } +export interface OpenReq { path: string; name: string; staleLevel: StaleLevel; branch: string | null; dirty: number; tileId?: string; sessionId?: string | null; mode: OpenMode; label?: string | null; pinned?: boolean; agentId: AgentId; /** Which machine the project lives on; absent means this one. */ machineId?: string; /** Bind to a terminal that is ALREADY running under this id instead of starting one. */ adoptId?: string; instanceId?: string; labelRevision?: number; /** Picked up because a machine announced it, not because anybody here asked: build it without taking the screen or the keyboard. */ background?: boolean; /** Open one of Claude Code's own background sessions by its short id (`claude attach`). */ attachId?: string; } const live = new Map(); /** How long after a size change a terminal's output is taken to be the repaint, not the agent. */ @@ -178,6 +179,9 @@ export function mountCockpit(): void { if (strip) initImageStrip(strip, () => { if (!editingId) renderHeader(); scheduleLayout(); }, { popupIn: termsEl, refsChanged: () => { markSelectedTerminal(); void refreshTimeline(); } }); // Floats over the terminal's right edge, so opening it never resizes the pty. initTimeline(termsEl, () => { if (!editingId) renderHeader(); }); + // Not at start-up: restoring sessions comes first, and the list is not urgent. + setTimeout(() => void refreshBackgroundAgents(), BACKGROUND_FIRST_POLL_MS); + setInterval(() => { if (!document.hidden) void refreshBackgroundAgents(); }, BACKGROUND_POLL_MS); // Mid-turn, new screenshots show up as they are taken rather than at the end of the turn; between // turns a slow re-read still catches images written some other way (another process, a resume). // Only the selected session is ever read, and an unchanged transcript costs one stat. @@ -593,7 +597,56 @@ export function cockpitNavigationItems(): ShellSessionInput[] { previous: true, conversationGone: missingConversations.has(prevKey(entry)), })); - return [...current, ...previous]; + // Claude Code's own background sessions, except one already open in a tile here. + const openConversations = new Set(liveItems.map((l) => l.openedSessionId).filter(Boolean)); + const background: ShellSessionInput[] = []; + for (const [machineId, list] of backgroundAgents) { + for (const b of list) { + if (openConversations.has(b.sessionId)) continue; + background.push({ + id: backgroundNavigationId(machineId, b.id), + projectPath: b.cwd, + label: b.name ?? basename(b.cwd), + detail: [basename(b.cwd), tr(`cockpit.bg_state_${b.state}`), ...(machineId !== LOCAL_MACHINE_ID ? [machineName(machineId)] : [])].join(' · '), + activity: b.state === 'blocked' ? 'attention' : b.state === 'running' || b.state === 'working' ? 'working' : b.state === 'idle' ? 'turn' : 'idle', + pinned: false, + lastActiveMs: b.startedAt, + backgroundAgent: true, + }); + } + } + return [...current, ...background, ...previous]; +} + +// ---- Claude Code's own background sessions ---- +/** Per machine, the background sessions its `claude agents` lists (see main/backgroundAgents). */ +const backgroundAgents = new Map(); +const BACKGROUND_POLL_MS = 30_000; +const BACKGROUND_FIRST_POLL_MS = 15_000; + +function backgroundNavigationId(machineId: string, id: string): string { return `bg:${machineId}:${id}`; } + +/** Re-read every reachable machine's background sessions; the sidebar updates if anything changed. */ +async function refreshBackgroundAgents(): Promise { + // "Update all" stops every claude before replacing it; a listing would start one again. + if (claudeUpdateRunning) return; + const machines = [LOCAL_MACHINE_ID, ...[...live.values()].map((l) => l.machineId).filter((m) => m !== LOCAL_MACHINE_ID && machineState(m) === 'connected')]; + let changed = false; + for (const machineId of new Set(machines)) { + const list = await deckFor(machineId).cockpit.backgroundAgents().catch(() => [] as BackgroundAgent[]); + if (JSON.stringify(list) !== JSON.stringify(backgroundAgents.get(machineId) ?? [])) { backgroundAgents.set(machineId, list); changed = true; } + } + if (changed) publishCockpitNavigation(); +} + +/** Open a background session in a tile, attached to it — the conversation and the session go on as they were. */ +function openBackgroundAgent(id: string): boolean { + const m = /^bg:(.+):([0-9a-f]+)$/i.exec(id); + if (!m) return false; + const b = backgroundAgents.get(m[1])?.find((x) => x.id === m[2]); + if (!b) return false; + void createSession({ path: b.cwd, name: b.name ?? basename(b.cwd), staleLevel: 'neutral', branch: null, dirty: 0, mode: 'auto', sessionId: b.sessionId, attachId: b.id, agentId: 'claude', machineId: m[1] }); + return true; } /** One-release migration map for shell contexts saved before opaque tile identities existed. */ @@ -649,7 +702,8 @@ export function activateCockpitSession(id: string): void { const current = [...live.values()].find((entry) => navigationIdForLive(entry) === id || legacyCockpitNavigationId(navigationIdentity(entry)) === id); if (current) { select(current.session.id); return; } const previous = restorable.find((entry) => cockpitNavigationId(entry) === id || legacyCockpitNavigationId(entry) === id); - if (previous) void restoreSession(previous); + if (previous) { void restoreSession(previous); return; } + openBackgroundAgent(id); } /** @@ -1114,7 +1168,7 @@ async function buildTile(p: OpenReq): Promise { try { // The machine that owns the project opens it. A remote answer carries an id already qualified // with that machine, which is what lets input/resize/close below stay machine-agnostic. - res = await deckFor(machineId).cockpit.open({ projectPath: p.path, sessionId: p.sessionId ?? null, cols, rows, mode: p.mode, agentId: p.agentId }); + res = await deckFor(machineId).cockpit.open({ projectPath: p.path, sessionId: p.sessionId ?? null, cols, rows, mode: p.mode, agentId: p.agentId, ...(p.attachId ? { attachId: p.attachId } : {}) }); } catch (err) { res = { id: '', agentId: 'claude', sessionId: null, error: err instanceof Error ? err.message : String(err) }; } diff --git a/src/renderer/global.d.ts b/src/renderer/global.d.ts index 6efdda2..ee88893 100644 --- a/src/renderer/global.d.ts +++ b/src/renderer/global.d.ts @@ -57,7 +57,7 @@ declare global { availableAgents(): Promise; appInfo(): Promise<{ version: string; electron: string; repoUrl: string; packaged: boolean; machineId: string; machineName: string; gpu?: boolean }>; cockpit: { - open(req: { projectPath: string; sessionId: string | null; cols: number; rows: number; mode: import('../shared/types').OpenMode; agentId: string }): Promise<{ id: string; agentId: import('../shared/types').AgentId; sessionId: string | null; error?: string; adopted?: boolean }>; + open(req: { projectPath: string; sessionId: string | null; cols: number; rows: number; mode: import('../shared/types').OpenMode; agentId: string; attachId?: string }): Promise<{ id: string; agentId: import('../shared/types').AgentId; sessionId: string | null; error?: string; adopted?: boolean }>; sessionMeta(projectPath: string, sessionId: string, agentId?: string, wantAi?: boolean): Promise; sessionIds(projectPath: string, agentId?: string): Promise; sessionsExist(items: { projectPath: string; sessionId: string | null; agentId?: string }[]): Promise; @@ -67,6 +67,7 @@ declare global { receiveImage(base64: string): Promise; receiveFile(name: string, base64: string): Promise; createWorktree(repoPath: string): Promise<{ worktree?: import('../main/worktrees').Worktree; error?: string }>; + backgroundAgents(): Promise; worktreeInfo(path: string): Promise<{ worktree: import('../main/worktrees').Worktree; changes: import('../main/worktrees').WorktreeChanges } | null>; removeWorktree(path: string, force: boolean): Promise<{ removed: boolean; reason?: string; error?: string }>; sessionTurns(projectPath: string, sessionId: string, agentId?: string): Promise; @@ -134,7 +135,7 @@ declare global { readImageBytes(): Promise<{ tooLarge: boolean; bytes: string | null } | null>; }; cockpit: { - open(req: { projectPath: string; sessionId: string | null; cols: number; rows: number; mode: import('../shared/types').OpenMode; agentId: import('../shared/types').AgentId }): Promise<{ id: string; agentId: import('../shared/types').AgentId; sessionId: string | null; error?: string; adopted?: boolean }>; + open(req: { projectPath: string; sessionId: string | null; cols: number; rows: number; mode: import('../shared/types').OpenMode; agentId: import('../shared/types').AgentId; attachId?: string }): Promise<{ id: string; agentId: import('../shared/types').AgentId; sessionId: string | null; error?: string; adopted?: boolean }>; input(id: string, data: string): void; resize(id: string, cols: number, rows: number): void; close(id: string): void; @@ -166,6 +167,7 @@ declare global { receiveImage(base64: string): Promise; receiveFile(name: string, base64: string): Promise; createWorktree(repoPath: string): Promise<{ worktree?: import('../main/worktrees').Worktree; error?: string }>; + backgroundAgents(): Promise; worktreeInfo(path: string): Promise<{ worktree: import('../main/worktrees').Worktree; changes: import('../main/worktrees').WorktreeChanges } | null>; removeWorktree(path: string, force: boolean): Promise<{ removed: boolean; reason?: string; error?: string }>; sessionTurns(projectPath: string, sessionId: string, agentId?: string): Promise; diff --git a/src/renderer/locales/en.json b/src/renderer/locales/en.json index 65aed2c..186f494 100644 --- a/src/renderer/locales/en.json +++ b/src/renderer/locales/en.json @@ -633,5 +633,14 @@ "whatsnew.f.worktree-sessions.body": "Start another session on the same project in its own folder and git branch, so two agents never edit the same files. The header shows what it has changed; cleaning up warns before anything unmerged is lost.", "whatsnew.f.worktree-sessions.where": "The branch button in a session's header", "cockpit.worktree_part_dirty": "{n} uncommitted changes", - "cockpit.worktree_part_commits": "{n} commits that {base} does not have" + "cockpit.worktree_part_commits": "{n} commits that {base} does not have", + "shell.grp_background": "Claude background sessions", + "cockpit.bg_state_blocked": "waiting for you", + "cockpit.bg_state_running": "running", + "cockpit.bg_state_working": "working", + "cockpit.bg_state_idle": "idle", + "cockpit.bg_state_failed": "failed", + "whatsnew.f.background-agents.title": "Claude's background sessions in the sidebar", + "whatsnew.f.background-agents.body": "Sessions started with claude --bg or from Claude's agent view now show up, with the ones waiting for you marked. Click one to open it in a tile; it keeps running as it was.", + "whatsnew.f.background-agents.where": "The \"Claude background sessions\" group in the sidebar" } diff --git a/src/renderer/locales/ja.json b/src/renderer/locales/ja.json index 84bc3ee..9441f6a 100644 --- a/src/renderer/locales/ja.json +++ b/src/renderer/locales/ja.json @@ -633,5 +633,14 @@ "whatsnew.f.worktree-sessions.body": "同じプロジェクトにもう 1 つセッションを開きますが、専用のフォルダーと git ブランチを使うので 2 つのエージェントが同じファイルを触りません。ヘッダーに変更ファイル数とコミット数が表示され、片付ける前に未マージの作業があれば警告します。", "whatsnew.f.worktree-sessions.where": "セッションヘッダーのブランチボタン", "cockpit.worktree_part_dirty": "未コミットの変更 {n} 件", - "cockpit.worktree_part_commits": "{base} にないコミット {n} 件" + "cockpit.worktree_part_commits": "{base} にないコミット {n} 件", + "shell.grp_background": "Claude バックグラウンドセッション", + "cockpit.bg_state_blocked": "返答待ち", + "cockpit.bg_state_running": "実行中", + "cockpit.bg_state_working": "作業中", + "cockpit.bg_state_idle": "待機", + "cockpit.bg_state_failed": "失敗", + "whatsnew.f.background-agents.title": "Claude のバックグラウンドセッションをサイドバーに", + "whatsnew.f.background-agents.body": "claude --bg や Claude のエージェント画面から始めたセッションが表示され、返答を待っているものは区別して示されます。クリックするとタイルで開き、セッションはそのまま続きます。", + "whatsnew.f.background-agents.where": "サイドバーの「Claude バックグラウンドセッション」グループ" } diff --git a/src/renderer/locales/ko.json b/src/renderer/locales/ko.json index 47f719f..2657c0c 100644 --- a/src/renderer/locales/ko.json +++ b/src/renderer/locales/ko.json @@ -633,5 +633,14 @@ "whatsnew.f.worktree-sessions.body": "같은 프로젝트에 세션을 하나 더 띄우되, 자체 폴더와 git 브랜치를 써서 두 에이전트가 같은 파일을 건드리지 않습니다. 상단에 바뀐 파일·커밋 수가 보이고, 정리할 때 병합 안 된 작업이 있으면 먼저 경고합니다.", "whatsnew.f.worktree-sessions.where": "세션 상단의 브랜치 버튼", "cockpit.worktree_part_dirty": "커밋하지 않은 변경 {n}개", - "cockpit.worktree_part_commits": "{base}에 없는 커밋 {n}개" + "cockpit.worktree_part_commits": "{base}에 없는 커밋 {n}개", + "shell.grp_background": "Claude 백그라운드 세션", + "cockpit.bg_state_blocked": "응답 대기", + "cockpit.bg_state_running": "실행 중", + "cockpit.bg_state_working": "작업 중", + "cockpit.bg_state_idle": "대기", + "cockpit.bg_state_failed": "실패", + "whatsnew.f.background-agents.title": "Claude 백그라운드 세션을 사이드바에", + "whatsnew.f.background-agents.body": "claude --bg나 Claude 에이전트 화면에서 띄운 세션이 이제 보이고, 내 응답을 기다리는 세션은 따로 표시됩니다. 클릭하면 타일로 열리며 세션은 그대로 계속됩니다.", + "whatsnew.f.background-agents.where": "사이드바의 \"Claude 백그라운드 세션\" 그룹" } diff --git a/src/renderer/locales/zh.json b/src/renderer/locales/zh.json index ee7b299..1c6ed80 100644 --- a/src/renderer/locales/zh.json +++ b/src/renderer/locales/zh.json @@ -633,5 +633,14 @@ "whatsnew.f.worktree-sessions.body": "在同一项目上再开一个会话,但使用独立的文件夹和 git 分支,两个智能体不会改到相同文件。顶部显示改动的文件数和提交数;清理前若有未合并的工作会先提醒。", "whatsnew.f.worktree-sessions.where": "会话顶部的分支按钮", "cockpit.worktree_part_dirty": "{n} 处未提交的改动", - "cockpit.worktree_part_commits": "{n} 个 {base} 没有的提交" + "cockpit.worktree_part_commits": "{n} 个 {base} 没有的提交", + "shell.grp_background": "Claude 后台会话", + "cockpit.bg_state_blocked": "等待你回应", + "cockpit.bg_state_running": "运行中", + "cockpit.bg_state_working": "工作中", + "cockpit.bg_state_idle": "空闲", + "cockpit.bg_state_failed": "失败", + "whatsnew.f.background-agents.title": "侧边栏显示 Claude 后台会话", + "whatsnew.f.background-agents.body": "用 claude --bg 或在 Claude 智能体视图中启动的会话现在会显示出来,等待你回应的会单独标出。点击即可在面板中打开,会话照常继续。", + "whatsnew.f.background-agents.where": "侧边栏中的“Claude 后台会话”分组" } diff --git a/src/renderer/machineDeck.ts b/src/renderer/machineDeck.ts index f872fef..a757c38 100644 --- a/src/renderer/machineDeck.ts +++ b/src/renderer/machineDeck.ts @@ -36,7 +36,7 @@ export interface DeckFacade { setHidden: (path: string, hidden: boolean) => Promise; usageReport: (sinceMs: number) => Promise; cockpit: { - open: (req: { projectPath: string; sessionId: string | null; cols: number; rows: number; mode: import('../shared/types').OpenMode; agentId: string }) => Promise<{ id: string; agentId: import('../shared/types').AgentId; sessionId: string | null; error?: string; adopted?: boolean }>; + open: (req: { projectPath: string; sessionId: string | null; cols: number; rows: number; mode: import('../shared/types').OpenMode; agentId: string; attachId?: string }) => Promise<{ id: string; agentId: import('../shared/types').AgentId; sessionId: string | null; error?: string; adopted?: boolean }>; sessionMeta: (projectPath: string, sessionId: string, agentId?: string, wantAi?: boolean) => Promise; sessionIds: (projectPath: string, agentId?: string) => Promise; sessionsExist: (items: { projectPath: string; sessionId: string | null; agentId?: string }[]) => Promise; @@ -46,6 +46,7 @@ export interface DeckFacade { receiveImage: (base64: string) => Promise; receiveFile: (name: string, base64: string) => Promise; createWorktree: (repoPath: string) => Promise<{ worktree?: import('../main/worktrees').Worktree; error?: string }>; + backgroundAgents: () => Promise; worktreeInfo: (path: string) => Promise<{ worktree: import('../main/worktrees').Worktree; changes: import('../main/worktrees').WorktreeChanges } | null>; removeWorktree: (path: string, force: boolean) => Promise<{ removed: boolean; reason?: string; error?: string }>; sessionTurns: (projectPath: string, sessionId: string, agentId?: string) => Promise; @@ -115,6 +116,8 @@ export function deckFor(machineId: string): DeckFacade { // An older host has no workspaces: creating one says so, reading one finds none. createWorktree: (repoPath) => remote.cockpit.createWorktree(repoPath).catch((error: unknown) => ({ error: /no such method/.test(String(error)) ? HOST_TOO_OLD : String(error) })), worktreeInfo: (path) => remote.cockpit.worktreeInfo(path).catch(() => null), + // An older host does not list them; it simply has none to show. + backgroundAgents: () => remote.cockpit.backgroundAgents().catch(() => []), removeWorktree: (path, force) => remote.cockpit.removeWorktree(path, force).catch((error: unknown) => ({ removed: false, reason: 'error', error: String(error) })), // A host older than the timeline is said so (the panel tells the person to update it); any other // failure is an empty list for now, read again on the next turn. diff --git a/src/renderer/shell.ts b/src/renderer/shell.ts index f0ab35e..f7cb8d8 100644 --- a/src/renderer/shell.ts +++ b/src/renderer/shell.ts @@ -40,7 +40,7 @@ const viewLabels: Record = { const groupLabels: Record = { attention: 'shell.needs_you', working: 'shell.working', pinned: 'cockpit.grp_pinned', remote: 'shell.grp_remote', turn: 'cockpit.grp_turn', quiet: 'cockpit.grp_idle', - previous: 'cockpit.prev_sessions', + previous: 'cockpit.prev_sessions', background: 'shell.grp_background', }; const actionLabels: Record = { @@ -60,7 +60,7 @@ const actionIcons: Record = { * session that is *waiting on you* behind a "show more" would defeat the reason the sidebar exists. */ const groupLimits: Record = { - attention: Infinity, working: Infinity, pinned: 10, remote: 10, turn: 8, quiet: 6, previous: 5, + attention: Infinity, working: Infinity, background: 6, pinned: 10, remote: 10, turn: 8, quiet: 6, previous: 5, }; /** Recent-first project rows shown before the list offers the full deck. Pinned ones are never cut. */ @@ -405,6 +405,8 @@ export function mountShell(options: { const actions = wrap.querySelector('.shell-session-actions')!; actions.title = tr('shell.session_actions'); actions.setAttribute('aria-label', `${tr('shell.session_actions')}: ${item.label}`); const offered = new Set(sessionActionsFor(item)); + // A row with nothing to offer (a background session) has no ⋯ at all, rather than an empty menu. + actions.classList.toggle('hidden', offered.size === 0); for (const entry of Array.from(wrap.querySelectorAll('[data-session-action]'))) { const action = entry.dataset.sessionAction as ShellSessionAction; const shown = offered.has(action); @@ -565,7 +567,8 @@ export function mountShell(options: { const bulkLabel = tr(group.kind === 'previous' ? 'shell.forget_group' : 'shell.close_group', { n: String(group.items.length), name: spoken }); bulk.title = bulkLabel; bulk.setAttribute('aria-label', bulkLabel); - bulk.classList.toggle('hidden', collapsed || group.items.length < 2); // one row already has its own ⋯ close + // Background sessions are not DevDeck's to close in bulk. + bulk.classList.toggle('hidden', collapsed || group.items.length < 2 || group.kind === 'background'); // one row already has its own ⋯ close const body = section.querySelector('.shell-group-body')!; body.classList.toggle('hidden', collapsed); const cut = rendered.get(group.key)!; diff --git a/src/renderer/whatsNew.test.ts b/src/renderer/whatsNew.test.ts index 579fcbb..86e97ec 100644 --- a/src/renderer/whatsNew.test.ts +++ b/src/renderer/whatsNew.test.ts @@ -13,9 +13,9 @@ describe('when the What\'s new window opens', () => { }); it('lists only what is newer than what was last shown, newest first', () => { - expect(featuresAfter('1.41.0').map((f) => f.version)).toEqual(['1.43.0', '1.42.0']); + expect(featuresAfter('1.41.0').map((f) => f.version)).toEqual(['1.44.0', '1.43.0', '1.42.0']); const all = featuresAfter(null).map((f) => f.version); - expect(all[0]).toBe('1.43.0'); + expect(all[0]).toBe('1.44.0'); expect([...all].sort((a, b) => compareVersions(b, a))).toEqual(all); }); diff --git a/src/renderer/whatsNew.ts b/src/renderer/whatsNew.ts index d0ebf16..3c78fd5 100644 --- a/src/renderer/whatsNew.ts +++ b/src/renderer/whatsNew.ts @@ -24,6 +24,8 @@ export interface Feature { /** Newest first. A feature's NEW dot is shown on its `target` until the person uses it. */ export const FEATURES: readonly Feature[] = [ + // No Show me: the group exists only while Claude has background sessions running. + { id: 'background-agents', version: '1.44.0', icon: 'sessions' }, { id: 'worktree-sessions', version: '1.43.0', icon: 'branch', target: { view: 'cockpit', selector: '#ck-header .ck-wt-new' } }, { id: 'program-status', version: '1.42.0', icon: 'terminal', target: { view: 'settings', selector: '#set-program-status' } }, { id: 'quick-replies', version: '1.41.0', icon: 'reply', target: { view: 'cockpit', selector: '#ck-header .ck-qr-btn' } }, diff --git a/src/shared/backgroundAgents.test.ts b/src/shared/backgroundAgents.test.ts new file mode 100644 index 0000000..049e38d --- /dev/null +++ b/src/shared/backgroundAgents.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { FAILED_SHOWN_MS, isBackgroundAgentId, parseBackgroundAgents } from './backgroundAgents'; + +const NOW = 1_800_000_000_000; +// The shape `claude agents --json --all` printed on Claude Code 2.1.306. +const sample = JSON.stringify([ + { id: '09644674', cwd: 'C:\g\a', kind: 'background', startedAt: NOW - 1000, sessionId: '09644674-b035-46c9-a894-aa6733d64119', name: 'customer support response', state: 'blocked' }, + { id: 'b54cc363', cwd: 'C:\g\b', kind: 'background', startedAt: NOW - 2000, sessionId: 'b54cc363-4978-4ff3-965a-21d575a0c3d0', name: 'upscaler review', state: 'failed' }, + { id: 'aaaabbbb', cwd: 'C:\g\c', kind: 'background', startedAt: NOW - FAILED_SHOWN_MS - 1, sessionId: 'aaaabbbb-4978-4ff3-965a-21d575a0c3d0', name: 'old failure', state: 'failed' }, + { id: 'ccccdddd', cwd: 'C:\g\d', kind: 'background', startedAt: NOW - 500, sessionId: 'ccccdddd-4978-4ff3-965a-21d575a0c3d0', name: 'finished', state: 'done' }, + { pid: 19736, cwd: 'C:\g\devdeck', kind: 'interactive', startedAt: NOW, sessionId: '565a8bf4-9834-472a-aac7-e2664fa8db6f', name: 'devdeck-8d', status: 'busy' }, + { id: '; rm -rf /', cwd: 'C:\g\e', kind: 'background', startedAt: NOW, sessionId: '565a8bf4-9834-472a-aac7-e2664fa8db6f', state: 'running' }, +]); + +describe('parseBackgroundAgents', () => { + it('keeps live background sessions, newest first; drops interactive ones, finished ones and old failures', () => { + expect(parseBackgroundAgents(sample, NOW).map((b) => [b.id, b.state])).toEqual([['09644674', 'blocked'], ['b54cc363', 'failed']]); + }); + + it('never lets anything but a short hex id through — it goes on a command line', () => { + expect(isBackgroundAgentId('09644674')).toBe(true); + for (const bad of ['; rm -rf /', '0964 4674', '', 'xyz', 42, null]) expect(isBackgroundAgentId(bad)).toBe(false); + }); + + it('is empty for anything that is not the list it expects', () => { + for (const junk of ['', 'not json', '{}', 'null']) expect(parseBackgroundAgents(junk, NOW)).toEqual([]); + }); +}); diff --git a/src/shared/backgroundAgents.ts b/src/shared/backgroundAgents.ts new file mode 100644 index 0000000..59e6096 --- /dev/null +++ b/src/shared/backgroundAgents.ts @@ -0,0 +1,53 @@ +// src/shared/backgroundAgents.ts +// Claude Code's own background sessions (`claude --bg`, the agent view), as `claude agents --json` +// reports them. DevDeck never started these, so until now it never showed them — and the ones +// waiting on the person ("blocked") sat there unseen. + +export type BackgroundState = 'running' | 'working' | 'blocked' | 'idle' | 'failed'; + +export interface BackgroundAgent { + /** The short id `claude attach` takes. */ + id: string; + /** The conversation it is running. */ + sessionId: string; + cwd: string; + name: string | null; + state: BackgroundState; + startedAt: number | null; +} + +const SHORT_ID_RE = /^[0-9a-f]{6,16}$/i; +const SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +/** States worth showing; done/completed/stopped sessions are history. */ +const SHOWN = new Set(['running', 'working', 'blocked', 'idle', 'failed']); +/** A failed session stays listed this long, then is old news. */ +export const FAILED_SHOWN_MS = 7 * 24 * 3_600_000; +const MAX_LISTED = 50; + +/** `claude agents --json --all` → the background sessions worth showing, newest first. */ +export function parseBackgroundAgents(json: string, nowMs: number): BackgroundAgent[] { + let raw: unknown; + try { raw = JSON.parse(json); } catch { return []; } + if (!Array.isArray(raw)) return []; + const out: BackgroundAgent[] = []; + for (const item of raw) { + if (!item || typeof item !== 'object') continue; + const r = item as Record; + if (r.kind !== 'background') continue; + const id = typeof r.id === 'string' ? r.id : ''; + const sessionId = typeof r.sessionId === 'string' ? r.sessionId : ''; + const cwd = typeof r.cwd === 'string' ? r.cwd : ''; + const state = r.state as BackgroundState; + if (!SHORT_ID_RE.test(id) || !SESSION_ID_RE.test(sessionId) || !cwd || !SHOWN.has(state)) continue; + const startedAt = typeof r.startedAt === 'number' && Number.isFinite(r.startedAt) ? r.startedAt : null; + if (state === 'failed' && (startedAt === null || nowMs - startedAt > FAILED_SHOWN_MS)) continue; + const name = typeof r.name === 'string' && r.name.trim() ? r.name.trim().slice(0, 200) : null; + out.push({ id, sessionId, cwd, name, state, startedAt }); + } + return out.sort((a, b) => (b.startedAt ?? 0) - (a.startedAt ?? 0)).slice(0, MAX_LISTED); +} + +/** The short ids `claude attach` accepts — the only thing ever put on its command line. */ +export function isBackgroundAgentId(id: unknown): id is string { + return typeof id === 'string' && SHORT_ID_RE.test(id); +} diff --git a/src/shared/pathGuard.test.ts b/src/shared/pathGuard.test.ts index bbe3ae6..0b1883b 100644 --- a/src/shared/pathGuard.test.ts +++ b/src/shared/pathGuard.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import { join, resolve, sep } from 'node:path'; -import { isAllowedPath, isAllowedFilePath, resolveAgentFilePath, AGENT_OPEN_EXT } from './pathGuard'; +import { isAllowedPath, isAllowedFilePath, resolveAgentFilePath, tempImageRoot, AGENT_OPEN_EXT } from './pathGuard'; // Build OS-native absolute paths so the test exercises the same separator/`resolve` // semantics the guard uses on whatever platform runs it (CI runs ubuntu/macos/windows). @@ -154,3 +154,22 @@ describe('a repo\'s parallel-session workspaces', () => { expect(isAllowedPath(folders, join(repo, 'src'))).toBe(false); }); }); + +describe('tempImageRoot (images a remote viewer may read from the temp directory)', () => { + const tmp = resolve(sep, 'tmp'); + const shots = join(tmp, 'claude-chrome-screenshots-1Zrl6r'); + it('admits a Claude in Chrome screenshot, rooted at its own folder', () => { + expect(tempImageRoot(tmp, join(shots, 'screenshot-1792616816783-10.jpg'), 'devdeck-paste-')).toBe(shots); + }); + it('admits a DevDeck pasted image directly in temp', () => { + expect(tempImageRoot(tmp, join(tmp, 'devdeck-paste-abc.png'), 'devdeck-paste-')).toBe(tmp); + }); + it('refuses every other temp file', () => { + expect(tempImageRoot(tmp, join(tmp, 'other.png'), 'devdeck-paste-')).toBeNull(); + expect(tempImageRoot(tmp, join(tmp, 'someapp', 'shot.png'), 'devdeck-paste-')).toBeNull(); + expect(tempImageRoot(tmp, join(shots, 'deeper', 'shot.png'), 'devdeck-paste-')).toBeNull(); + expect(tempImageRoot(tmp, join(tmp, 'claude-chrome-screenshots-', 'shot.png'), 'devdeck-paste-')).toBeNull(); + expect(tempImageRoot(tmp, join(shots, '..', 'other.png'), 'devdeck-paste-')).toBeNull(); + expect(tempImageRoot(tmp, resolve(sep, 'tmp2', 'devdeck-paste-a.png'), 'devdeck-paste-')).toBeNull(); + }); +}); diff --git a/src/shared/pathGuard.ts b/src/shared/pathGuard.ts index b97c306..f4a0227 100644 --- a/src/shared/pathGuard.ts +++ b/src/shared/pathGuard.ts @@ -68,6 +68,27 @@ export function isAllowedFilePath(folders: Folder[], incoming: string, extraRoot return bases.some((base) => isWithin(base, incoming)); } +/** Claude in Chrome keeps its screenshots in `/claude-chrome-screenshots-/`. */ +const CHROME_SHOT_DIR = /^claude-chrome-screenshots-[A-Za-z0-9_-]{1,64}$/; + +/** + * Which folder of the temp directory an image a session named may be served from, or null when the + * file is not in one. Only two shapes count, not the whole temp directory (other applications' files + * live there too): one of DevDeck's own pasted images directly in temp (`pastePrefix`), and a + * screenshot Claude in Chrome saved in its own folder one level down — the path Claude prints as + * `[image] ~\AppData\Local\Temp\claude-chrome-screenshots-…\screenshot-….jpg`. + */ +export function tempImageRoot(tmp: string, file: string, pastePrefix: string): string | null { + const t = canon(tmp); + const f = canon(file); + if (!f.startsWith(t + sep)) return null; + const parts = f.slice(t.length + 1).split(sep); + if (parts.length === 1) return parts[0].startsWith(FOLD_CASE ? pastePrefix.toLowerCase() : pastePrefix) ? resolve(tmp) : null; + const dir = resolve(file, '..'); + const name = dir.slice(dir.lastIndexOf(sep) + 1); + return parts.length === 2 && CHROME_SHOT_DIR.test(name) ? dir : null; +} + /** * File extensions the terminal click-to-open accepts (opened via the OS DEFAULT handler, so this is * a strict inert-content allowlist): raster images, audio, video, and plain documents. Deliberately diff --git a/src/shared/shellNavigation.test.ts b/src/shared/shellNavigation.test.ts index 3fb7e39..b77eed7 100644 --- a/src/shared/shellNavigation.test.ts +++ b/src/shared/shellNavigation.test.ts @@ -220,3 +220,14 @@ describe('shell navigation', () => { .toBe('Review API, feature/api · Codex, Awaiting you, Rewriting the auth guard'); }); }); + +describe('Claude background sessions in the sidebar', () => { + it('get a group of their own, under what is live here, and offer no close or rename', () => { + const live: ShellSessionInput = { id: 'a', projectPath: 'C:/a', label: 'a', detail: '', activity: 'turn', pinned: false }; + const bg: ShellSessionInput = { id: 'bg:local:09644674', projectPath: 'C:/b', label: 'b', detail: '', activity: 'attention', pinned: false, backgroundAgent: true }; + expect(buildSessionGroups([live, bg]).map((g) => g.kind)).toEqual(['background', 'turn']); + expect(sessionActionsFor(bg)).toEqual([]); + // Waiting on the person, so the collapsed rail still counts it. + expect(sessionStatusCounts([live, bg]).attention).toBe(1); + }); +}); diff --git a/src/shared/shellNavigation.ts b/src/shared/shellNavigation.ts index e6df421..c4ea635 100644 --- a/src/shared/shellNavigation.ts +++ b/src/shared/shellNavigation.ts @@ -23,6 +23,11 @@ export interface ShellSessionInput { machineId?: string | null; /** What that machine is called, for the group heading. */ machineLabel?: string | null; + /** + * One of Claude Code's own background sessions (`claude --bg`), which DevDeck did not start: + * listed in a group of its own, and opened by attaching to it. + */ + backgroundAgent?: boolean; } export interface ShellProjectInput { @@ -33,7 +38,7 @@ export interface ShellProjectInput { pinned?: boolean; } -export type ShellGroupKind = 'attention' | 'working' | 'pinned' | 'remote' | 'turn' | 'quiet' | 'previous'; +export type ShellGroupKind = 'attention' | 'working' | 'background' | 'pinned' | 'remote' | 'turn' | 'quiet' | 'previous'; export interface ShellSessionGroup { kind: ShellGroupKind; @@ -71,6 +76,7 @@ export type ShellSessionAction = 'pin' | 'unpin' | 'rename' | 'close' | 'forget' * pinned or forgotten instead. Pin/unpin is one toggle, labelled by the row's current state. */ export function sessionActionsFor(item: ShellSessionInput): ShellSessionAction[] { const pin: ShellSessionAction = item.pinned ? 'unpin' : 'pin'; + if (item.backgroundAgent === true) return []; // nothing here to rename or close: opening it attaches return item.previous === true ? [pin, 'forget'] : [pin, 'rename', 'close']; } @@ -106,10 +112,13 @@ export function sessionAccessibleLabel(item: ShellSessionInput, localizedStatus: return item.summary ? `${base}, ${item.summary}` : base; } -const groupOrder: ShellGroupKind[] = ['attention', 'working', 'pinned', 'remote', 'turn', 'quiet', 'previous']; +// Background sessions sit just under what is live here: they are work in progress, and the ones +// waiting on the person were exactly what went unseen before they were listed. +const groupOrder: ShellGroupKind[] = ['attention', 'working', 'background', 'pinned', 'remote', 'turn', 'quiet', 'previous']; function groupOf(item: ShellSessionInput): ShellGroupKind { if (item.previous) return 'previous'; // a saved entry has no terminal anywhere yet + if (item.backgroundAgent) return 'background'; // Before its own group, a session on another computer sat among the local ones with a marker // buried in its detail line. Which machine a terminal is on decides where your keystrokes land, // so it outranks what the session happens to be doing — high enough in the order that an active