diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0fdcdb2..bdc7ffa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,7 +82,7 @@ jobs: matrix: os: [ubuntu-latest, windows-latest] runs-on: ${{ matrix.os }} - timeout-minutes: 10 + timeout-minutes: 20 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 @@ -114,6 +114,22 @@ jobs: - name: Verify host and two viewers agree on committed session titles if: runner.os == 'Windows' run: node qa/session-sync.mjs + - name: Performance gate (fixture agent; animations, DOM churn, main-process stalls) + if: runner.os == 'Windows' + run: node qa/perf-gate.mjs + # One step per harness: a multi-line pwsh `run` fails only on its LAST command's exit code. + - name: Session images and attach + if: runner.os == 'Windows' + run: node qa/images.mjs + - name: Session images from a paired host + if: runner.os == 'Windows' + run: node qa/images-remote.mjs + - name: Update all Claude sessions + if: runner.os == 'Windows' + run: node qa/claude-update.mjs + - name: Restored agents start one at a time + if: runner.os == 'Windows' + run: node qa/restore-pacing.mjs - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 if: always() with: @@ -122,3 +138,7 @@ jobs: qa/shots/resilience/ qa/shots/maintenance/ qa/shots/session-sync/ + qa/shots/perf-gate/ + qa/shots/images/ + qa/shots/images-remote/ + qa/shots/claude-update/ diff --git a/README.md b/README.md index 4521a7a..f26ff78 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.38.0** makes closing a session reliable across paired PCs, fixes stale terminal drawing, adds **Apply update** for Claude Code on Windows, protects notes from being overwritten by another device, and cuts background CPU use ([release notes](docs/releases/1.38.0.md)). Use 1.38.0 on both machines for confirmed remote close and versioned notes; 1.37.9 added confirmed session renames and task conflict protection ([notes](docs/releases/1.37.9.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.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).
@@ -32,6 +32,8 @@ DevDeck is a local-first desktop command center for **Claude Code, Codex, and An
| --- | --- |
| Find work that needs attention | Shared sidebar for working/waiting sessions; Quick Open with `Ctrl+Shift+P`; recent projects |
| Resume without losing context | Provider-aware Open, existing terminal focus, previous-session restore, local Project Memory |
+| See what the agent looked at | Image strip of a Claude session's screenshots, pasted images and written image files, with a full-size viewer — local or remote |
+| Hand the agent a file | 📎 or drag-and-drop onto the terminal; images are sent to a remote PC and typed as its path |
| Track a repository | Branch, dirty files, unpushed commits, activity, notes, project pin/hide and external actions |
| Plan and inspect usage | Cross-project tasks and calendar; local token/API-equivalent cost estimates; supported live provider limits |
| Use another PC | Opt-in DevDeck Link: the agent runs on the host; the viewer sends input and receives terminal output |
diff --git a/docs/quality.md b/docs/quality.md
index 2e63394..8c330c9 100644
--- a/docs/quality.md
+++ b/docs/quality.md
@@ -28,9 +28,11 @@ Results and screenshots are written to `qa/shots/maintenance/` and `qa/shots/res
## Manual harnesses
-`qa:link` is a supervised two-app harness that may start installed provider CLIs. `qa:close` is a supervised two-app harness for the session close lifecycle (remote, offline, refused and bulk close, adoption focus, restart, Apply update); it starts the installed `claude` CLI in a throwaway repository and sends no prompts. `npm run qa:perf -- 4 --allow-live-agents` is a manual Windows benchmark and explicitly opts into real-agent startup. Performance QA refuses missing terminals, invisible workload, incomplete streaming/repaint activity and missing CPU samples instead of treating low CPU as success. It has not become a deterministic CI performance gate; that conversion remains a separate task.
+`qa:link` is a supervised two-app harness that may start installed provider CLIs. `qa:close` is a supervised two-app harness for the session close lifecycle (remote, offline, refused and bulk close, adoption focus, restart, Apply update); it starts the installed `claude` CLI in a throwaway repository and sends no prompts. `npm run qa:perf -- 4 --allow-live-agents` is a manual Windows benchmark and explicitly opts into real-agent startup. Performance QA refuses missing terminals, invisible workload, incomplete streaming/repaint activity and missing CPU samples instead of treating low CPU as success. Its CPU percentages are for a person to read, not for CI.
-`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. None of these starts a real agent.
+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). 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.39.0.md b/docs/releases/1.39.0.md
new file mode 100644
index 0000000..1f39a5a
--- /dev/null
+++ b/docs/releases/1.39.0.md
@@ -0,0 +1,38 @@
+# DevDeck 1.39.0
+
+Includes 1.38.1, which was tagged but not published separately.
+
+## See what a session looked at
+
+- **Image strip.** The 🖼 button in a Claude session's header shows the screenshots its tools took (Playwright, Windows MCP, Blender and others), the images you pasted, and the image files it wrote — newest first, between the header and the terminal. Click one for a full-size view: ←/→ step through them, Esc closes, Save keeps a copy. Images that arrived while the strip was closed show as **+N** on the button.
+- It reads the session's own transcript, where those screenshots exist and nowhere else, and only what was added since the last look (a 133 MB transcript: 100 ms the first time, about 1 ms after).
+- Works for a session on another PC: thumbnails are made on that PC, so a 4K screenshot crosses the connection as a ~30 KB preview.
+- Clicking an image path in a remote session's terminal now shows the image instead of "that file is on another PC".
+
+## Attach files
+
+- **📎** in the session header, or drag files onto the terminal. On this PC the file's own path is typed into the prompt.
+- For a session on another PC, images (PNG, JPEG, GIF, WebP, BMP) are sent there as PNG — scaled down if needed — and the path on that PC is typed. Anything that could not be attached is reported.
+
+## Claude Code updates on Windows
+
+- On Windows, Claude Code cannot update itself while any session is running it, so with several sessions open its background update keeps failing. When a session shows "Auto-update failed", or "Update installed" while other sessions are open, the header offers **Update all (N)**: it waits for running turns (and permission prompts) to finish, stops every Claude session on this PC, runs `claude update`, and reopens each conversation. Sessions are saved first, so quitting DevDeck in the middle loses nothing.
+
+## Fewer freezes when sessions come back
+
+- Restored sessions now start one at a time, each after the previous one has finished starting. Before, a reboot, an update or **Restore all** started every agent at once. One `claude --resume` of a large conversation was measured at 1.6 GB and ~10 CPU-seconds while starting, four at once at 6.6 GB and 88 CPU-seconds within five seconds — enough to stall a PC for minutes.
+
+## Usage footer
+
+- No more daily "Login expired". Claude's access token lasts about eight hours and is renewed by Claude Code on its next reply; while the 30-day refresh token is still valid the footer now says **Awaiting refresh** and keeps the last numbers, with no sign-in prompt. Sign-in is offered only when the refresh token is gone or rejected. *(1.38.1)*
+
+## Security
+
+- A paired device with only "observe" permission can read just the images a session's own record lists. Image files are checked by their contents, must really be inside a project folder (symlinks and junctions are resolved), and full-size images are kept small enough that they can never drop the connection.
+
+## Under the hood
+
+- New automated checks run on every change: a performance gate (no animation or DOM churn on an idle deck, no main-process stalls while streaming — each limit verified by injecting the regression it guards against), the image strip locally and across two paired PCs, attach, Update all, and staggered restores.
+- TypeScript 7, Vitest 5, Playwright 1.64; `@types/node` now matches Electron's Node 24.
+
+The image strip, attach and remote image viewing need 1.39.0 on the PC running the session; with an older host the strip stays empty and remote image paths keep the old message.
diff --git a/package.json b/package.json
index 280e720..a38251f 100644
--- a/package.json
+++ b/package.json
@@ -37,12 +37,14 @@
"qa:close": "npm run build && node qa/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",
"qa:maintenance": "npm run build && node qa/maintenance.mjs",
"check:docs": "node scripts/check-docs.mjs",
"qa:session-sync": "npm run build && node qa/session-sync.mjs",
"qa:images": "npm run build && node qa/images.mjs",
"qa:images-remote": "npm run build && node qa/images-remote.mjs",
- "qa:update": "npm run build && node qa/claude-update.mjs"
+ "qa:update": "npm run build && node qa/claude-update.mjs",
+ "qa:restore-pacing": "npm run build && node qa/restore-pacing.mjs"
},
"build": {
"appId": "com.soursea.devdeck",
diff --git a/qa/perf-gate.mjs b/qa/perf-gate.mjs
new file mode 100644
index 0000000..086506d
--- /dev/null
+++ b/qa/perf-gate.mjs
@@ -0,0 +1,196 @@
+// Deterministic performance gate (Windows CI): the regressions DevDeck has actually shipped, measured
+// in units that do not depend on how fast the runner is.
+//
+// qa/perf.mjs is the manual benchmark — real agents, CPU percentages, a person reading the numbers.
+// CPU percentages are meaningless across CI hardware, and real agents cost money, so this gate asks
+// different questions of a fixture agent that prints a known workload:
+//
+// - IDLE: is anything animating or rewriting the DOM when nothing is happening? Every "the app got
+// heavy" report so far was a screen that never stopped drawing (a CSS spinner composited at 60fps,
+// a sidebar rebuilt on every PTY chunk). Counted, not timed.
+// - WORKING: one tile repainting its whole screen at 10fps the way an agent's TUI does. Does the
+// main process keep up (event-loop stall), and does the UI thread stay responsive (long tasks)?
+// - STREAM: 20,000 lines into a visible terminal. The "other PC freezes for minutes" class is a main
+// process that stops answering; its longest stall is the number that says so.
+//
+// Limits are set several times above what this machine measures, so a slow runner passes and a real
+// regression (an order of magnitude) does not. Never starts a real agent.
+import { _electron as electron } from 'playwright';
+import assert from 'node:assert/strict';
+import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
+import { execFileSync } from 'node:child_process';
+import { tmpdir } from 'node:os';
+import { dirname, join, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { closeElectron } from './electron-lifecycle.mjs';
+
+const root = dirname(dirname(fileURLToPath(import.meta.url)));
+const out = resolve(process.env.DEVDECK_PERF_OUT || join(root, 'qa', 'shots', 'perf-gate'));
+mkdirSync(out, { recursive: true });
+if (process.platform !== 'win32') { console.log('SKIP: Windows native PTY required'); process.exit(0); }
+
+const LIMITS = {
+ idleRunningAnimations: 0, // nothing may animate on a quiet deck
+ idleMutations: 40, // DOM mutation records in 6s of quiet (a rebuild-per-tick loop is thousands)
+ idleLongTasks: 0,
+ workingMainStallMs: 250, // longest main-process event-loop stall while a tile repaints at 10fps
+ workingLongestTaskMs: 250,
+ workingOutsideMutations: 400, // sidebar/header/footer while two tiles repaint at 10fps for 8s (a rebuild per chunk is ~10k)
+ streamMainStallMs: 600, // longest main-process stall while 20k lines stream
+ streamLongestTaskMs: 600,
+ streamSeconds: 45, // the stream must finish drawing at all
+};
+
+const sandbox = mkdtempSync(join(tmpdir(), 'devdeck-perf-gate-'));
+const repo = join(sandbox, 'perf-workspace');
+const bin = join(sandbox, 'bin');
+const profile = join(sandbox, 'profile');
+const home = join(profile, 'home');
+for (const p of [repo, bin, home]) mkdirSync(p, { recursive: true });
+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'), `
+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> ');
+screen('PERF_FIXTURE_READY');
+if (process.stdin.isTTY) process.stdin.setRawMode(true);
+const glyphs = ['\\u00b7', '\\u2722', '\\u2733', '\\u2736', '\\u273b', '\\u273d'];
+process.stdin.on('data', (d) => {
+ const k = d.toString();
+ if (k === 'W') {
+ let i = 0; const end = Date.now() + 8000;
+ const t = setInterval(() => {
+ i++;
+ const rows = Array.from({ length: 34 }, (_, r) => 'row ' + r + ' frame ' + i + ' ' + 'x'.repeat(90));
+ out(E + '[H' + rows.join('\\r\\n') + '\\r\\n' + glyphs[i % glyphs.length] + ' Thinking\\u2026 (' + i + 's \\u00b7 esc to interrupt)');
+ if (Date.now() > end) { clearInterval(t); screen('WORK_DONE'); }
+ }, 100);
+ } else if (k === 'S') {
+ let n = 0;
+ const pump = () => { let chunk = ''; for (let j = 0; j < 500 && n < 20000; j++) chunk += 'perf line ' + (++n) + ' ' + 'y'.repeat(60) + '\\r\\n'; out(chunk); if (n < 20000) setImmediate(pump); else out('STREAM_DONE\\r\\n'); };
+ pump();
+ }
+});
+setInterval(() => {}, 1 << 30);
+`);
+writeFileSync(join(bin, 'claude.cmd'), '@echo off\r\nnode "%~dp0agent.js" %*\r\n');
+const sids = ['8d4e5f6a-1111-4222-8333-944455556666', '9e5f6a7b-1111-4222-8333-944455556666'];
+const tdir = join(home, '.claude', 'projects', repo.replace(/[^a-zA-Z0-9]/g, '-'));
+mkdirSync(tdir, { recursive: true });
+for (const sid of sids) writeFileSync(join(tdir, sid + '.jsonl'), JSON.stringify({ type: 'user', timestamp: new Date().toISOString(), cwd: repo, sessionId: sid, message: { role: 'user', content: 'hi' } }) + '\n');
+writeFileSync(join(profile, 'state.json'), JSON.stringify({ projects: {}, settings: {
+ language: 'en', folders: [{ path: repo, kind: 'repo' }], viewMode: 'list',
+ cockpitSessions: sids.map((sid, i) => ({ tileId: 'tile-perf-' + i, projectPath: repo, name: 'perf-workspace', sessionId: sid, agentId: 'claude', label: 'Perf ' + i, lastActiveMs: Date.now() })),
+} }));
+const env = { ...process.env, HOME: home, USERPROFILE: home, CLAUDECODE: '', CLAUDE_CODE_SSE_PORT: '', CLAUDE_CODE_ENTRYPOINT: '' };
+const pathKey = Object.keys(env).find((k) => k.toLowerCase() === 'path') || 'PATH';
+env[pathKey] = bin + ';' + env[pathKey];
+
+const app = await electron.launch({ args: ['.', '--user-data-dir=' + profile, '--no-sandbox'], cwd: root, env });
+const results = {};
+const failures = [];
+const judge = (name, value, limit) => {
+ results[name] = { value, limit };
+ const ok = value <= limit;
+ console.log(`${ok ? 'PASS' : 'FAIL'} ${name}: ${value} (limit ${limit})`);
+ if (!ok) failures.push(name);
+};
+try {
+ const win = await app.firstWindow();
+ win.setDefaultTimeout(30000);
+ await win.setViewportSize?.({ width: 1500, height: 950 }).catch(() => {});
+ await win.waitForSelector('#shell-session-groups .shell-session', { timeout: 30000 });
+ // Both tiles running, one on screen.
+ const restoreAll = win.getByRole('button', { name: /Restore all/i });
+ if (await restoreAll.count()) await restoreAll.first().click();
+ await win.waitForFunction(() => document.querySelectorAll('.ck-term').length >= 2, null, { timeout: 30000 });
+ await win.locator('#shell-session-groups .shell-session').first().click();
+ const ids = await win.evaluate(() => window.devdeck.cockpit.liveSessions().then((list) => list.map((s) => s.id)));
+ assert.ok(ids.length >= 2, 'two sessions running: ' + JSON.stringify(ids));
+ // Read off each session's output buffer, not the visible rows: a screen printed before the terminal
+ // reached its final size can scroll the marker out of view, which says nothing about performance.
+ const allPrinted = (marker, ms) => win.waitForFunction(async ([list, m]) => {
+ const bufs = await Promise.all(list.map((i) => window.devdeck.cockpit.sessionBuffer(i)));
+ return bufs.every((b) => b.includes(m));
+ }, [ids, marker], { timeout: ms, polling: 250 });
+ await allPrinted('PERF_FIXTURE_READY', 30000);
+ // The workload goes to every running session — one is on screen, the other works in the background,
+ // which is how a deck is actually used.
+ const sendAll = (key) => win.evaluate(([list, k]) => { for (const i of list) window.devdeck.cockpit.input(i, k); }, [ids, key]);
+ await win.waitForTimeout(3000); // let restore, meta and git refreshes settle
+
+ // ---- instruments ---------------------------------------------------------------------------
+ await win.evaluate(() => {
+ window.__gate = { long: [], mutations: 0, outside: 0 };
+ new PerformanceObserver((l) => { for (const e of l.getEntries()) window.__gate.long.push(Math.round(e.duration)); }).observe({ entryTypes: ['longtask'] });
+ new MutationObserver((records) => {
+ window.__gate.mutations += records.length;
+ // Outside the terminals: the sidebar, header and footer. A terminal redraws by design while its
+ // agent works; the chrome around it must not rebuild along with every chunk.
+ for (const r of records) { const t = r.target.nodeType === 1 ? r.target : r.target.parentElement; if (!t?.closest?.('.ck-terms')) window.__gate.outside++; }
+ }).observe(document.body, { subtree: true, childList: true, attributes: true, characterData: true });
+ });
+ // Main-process event-loop stall: a 20ms timer's lateness, the longest of the window.
+ await app.evaluate(() => {
+ globalThis.__lag = { max: 0, last: Date.now() };
+ globalThis.__lagTimer = setInterval(() => { const now = Date.now(); globalThis.__lag.max = Math.max(globalThis.__lag.max, now - globalThis.__lag.last - 20); globalThis.__lag.last = now; }, 20);
+ });
+ const reset = async () => {
+ await win.evaluate(() => { window.__gate.long = []; window.__gate.mutations = 0; window.__gate.outside = 0; });
+ await app.evaluate(() => { globalThis.__lag.max = 0; globalThis.__lag.last = Date.now(); });
+ };
+ const read = async () => ({
+ ...(await win.evaluate(() => ({ long: [...window.__gate.long], mutations: window.__gate.mutations, outside: window.__gate.outside }))),
+ stall: await app.evaluate(() => globalThis.__lag.max),
+ });
+
+ // ---- IDLE ----------------------------------------------------------------------------------
+ await reset();
+ await win.waitForTimeout(6000);
+ const idle = await read();
+ const running = await win.evaluate(() => document.getAnimations().filter((a) => a.playState === 'running').map((a) => {
+ const el = a.effect?.target;
+ return `${a.animationName ?? a.constructor.name} on ${el?.className ?? el?.tagName ?? '?'}`;
+ }));
+ if (running.length) console.log(' running animations: ' + running.join(' | '));
+ judge('idle running animations', running.length, LIMITS.idleRunningAnimations);
+ judge('idle DOM mutation records (6s)', idle.mutations, LIMITS.idleMutations);
+ judge('idle long tasks', idle.long.length, LIMITS.idleLongTasks);
+ await win.screenshot({ path: join(out, 'idle.png') });
+
+ // ---- WORKING -------------------------------------------------------------------------------
+ await reset();
+ await sendAll('W');
+ await allPrinted('WORK_DONE', 30000);
+ const working = await read();
+ judge('working: main-process stall ms', working.stall, LIMITS.workingMainStallMs);
+ judge('working: longest UI task ms', Math.max(0, ...working.long), LIMITS.workingLongestTaskMs);
+ judge('working: DOM mutation records outside the terminals (8s)', working.outside, LIMITS.workingOutsideMutations);
+
+ // ---- STREAM --------------------------------------------------------------------------------
+ await win.waitForTimeout(1500);
+ await reset();
+ const t0 = Date.now();
+ await sendAll('S');
+ await allPrinted('STREAM_DONE', LIMITS.streamSeconds * 1000);
+ // ...and drawn: the visible terminal has caught up with it.
+ await win.waitForFunction(() => [...document.querySelectorAll('.ck-term.show .xterm-rows')].some((r) => r.textContent.includes('STREAM_DONE')), null, { timeout: LIMITS.streamSeconds * 1000 });
+ const streamSeconds = Math.round((Date.now() - t0) / 100) / 10;
+ // The window includes what follows a stream — scrollback trimming, reflow, the deck catching up —
+ // so a stall that lands just after the last line is still inside it.
+ await win.waitForTimeout(3000);
+ const stream = await read();
+ judge('stream: seconds to draw 20k lines', streamSeconds, LIMITS.streamSeconds);
+ judge('stream: main-process stall ms', stream.stall, LIMITS.streamMainStallMs);
+ judge('stream: longest UI task ms', Math.max(0, ...stream.long), LIMITS.streamLongestTaskMs);
+ await win.screenshot({ path: join(out, 'stream.png') });
+} catch (e) {
+ console.log('FAIL harness: ' + (e?.message ?? e));
+ failures.push('harness');
+} finally {
+ writeFileSync(join(out, 'results.json'), JSON.stringify({ results, failures }, null, 2));
+ await closeElectron(app).catch(() => {});
+}
+if (failures.length) { console.log(`\nFAIL: ${failures.join(', ')}`); process.exitCode = 1; } else console.log('\nPASS: performance gate');
diff --git a/qa/restore-pacing.mjs b/qa/restore-pacing.mjs
new file mode 100644
index 0000000..eae3ae3
--- /dev/null
+++ b/qa/restore-pacing.mjs
@@ -0,0 +1,78 @@
+// Windows harness: restored agents start ONE AT A TIME — each only after the one before it finished
+// starting. A fixture agent takes 2s to boot (the way `claude --resume` of a large transcript does)
+// and records when it was launched; the gaps between launches must cover that boot, both for the
+// sessions put back at launch and for "Restore all". Never starts a real agent.
+// Run with npm run qa:restore-pacing.
+import { _electron as electron } from 'playwright';
+import assert from 'node:assert/strict';
+import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
+import { execFileSync } from 'node:child_process';
+import { tmpdir } from 'node:os';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { closeElectron } from './electron-lifecycle.mjs';
+
+const root = dirname(dirname(fileURLToPath(import.meta.url)));
+if (process.platform !== 'win32') { console.log('SKIP: Windows native PTY required'); process.exit(0); }
+const sandbox = mkdtempSync(join(tmpdir(), 'devdeck-restore-pacing-'));
+const repo = join(sandbox, 'pacing-workspace');
+const bin = join(sandbox, 'bin');
+const profile = join(sandbox, 'profile');
+const home = join(profile, 'home');
+const log = join(sandbox, 'starts.log');
+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'), `
+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});
+if (process.stdin.isTTY) process.stdin.setRawMode(true);
+process.stdin.on('data', () => {});
+setInterval(() => {}, 1 << 30);
+`);
+writeFileSync(join(bin, 'claude.cmd'), '@echo off\r\nnode "%~dp0agent.js" %*\r\n');
+
+const N = 4;
+const sids = Array.from({ length: N }, (_, i) => `${i + 1}a2b3c4d-1111-4222-8333-944455556666`);
+const tdir = join(home, '.claude', 'projects', repo.replace(/[^a-zA-Z0-9]/g, '-'));
+mkdirSync(tdir, { recursive: true });
+for (const sid of sids) writeFileSync(join(tdir, sid + '.jsonl'), JSON.stringify({ type: 'user', timestamp: new Date().toISOString(), cwd: repo, sessionId: sid, message: { role: 'user', content: 'hi' } }) + '\n');
+writeFileSync(join(profile, 'state.json'), JSON.stringify({ projects: {}, settings: {
+ language: 'en', folders: [{ path: repo, kind: 'repo' }], viewMode: 'list',
+ cockpitSessions: sids.map((sid, i) => ({ tileId: 'tile-pace-' + i, projectPath: repo, name: 'pacing-workspace', sessionId: sid, agentId: 'claude', label: 'Pace ' + i, lastActiveMs: Date.now() })),
+} }));
+const env = { ...process.env, HOME: home, USERPROFILE: home, CLAUDECODE: '', CLAUDE_CODE_SSE_PORT: '', CLAUDE_CODE_ENTRYPOINT: '' };
+const pathKey = Object.keys(env).find((k) => k.toLowerCase() === 'path') || 'PATH';
+env[pathKey] = bin + ';' + env[pathKey];
+
+const starts = () => (existsSync(log) ? readFileSync(log, 'utf8').trim().split(/\r?\n/).filter(Boolean).map((l) => Number(l.split(' ')[0])) : []);
+const gaps = (ts) => ts.slice(1).map((t, i) => t - ts[i]);
+const app = await electron.launch({ args: ['.', '--user-data-dir=' + profile, '--no-sandbox', '--disable-gpu'], cwd: root, env });
+let failed = false;
+try {
+ const win = await app.firstWindow();
+ win.setDefaultTimeout(60000);
+ await win.waitForSelector('#shell-session-groups .shell-session', { timeout: 30000 });
+ // Whatever launch put back, then the rest by "Restore all".
+ const restoreAll = win.getByRole('button', { name: /Restore all/i });
+ const until = Date.now() + 60000;
+ while (Date.now() < until) {
+ if (await restoreAll.count() && await restoreAll.first().isVisible() && await restoreAll.first().isEnabled()) { await restoreAll.first().click(); break; }
+ if (starts().length >= N) break;
+ await win.waitForTimeout(500);
+ }
+ while (Date.now() < until && starts().length < N) await win.waitForTimeout(500);
+ const ts = starts();
+ console.log(`starts: ${ts.length} gaps(ms): ${gaps(ts).join(', ')}`);
+ assert.equal(ts.length, N, 'every saved session started');
+ // Each agent draws for BOOT_MS and then needs a quiet moment before the next may start.
+ for (const g of gaps(ts)) assert.ok(g >= BOOT_MS, `next agent started ${g}ms after the previous one, before it finished starting`);
+ console.log('PASS restored agents start one at a time, each after the previous finished starting');
+} catch (e) {
+ console.log('FAIL', e?.message ?? e);
+ failed = true;
+} finally {
+ await closeElectron(app).catch(() => {});
+}
+if (failed) process.exitCode = 1;
diff --git a/src/renderer/cockpitView.ts b/src/renderer/cockpitView.ts
index 3cbaddc..00d2036 100644
--- a/src/renderer/cockpitView.ts
+++ b/src/renderer/cockpitView.ts
@@ -318,7 +318,47 @@ async function autoRestoreAfterUpdate(pending: PersistedSession[]): Promise