diff --git a/README.md b/README.md
index 8169482..4521a7a 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.37.9** adds confirmed session renames and task conflict protection ([release notes](docs/releases/1.37.9.md)). Use 1.37.9 on both machines for the new write protocol. See [the maintenance review](docs/quality-review-2026-09-08.md) and [session synchronization review](docs/session-sync-review-2026-09-08.md).
+> **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).

@@ -83,7 +83,7 @@ Both machines must be reachable, for example on the same LAN, through an existin
| Application settings | Belong to the PC whose Settings screen you are using. |
| Terminal screen | The host provides bounded recent output for reconstruction, not an unlimited durable archive. |
-**Compatibility:** confirmed titles and task editing need compatible 1.37.9-or-newer builds on both ends. The app checks title-version support and refuses unversioned writes rather than silently treating a viewer-only rename as shared success. Titles of disconnected or ended sessions cannot be remotely committed.
+**Compatibility:** confirmed titles and task editing need compatible 1.37.9-or-newer builds on both ends; confirmed remote close and versioned notes need 1.38.0 on both ends (with an older peer they fall back to the previous behavior). The app checks title-version support and refuses unversioned writes rather than silently treating a viewer-only rename as shared success. Titles of disconnected or ended sessions cannot be remotely committed.
Reconnecting reads the host's current title; it must not send an old cached title back. Clearing a custom title is an explicit change to the default project name, not an invitation to restore the old name. See [state ownership and consistency](docs/state-consistency.md) for the implementation contract and remaining gaps.
diff --git a/docs/backlog.md b/docs/backlog.md
index 7e843d1..3940602 100644
--- a/docs/backlog.md
+++ b/docs/backlog.md
@@ -12,9 +12,12 @@ Updated 2026-09-08. The 1.37.9 maintenance work was released on 2026-10-15. Impl
| P2 | Persistent terminal history | Separate screen reconstruction from archive; bounded retention, deletion/export, redaction and visible gaps |
| P2 | Task operation API | Task-ID mutations after revision-checked baseline; explicit same-task conflicts and concurrent create/edit/delete/clear-completed checks |
| P1 | Remaining shared-metadata migrations | Apply the [ownership contract](state-consistency.md) to notes, project flags and Link settings; explicit failure/conflict UI and multi-observer QA |
-| P2 | Note concurrency | Notes remain last-write-wins across devices; add revision checks while preserving drafts |
| P2 | Focused module extraction | Terminal lifecycle/rendering boundaries protected by regression tests; no rewrite solely for file length |
+## Implemented in 1.38.0
+
+Reliable session close across paired PCs (answered remote close, offline closes delivered on reconnect, tombstones against re-adoption, `closed` exits), DOM terminal rendering, Apply update for Claude Code, versioned notes, Windows process-tree kill, Link pre-auth/notify/event hardening, scan performance and spinner CPU reduction, and the supervised `qa:close` harness. See the [release notes](releases/1.38.0.md).
+
## Implemented in 1.37.9
Revision-checked task writes, legacy refusal, conflict review with retained attempted change, settings save rollback and inline validation, js-yaml/Vitest advisory fixes, Node24 CI, maintenance regression gates, fail-closed performance preconditions, and current documentation/maintenance configuration. See the candidate review for evidence and limits.
diff --git a/docs/quality.md b/docs/quality.md
index f36be88..00ba5c2 100644
--- a/docs/quality.md
+++ b/docs/quality.md
@@ -28,7 +28,7 @@ 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. `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. It has not become a deterministic CI performance gate; that conversion remains a separate task.
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.38.0.md b/docs/releases/1.38.0.md
new file mode 100644
index 0000000..a0dc25c
--- /dev/null
+++ b/docs/releases/1.38.0.md
@@ -0,0 +1,36 @@
+# DevDeck 1.38.0
+
+## Closing sessions
+
+- A session closed on one PC disappears from every paired PC. It no longer stays behind as an "exited" tile that was saved and reopened as a previous session — or started again by an update restart.
+- Closing several sessions at once no longer brings some of them back as dead tiles.
+- Closing a session on another PC now waits for that PC's answer. If the PC is offline, the close is delivered when it reconnects (also after restarting DevDeck). If this device may not control that PC's sessions, the tile comes back with an explanation instead of looking closed while the agent keeps running.
+- Closing a session on Windows also ends what the agent started in the background (MCP servers, hidden dev servers).
+- A renderer crash no longer stops every running agent; the window reloads and picks the sessions back up.
+
+## Terminals
+
+- Terminals draw with the DOM renderer. Under the WebGL renderer, Claude Code's screen could keep stale cells — old status lines stacked down the screen and earlier text showing through gaps — until the window was minimized and restored.
+- When Claude Code shows "Update installed · Restart to apply", the session header offers **Apply update**. It restarts the session on the new version and keeps the conversation; during a running turn it waits for the turn to finish. (Claude Code's own `/restart` is not available on Windows.)
+- A session started from another PC no longer takes the screen or keyboard focus.
+- "+ New session" on a remote session opens on that PC. Opening from the task board or the project memory window opens on the machine those projects belong to.
+- Resizing the window no longer marks every idle session as working.
+
+## Data safety
+
+- Notes are saved with a revision. If another device saved the note first, both versions are shown so nothing is overwritten unseen. Older paired builds keep working without the check.
+- Usage totals could double when two scans read the same growing transcript at once; they no longer do.
+- The project card shows the real number of sessions instead of stopping at 5.
+
+## Performance
+
+- Working-session spinners cost roughly a quarter of the CPU they did (measured 16.0% → 3.6% of one core with 12 sessions, 6 working) and stop while DevDeck is not focused.
+- Codex session lookups and usage scans no longer stall the app (100 projects: 18.8 s → 0.3 s; longest stall 84–99 ms → 6–11 ms).
+
+## Security and robustness
+
+- Paired machines: stricter checks for unauthenticated connections, request types and rate limits, event forgery, audit-log flooding, removed machines reconnecting, and remote writes to paths that are not real projects.
+- Opening a folder in VS Code no longer expands `%VAR%` in its name; `;` in folder names no longer breaks Windows Terminal tabs.
+- Electron 43.7, no open dependency advisories.
+
+Remote close confirmation and versioned notes need 1.38.0 on both PCs to take effect; with an older peer DevDeck falls back to the previous behavior.
diff --git a/docs/state-consistency.md b/docs/state-consistency.md
index 780ab68..fe2dc07 100644
--- a/docs/state-consistency.md
+++ b/docs/state-consistency.md
@@ -15,7 +15,9 @@ Related paths treated cached session labels as authority when attaching, used ba
| Session title | Main process of the machine running the terminal | Permission + terminal incarnation + expected label revision; persist before publishing/acknowledging | All renderers, including the host, consume the owner snapshot. Drafts are separate. |
| Saved session membership and pins | Each viewer | Local persistence; overlay host-owned titles when reconciling local live sessions | Never send cached titles back merely because a viewer attached or restarted. |
| Project task list | Main process owning the project | Expected task revision; reject conflicts; no silent retry | Display returned snapshot; preserve unsaved attempted change. |
-| Project notes/flags | Project-owning machine | Existing last-write-wins behavior | Not yet covered by title/task CAS guarantees. |
+| Project notes | Project-owning machine | Expected note revision (`project:saveNote`, 1.38.0); legacy `setNote` still accepted and bumps the revision | On conflict show both the saved note and the attempted one; nothing is overwritten unseen. |
+| Project flags (pin/hide) | Project-owning machine | Last-write-wins; remote writes only to scanned projects | Not yet covered by CAS guarantees. |
+| Session membership (close) | Machine running the terminal | `cockpit:closeSession` answers ok/offline/denied; closes owed to an offline machine are kept (also across restarts) and delivered on reconnect | Closed ids are tombstoned until the machine stops listing them; an exit carries `closed`/`closedBy` so every deck removes the tile. |
| Settings | Current PC | Await write; rollback or retain invalid draft with explicit feedback | No success state before persistence. Link settings still need broader failure coverage. |
| Terminal input/output | Host terminal | Ordered byte stream; do not turn every keystroke into a storage transaction | Screen replay is bounded and is not an archival transcript. |
diff --git a/package-lock.json b/package-lock.json
index 7b971d3..7b73efd 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,18 +1,17 @@
{
"name": "devdeck",
- "version": "1.37.9",
+ "version": "1.38.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "devdeck",
- "version": "1.37.9",
+ "version": "1.38.0",
"license": "MIT",
"dependencies": {
"@fontsource-variable/geist": "5.3.0",
"@homebridge/node-pty-prebuilt-multiarch": "^0.13.1",
"@xterm/addon-search": "^0.16.0",
- "@xterm/addon-webgl": "^0.19.0",
"electron-updater": "^6.8.9"
},
"devDependencies": {
@@ -1502,12 +1501,6 @@
"integrity": "sha512-9OeuBFu0/uZJPu+9AHKY6g/w0Czyb/Ut0A5t79I4ULoU4IfU5BEpPFVGQxP4zTTMdfZEYkVIRYbHBX1xWwjeSA==",
"license": "MIT"
},
- "node_modules/@xterm/addon-webgl": {
- "version": "0.19.0",
- "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.19.0.tgz",
- "integrity": "sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A==",
- "license": "MIT"
- },
"node_modules/@xterm/xterm": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
@@ -1864,15 +1857,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/boolean": {
- "version": "3.2.0",
- "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz",
- "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==",
- "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
- "dev": true,
- "license": "MIT",
- "optional": true
- },
"node_modules/brace-expansion": {
"version": "5.0.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
@@ -2338,14 +2322,6 @@
"node": ">=8"
}
},
- "node_modules/detect-node": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz",
- "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==",
- "dev": true,
- "license": "MIT",
- "optional": true
- },
"node_modules/dir-compare": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-4.2.0.tgz",
@@ -2774,14 +2750,6 @@
"node": ">= 0.4"
}
},
- "node_modules/es6-error": {
- "version": "4.1.1",
- "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz",
- "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==",
- "dev": true,
- "license": "MIT",
- "optional": true
- },
"node_modules/esbuild": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
@@ -3160,19 +3128,17 @@
}
},
"node_modules/global-agent": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz",
- "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==",
+ "version": "4.1.3",
+ "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-4.1.3.tgz",
+ "integrity": "sha512-KUJEViiuFT3I97t+GYMikLPJS2Lfo/S2F+DQuBWzuzaMPnvt5yyZePzArx36fBzpGTxZjIpDbXLeySLgh+k76g==",
"dev": true,
"license": "BSD-3-Clause",
"optional": true,
"dependencies": {
- "boolean": "^3.0.1",
- "es6-error": "^4.1.1",
- "matcher": "^3.0.0",
- "roarr": "^2.15.3",
- "semver": "^7.3.2",
- "serialize-error": "^7.0.1"
+ "globalthis": "^1.0.2",
+ "matcher": "^4.0.0",
+ "semver": "^7.3.5",
+ "serialize-error": "^8.1.0"
},
"engines": {
"node": ">=10.0"
@@ -3517,14 +3483,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/json-stringify-safe": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
- "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==",
- "dev": true,
- "license": "ISC",
- "optional": true
- },
"node_modules/json5": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
@@ -3881,9 +3839,9 @@
}
},
"node_modules/matcher": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz",
- "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==",
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/matcher/-/matcher-4.0.0.tgz",
+ "integrity": "sha512-S6x5wmcDmsDRRU/c2dkccDwQPXoFczc5+HpQ2lON8pnvHlnvHAHj5WlLVvw6n6vNyHuVugYrFohYxbS+pvFpKQ==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -3892,6 +3850,9 @@
},
"engines": {
"node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/math-intrinsics": {
@@ -4683,25 +4644,6 @@
"rimraf": "bin.js"
}
},
- "node_modules/roarr": {
- "version": "2.15.4",
- "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz",
- "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==",
- "dev": true,
- "license": "BSD-3-Clause",
- "optional": true,
- "dependencies": {
- "boolean": "^3.0.1",
- "detect-node": "^2.0.4",
- "globalthis": "^1.0.1",
- "json-stringify-safe": "^5.0.1",
- "semver-compare": "^1.0.0",
- "sprintf-js": "^1.1.2"
- },
- "engines": {
- "node": ">=8.0"
- }
- },
"node_modules/rolldown": {
"version": "1.2.14",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.14.tgz",
@@ -4787,23 +4729,15 @@
"node": ">=10"
}
},
- "node_modules/semver-compare": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz",
- "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==",
- "dev": true,
- "license": "MIT",
- "optional": true
- },
"node_modules/serialize-error": {
- "version": "7.0.1",
- "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz",
- "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==",
+ "version": "8.1.0",
+ "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-8.1.0.tgz",
+ "integrity": "sha512-3NnuWfM6vBYoy5gZFvHiYsVbafvI9vZv/+jlIigFn4oP4zjNPK3LhcY0xSCgeb1a5L8jO71Mit9LlNoi2UfDDQ==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
- "type-fest": "^0.13.1"
+ "type-fest": "^0.20.2"
},
"engines": {
"node": ">=10"
@@ -4938,14 +4872,6 @@
"source-map": "^0.6.0"
}
},
- "node_modules/sprintf-js": {
- "version": "1.1.3",
- "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
- "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==",
- "dev": true,
- "license": "BSD-3-Clause",
- "optional": true
- },
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
@@ -5249,9 +5175,9 @@
}
},
"node_modules/type-fest": {
- "version": "0.13.1",
- "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz",
- "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==",
+ "version": "0.20.2",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
+ "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
"dev": true,
"license": "(MIT OR CC0-1.0)",
"optional": true,
diff --git a/package.json b/package.json
index 4eb8729..700178c 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "devdeck",
- "version": "1.37.9",
+ "version": "1.38.0",
"description": "Project command deck — at-a-glance state + Claude Code, Codex, and Antigravity resume",
"main": "dist/main/main.js",
"type": "commonjs",
@@ -34,6 +34,7 @@
"qa": "npm run build && node qa/screenshot.mjs",
"qa:audit": "npm run build && node qa/audit.mjs",
"qa:link": "npm run build && node qa/link.mjs",
+ "qa:close": "npm run build && node qa/close.mjs",
"qa:resilience": "npm run build && node qa/resilience.mjs",
"qa:perf": "npm run build && node qa/perf.mjs",
"qa:maintenance": "npm run build && node qa/maintenance.mjs",
@@ -119,11 +120,11 @@
"@fontsource-variable/geist": "5.3.0",
"@homebridge/node-pty-prebuilt-multiarch": "^0.13.1",
"@xterm/addon-search": "^0.16.0",
- "@xterm/addon-webgl": "^0.19.0",
"electron-updater": "^6.8.9"
},
"overrides": {
"undici": "^6.28.0",
- "form-data": "^4.0.6"
+ "form-data": "^4.0.6",
+ "global-agent": "^4.1.3"
}
}
diff --git a/qa/close.mjs b/qa/close.mjs
new file mode 100644
index 0000000..b4b429e
--- /dev/null
+++ b/qa/close.mjs
@@ -0,0 +1,296 @@
+// What "close" actually does, on both ends of a DevDeck Link, driven the way a person drives it.
+//
+// Every defect this guards against was silent: the deck that pressed close looked right, and the
+// damage was somewhere else — an "exited" tile left on the OTHER machine (saved, and re-run after an
+// update restart), a close pressed while the link was down that never arrived (the agent kept running
+// and the tile was adopted straight back on reconnect), a close refused for lack of permission that
+// looked like it worked, and a bulk close where all but one of the closed sessions came back as dead
+// tiles. None of it shows up in unit tests, because each is an ordering between two processes.
+//
+// Run with `npm run qa:close`. Two Electron instances: do NOT run it alongside `npm run qa`/`qa:link`.
+// Sessions run the real `claude` CLI in a throwaway repository with no conversation to continue (see
+// the fixture note in link.mjs for why it must never be this checkout).
+import { _electron as electron } from 'playwright';
+import { mkdtempSync, writeFileSync } 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 repo = dirname(dirname(fileURLToPath(import.meta.url)));
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+const stamp = () => new Date().toISOString().slice(11, 19);
+
+const fixtureRepo = mkdtempSync(join(tmpdir(), 'devdeck-close-fixture-'));
+writeFileSync(join(fixtureRepo, 'README.md'), '# fixture');
+execFileSync('git', ['init', '-q'], { cwd: fixtureRepo });
+execFileSync('git', ['-c', 'user.email=qa@devdeck', '-c', 'user.name=qa', 'add', '-A'], { cwd: fixtureRepo });
+execFileSync('git', ['-c', 'user.email=qa@devdeck', '-c', 'user.name=qa', 'commit', '-q', '-m', 'fixture'], { cwd: fixtureRepo });
+
+async function launch(tag, registerFolder) {
+ const userData = mkdtempSync(join(tmpdir(), `devdeck-close-${tag}-`));
+ const temp = mkdtempSync(join(tmpdir(), `devdeck-closetmp-${tag}-`));
+ const app = await electron.launch({
+ args: ['.', `--user-data-dir=${userData}`, '--no-sandbox', '--disable-gpu'],
+ cwd: repo,
+ // A nested agent must not inherit this session's identity (same scrub as link.mjs / perf.mjs).
+ env: { ...process.env, TEMP: temp, TMP: temp, TMPDIR: temp, CLAUDE_CODE_SSE_PORT: '', CLAUDECODE: '', CLAUDE_CODE_ENTRYPOINT: '' },
+ });
+ const win = await app.firstWindow();
+ win.on('pageerror', (e) => console.error(`[${stamp()}] ${tag}: page error ${String(e).slice(0, 200)}`));
+ await win.waitForSelector('#cards .card, #cards .empty', { timeout: 30000 }).catch(() => {});
+ if (registerFolder) {
+ await app.evaluate(({ dialog }, p) => { dialog.showOpenDialog = async () => ({ canceled: false, filePaths: [p] }); }, fixtureRepo);
+ await win.evaluate(async () => window.devdeck.pickFolder());
+ await win.evaluate(async (p) => window.devdeck.addFolder(p, 'repo'), fixtureRepo);
+ }
+ await win.setViewportSize({ width: 1400, height: 900 });
+ return { app, win };
+}
+
+async function freePort() {
+ const { createServer } = await import('node:net');
+ return new Promise((resolve, reject) => {
+ const s = createServer(); s.on('error', reject);
+ s.listen(0, '127.0.0.1', () => { const { port } = s.address(); s.close(() => resolve(port)); });
+ });
+}
+
+/** The agent processes under a pid: what is actually still running, whatever the UI says. */
+function agentProcessCount(rootPid) {
+ if (process.platform !== 'win32') return null;
+ const out = execFileSync('powershell', ['-NoProfile', '-Command',
+ 'Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,Name | ConvertTo-Json -Compress'], { encoding: 'utf8', maxBuffer: 64 << 20 });
+ const kids = new Map();
+ for (const p of JSON.parse(out)) { if (!kids.has(p.ParentProcessId)) kids.set(p.ParentProcessId, []); kids.get(p.ParentProcessId).push(p); }
+ let n = 0; const stack = [rootPid];
+ while (stack.length) for (const c of kids.get(stack.pop()) ?? []) { if (/^(powershell|pwsh|claude|node)\.exe$/i.test(c.Name)) n++; stack.push(c.ProcessId); }
+ return n;
+}
+
+const rows = (win) => win.evaluate(() => [...document.querySelectorAll('#shell-session-groups .shell-session')].map((n) => ({
+ label: n.querySelector('strong')?.textContent ?? '',
+ activity: [...n.classList].find((c) => c.startsWith('activity-'))?.slice(9) ?? '?',
+ previous: n.closest('.shell-session-wrap')?.dataset.previous === 'true',
+})));
+const liveRows = async (win, label) => (await rows(win)).filter((r) => r.label.includes(label) && !r.previous);
+const persisted = (win, label) => win.evaluate(async (l) => (await window.devdeck.cockpit.loadSessions()).filter((s) => (s.label ?? '').includes(l)).length, label);
+const running = (win, label) => win.evaluate(async (l) => (await window.devdeck.cockpit.liveSessions()).some((s) => (s.label ?? '').includes(l)), label);
+const headerTitle = (win) => win.evaluate(() => document.querySelector('#ck-header .title')?.textContent ?? '');
+
+async function waitFor(fn, ms = 20000, step = 400) {
+ const end = Date.now() + ms;
+ while (Date.now() < end) { const v = await fn(); if (v) return v; await sleep(step); }
+ return null;
+}
+
+/** Open a session on `owner` (window of the machine that runs it) and name it there. */
+async function openNamed(owner, label) {
+ return owner.win.evaluate(async (label) => {
+ const projects = await window.devdeck.listProjects();
+ const res = await window.devdeck.cockpit.open({ projectPath: projects[0].path, sessionId: null, cols: 80, rows: 24, mode: 'new', agentId: 'claude' });
+ await new Promise((r) => setTimeout(r, 800));
+ const me = (await window.devdeck.cockpit.liveSessions()).find((s) => s.id === res.id);
+ if (me) await window.devdeck.cockpit.renameSession(me.id, label, me);
+ return res.id;
+ }, label);
+}
+
+/** Open a session on the host FROM the viewer, then name it on the host. */
+async function openFromViewer(label) {
+ await viewer.win.evaluate(async () => {
+ const m = (await window.devdeck.link.machines()).find((x) => x.state === 'connected');
+ const projects = await window.devdeck.machine(m.machineId).listProjects();
+ await window.devdeck.machine(m.machineId).cockpit.open({ projectPath: projects[0].path, sessionId: null, cols: 80, rows: 24, mode: 'new', agentId: 'claude' });
+ });
+ await sleep(800);
+ await host.win.evaluate(async (label) => {
+ const me = (await window.devdeck.cockpit.liveSessions()).find((s) => !s.label);
+ if (me) await window.devdeck.cockpit.renameSession(me.id, label, me);
+ }, label);
+ await waitFor(async () => (await liveRows(viewer.win, label)).length && (await liveRows(host.win, label)).length);
+}
+
+async function selectRow(win, label) {
+ await win.locator('#shell-session-groups .shell-session-wrap:not(.is-previous) .shell-session', { hasText: label }).first().click();
+ return waitFor(async () => (await headerTitle(win)).includes(label), 5000, 150);
+}
+
+/** Header button by position: new session, pin, rename, folder, restart, close. */
+const HEADER = { newSession: 0, restart: 4, close: 5 };
+async function pressHeader(win, which) {
+ await win.evaluate((i) => document.querySelectorAll('#ck-header .ck-act')[i].click(), HEADER[which]);
+}
+async function closeViaUi(win, label) {
+ if (!(await selectRow(win, label))) return false;
+ await pressHeader(win, 'close');
+ await win.waitForSelector('.ck-confirm-ok', { timeout: 5000 });
+ await win.click('.ck-confirm-ok');
+ return true;
+}
+
+const host = await launch('host', true);
+const viewer = await launch('viewer', false);
+const checks = {};
+const notes = {};
+const check = (name, ok, detail) => { checks[name] = !!ok; if (detail !== undefined) notes[name] = detail; console.log(`[${stamp()}] ${ok ? 'PASS' : 'FAIL'} ${name}${detail !== undefined ? ` ${JSON.stringify(detail)}` : ''}`); };
+
+try {
+ const hostPid = host.app.process().pid;
+ await openNamed(host, 'qa-H1');
+ await openNamed(host, 'qa-H2');
+
+ // --- pair through the UI, on a port nobody else holds ---
+ await host.win.evaluate(async (p) => window.devdeck.link.setPort(p), await freePort());
+ await host.win.click('.rail-item[data-view="settings"]');
+ await host.win.waitForSelector('#settings-form .link-block .chip.chip-primary', { timeout: 15000 });
+ await host.win.click('#settings-form .link-block .chip.chip-primary');
+ await host.win.waitForSelector('#settings-form .link-code', { timeout: 15000 });
+ const code = (await host.win.textContent('#settings-form .link-code')).trim();
+ await viewer.win.click('.rail-item[data-view="settings"]');
+ await viewer.win.fill('#settings-form .link-code-input', code);
+ await viewer.win.click('#settings-form .link-add-row .chip');
+ await viewer.win.waitForFunction(() => document.querySelector('#settings-form .link-state.is-connected') !== null, undefined, { timeout: 20000 });
+ const machineId = await viewer.win.evaluate(async () => (await window.devdeck.link.machines()).find((m) => m.state === 'connected').machineId);
+ check('viewerAdoptsHostSessions', await waitFor(async () => (await liveRows(viewer.win, 'qa-H1')).length && (await liveRows(viewer.win, 'qa-H2')).length));
+
+ // --- 1. a remote close ends the session THERE, and the host's own deck follows ---
+ await openFromViewer('qa-V1');
+ await sleep(2500);
+ const procsBefore = agentProcessCount(hostPid);
+ await closeViaUi(viewer.win, 'qa-V1');
+ await sleep(5000);
+ const procsAfter = agentProcessCount(hostPid);
+ check('remoteClose.killsHostSession', !(await running(host.win, 'qa-V1')));
+ check('remoteClose.hostProcessesEnd', procsBefore === null || procsBefore - procsAfter >= 2, { procsBefore, procsAfter });
+ check('remoteClose.noGhostOnHost', (await liveRows(host.win, 'qa-V1')).length === 0 && (await persisted(host.win, 'qa-V1')) === 0);
+ check('remoteClose.goneOnViewer', (await liveRows(viewer.win, 'qa-V1')).length === 0 && (await persisted(viewer.win, 'qa-V1')) === 0);
+
+ // --- 2. closing at the host removes the session from the viewer watching it ---
+ await closeViaUi(host.win, 'qa-H1');
+ await sleep(5000);
+ check('hostClose.noGhostOnViewer', (await liveRows(viewer.win, 'qa-H1')).length === 0 && (await persisted(viewer.win, 'qa-H1')) === 0);
+
+ // --- 3. a close pressed while the link is down is delivered when it comes back ---
+ await openFromViewer('qa-V2');
+ await sleep(1500);
+ await host.win.evaluate(async () => window.devdeck.link.setHostMode(false));
+ await waitFor(() => viewer.win.evaluate(async () => (await window.devdeck.link.machines()).every((m) => m.state !== 'connected')), 20000);
+ await closeViaUi(viewer.win, 'qa-V2');
+ await sleep(1000);
+ await host.win.evaluate(async () => window.devdeck.link.setHostMode(true));
+ await sleep(1500);
+ await viewer.win.evaluate(async (id) => window.devdeck.link.reconnect(id).catch(() => {}), machineId);
+ await waitFor(() => viewer.win.evaluate(async () => (await window.devdeck.link.machines()).some((m) => m.state === 'connected')), 30000);
+ await sleep(8000);
+ check('offlineClose.deliveredOnReconnect', !(await running(host.win, 'qa-V2')));
+ check('offlineClose.notReadopted', (await liveRows(viewer.win, 'qa-V2')).length === 0);
+
+ // --- 4. a close this device is not permitted to make is not pretended ---
+ const fp = await host.win.evaluate(async () => (await window.devdeck.link.hostStatus()).devices?.[0]?.fingerprint ?? null);
+ await host.win.evaluate(async (fp) => window.devdeck.link.setDevicePermissions(fp, ['observe']), fp);
+ await sleep(1500);
+ await closeViaUi(viewer.win, 'qa-H2');
+ await sleep(4000);
+ check('deniedClose.sessionKeepsRunning', await running(host.win, 'qa-H2'));
+ check('deniedClose.tileComesBackHonestly', (await liveRows(viewer.win, 'qa-H2')).length === 1);
+ await host.win.evaluate(async (fp) => window.devdeck.link.setDevicePermissions(fp, ['observe', 'control', 'spawn', 'write']), fp);
+ await sleep(1500);
+
+ // --- 5. a bulk close leaves nothing behind (it used to bring all but one back as dead tiles) ---
+ for (const l of ['qa-B1', 'qa-B2', 'qa-B3', 'qa-B4']) await openNamed(host, l);
+ await waitFor(async () => (await liveRows(host.win, 'qa-B4')).length);
+ await sleep(3000);
+ for (const l of ['qa-B1', 'qa-B2', 'qa-B3']) {
+ await host.win.locator('#shell-session-groups .shell-session', { hasText: l }).first().click({ modifiers: ['Control'] });
+ await sleep(150);
+ }
+ await host.win.click('.shell-selection-close');
+ await host.win.waitForSelector('.ck-confirm-ok', { timeout: 5000 });
+ await host.win.click('.ck-confirm-ok');
+ await sleep(6000);
+ const leftHost = (await rows(host.win)).filter((r) => /qa-B[123]/.test(r.label));
+ const leftViewer = (await rows(viewer.win)).filter((r) => /qa-B[123]/.test(r.label));
+ check('bulkClose.noZombiesOnHost', leftHost.length === 0, leftHost);
+ check('bulkClose.noZombiesOnViewer', leftViewer.length === 0, leftViewer);
+ check('bulkClose.keepsTheUnselected', (await liveRows(host.win, 'qa-B4')).length === 1);
+
+ // --- 6. a session started elsewhere does not take the screen or the keyboard ---
+ await viewer.win.click('.rail-item[data-view="cockpit"]').catch(() => {});
+ await selectRow(viewer.win, 'qa-B4');
+ const before6 = await headerTitle(viewer.win);
+ await openNamed(host, 'qa-H6');
+ await waitFor(async () => (await liveRows(viewer.win, 'qa-H6')).length);
+ await sleep(1500);
+ check('adoption.doesNotStealSelection', (await headerTitle(viewer.win)) === before6, { before: before6, after: await headerTitle(viewer.win) });
+
+ // --- 7. "+ new session" on a remote tile opens on THAT machine ---
+ const hostCount7 = await host.win.evaluate(async () => (await window.devdeck.cockpit.liveSessions()).length);
+ await selectRow(viewer.win, 'qa-H6');
+ await pressHeader(viewer.win, 'newSession');
+ await sleep(4000);
+ const hostCount7b = await host.win.evaluate(async () => (await window.devdeck.cockpit.liveSessions()).length);
+ const viewerLocal7 = await viewer.win.evaluate(async () => (await window.devdeck.cockpit.liveSessions()).length);
+ check('remoteNewSession.opensOnHost', hostCount7b === hostCount7 + 1 && viewerLocal7 === 0, { hostCount7, hostCount7b, viewerLocal7 });
+
+ // --- 8. Enter on a focused Cancel cancels ---
+ await selectRow(host.win, 'qa-H6');
+ await pressHeader(host.win, 'close');
+ await host.win.waitForSelector('.ck-confirm-cancel', { timeout: 5000 });
+ await host.win.focus('.ck-confirm-cancel');
+ await host.win.keyboard.press('Enter');
+ await sleep(1500);
+ check('confirm.enterOnCancelCancels', await running(host.win, 'qa-H6'));
+
+ // --- 9. restart keeps the tile, its name, and leaves exactly one terminal for it ---
+ const count9 = await host.win.evaluate(async () => (await window.devdeck.cockpit.liveSessions()).length);
+ await selectRow(host.win, 'qa-H6');
+ await pressHeader(host.win, 'restart');
+ await host.win.waitForSelector('.ck-confirm-ok', { timeout: 5000 });
+ await host.win.click('.ck-confirm-ok');
+ await sleep(7000);
+ const count9b = await host.win.evaluate(async () => (await window.devdeck.cockpit.liveSessions()).length);
+ check('restart.oneTerminalSameName', count9b === count9 && (await liveRows(host.win, 'qa-H6')).length === 1, { count9, count9b, rows: await liveRows(host.win, 'qa-H6') });
+ check('restart.noGhostOnViewer', (await liveRows(viewer.win, 'qa-H6')).filter((r) => r.activity === 'exited').length === 0, await liveRows(viewer.win, 'qa-H6'));
+
+ // --- 10. "Update installed · Restart to apply" offers a one-click restart that keeps the tile ---
+ // Claude Code's own /restart is macOS/Linux-only, so on Windows this button is the way onto a new
+ // version. The notice is printed into a real tile (the agent is left for its shell first), exactly
+ // where Claude Code draws it, and the button must restart the session under the same name.
+ await openNamed(host, 'qa-U1');
+ await waitFor(async () => (await liveRows(host.win, 'qa-U1')).length);
+ await waitFor(() => selectRow(host.win, 'qa-U1'), 15000, 1000);
+ const u1 = await host.win.evaluate(async () => (await window.devdeck.cockpit.liveSessions()).find((s) => s.label === 'qa-U1')?.id);
+ const screenHas = (text) => host.win.evaluate((t) => [...(document.querySelector('.ck-term.show .xterm-rows')?.children ?? [])].some((r) => (r.textContent ?? '').includes(t)), text);
+ await waitFor(() => screenHas('No, exit'), 20000);
+ // Trust prompt: "No, exit" -> PowerShell. The prompt can be drawn a moment before it takes input, so
+ // Enter is sent again if the shell has not appeared; a spare Enter at a PowerShell prompt is harmless.
+ for (let attempt = 0; attempt < 3; attempt++) {
+ await host.win.evaluate((id) => window.devdeck.cockpit.input(id, '\r'), u1);
+ if (await waitFor(() => screenHas('PS '), 6000)) break;
+ }
+ await host.win.evaluate((id) => window.devdeck.cockpit.input(id, "Write-Host ' >> bypass permissions on Update installed · Restart to apply'\r"), u1);
+ const appeared = await waitFor(() => host.win.evaluate(() => !!document.querySelector('#ck-header .ck-update')), 8000);
+ check('update.buttonAppears', appeared, appeared ? undefined : await host.win.evaluate(() => [...(document.querySelector('.ck-term.show .xterm-rows')?.children ?? [])].map((r) => r.textContent).filter((t) => t && t.trim()).slice(-6)));
+ const count10 = await host.win.evaluate(async () => (await window.devdeck.cockpit.liveSessions()).length);
+ await host.win.click('#ck-header .ck-update').catch(() => {});
+ await sleep(7000);
+ const after10 = await host.win.evaluate(async () => (await window.devdeck.cockpit.liveSessions()).map((s) => ({ id: s.id, label: s.label })));
+ check('update.restartsSameTile', after10.length === count10 && after10.some((s) => s.label === 'qa-U1' && s.id !== u1), { u1, after10 });
+ check('update.buttonGoneAfterRestart', !(await host.win.evaluate(() => !!document.querySelector('#ck-header .ck-update'))));
+
+ // --- what a relaunch / update restart would bring back ---
+ const exitedAnywhere = [...await rows(host.win), ...await rows(viewer.win)].filter((r) => r.activity === 'exited' && !r.previous);
+ check('noExitedTilesAnywhere', exitedAnywhere.length === 0, exitedAnywhere);
+} catch (e) {
+ console.error('close QA failed', e);
+ checks.harness = false;
+} finally {
+ for (const i of [viewer, host]) { try { await closeElectron(i.app); } catch (e) { console.error(String(e)); checks.cleanExit = false; } }
+}
+
+const failed = Object.entries(checks).filter(([, ok]) => !ok).map(([name]) => name);
+console.log(JSON.stringify({ checks, notes }, null, 2));
+console.log(failed.length ? `FAILED: ${failed.join(', ')}` : 'all close checks passed');
+process.exit(failed.length ? 1 : 0);
diff --git a/qa/perf.mjs b/qa/perf.mjs
index 8d801d9..8b60220 100644
--- a/qa/perf.mjs
+++ b/qa/perf.mjs
@@ -84,11 +84,14 @@ console.log(`view=${state.view} tiles=${state.tiles} shown=${state.shown} sizes=
// ---- instrument the UI thread -------------------------------------------------------------
await win.evaluate(() => {
- window.__perf = { long: [], raf: 0 };
+ window.__perf = { long: [], raf: 0, counting: false };
new PerformanceObserver((l) => { for (const e of l.getEntries()) window.__perf.long.push(Math.round(e.duration)); })
.observe({ entryTypes: ['longtask'] });
- const tick = () => { window.__perf.raf++; requestAnimationFrame(tick); };
- requestAnimationFrame(tick);
+ // The frame counter is a requestAnimationFrame loop, and a rAF loop is itself a frame every vsync
+ // (240 a second on this display) — it runs only while a JS profile is being taken. Left running, it
+ // was part of every process-CPU figure below, idle included: the harness measuring its own cost.
+ const tick = () => { if (!window.__perf.counting) return; window.__perf.raf++; requestAnimationFrame(tick); };
+ window.__perf.startCounting = () => { if (window.__perf.counting) return; window.__perf.counting = true; requestAnimationFrame(tick); };
});
const cdp = await win.context().newCDPSession(win);
@@ -96,11 +99,12 @@ await cdp.send('Profiler.enable');
await cdp.send('Profiler.setSamplingInterval', { interval: 200 }); // µs
async function profile(label, body) {
- await win.evaluate(() => { window.__perf.long = []; window.__perf.raf = 0; });
+ await win.evaluate(() => { window.__perf.long = []; window.__perf.raf = 0; window.__perf.startCounting(); });
await cdp.send('Profiler.start');
const t0 = Date.now();
await body();
const { profile: p } = await cdp.send('Profiler.stop');
+ await win.evaluate(() => { window.__perf.counting = false; });
report(label, p, Date.now() - t0, ...Object.values(await win.evaluate(() => ({ long: window.__perf.long, raf: window.__perf.raf }))));
}
diff --git a/qa/screenshot.mjs b/qa/screenshot.mjs
index 2c45420..d34f218 100644
--- a/qa/screenshot.mjs
+++ b/qa/screenshot.mjs
@@ -999,7 +999,8 @@ const liveRowControls = await win.evaluate(async () => {
triggersVisible: wraps.every((wrap) => (wrap.querySelector('.shell-session-actions')?.getClientRects().length ?? 0) > 0),
liveActions: actionsOf(wraps[0]),
pinnedActions: actionsOf(wraps.find((wrap) => wrap.querySelector('[data-pinned="true"]'))),
- workingAnimated: getComputedStyle(working).animationName === 'shell-spin',
+ // The spinner is turned by a tick (not a CSS animation): it must actually move between two looks.
+ workingAnimated: await (async () => { const a = getComputedStyle(working).rotate; await new Promise((r) => setTimeout(r, 700)); return getComputedStyle(working).rotate !== a; })(),
distinctShapes: new Set(shapes).size === shapes.length,
summaryShown: !!document.querySelector('.activity-working')?.parentElement?.querySelector('.shell-entity-copy em:not(.hidden)')?.textContent,
};
diff --git a/src/main/agents.test.ts b/src/main/agents.test.ts
index 5aa70fb..6cf0bee 100644
--- a/src/main/agents.test.ts
+++ b/src/main/agents.test.ts
@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest';
import { join } from 'node:path';
-import { getProvider, availableAgents, resolveProjectOpenCommand, agentAvailableAtHome } from './agents';
+import { getProvider, availableAgents, resolveProjectOpenCommand, agentAvailableAtHome, resolveOpenSession } from './agents';
describe('agent providers', () => {
it('claude buildCommand maps kinds correctly', () => {
@@ -49,3 +49,20 @@ describe('agent providers', () => {
.toBe('claude --resume abc12345');
});
});
+
+describe('resolveOpenSession with an id the provider refuses', () => {
+ // `buildCommand('resume', bad)` falls back to `claude -c`, which opens the latest conversation —
+ // reporting the bad id would save the tile against a conversation nothing opened.
+ const gen = () => '00000000-0000-4000-8000-000000000000';
+ it('reports no session id when the resume id fails validation', () => {
+ for (const id of ['../../etc/passwd', 'not an id; rm -rf', '']) {
+ const r = resolveOpenSession(getProvider('claude'), { fresh: false, sessionId: id, sessionCount: 3, latestId: 'abc12345', genId: gen });
+ expect(r).toEqual({ command: 'claude -c', sessionId: null });
+ }
+ });
+ it('keeps a valid resume id', () => {
+ const id = 'a0b1c2d3-e4f5-6789-abcd-ef0123456789';
+ expect(resolveOpenSession(getProvider('codex'), { fresh: false, sessionId: id, sessionCount: 1, latestId: null, genId: gen }))
+ .toEqual({ command: `codex resume ${id}`, sessionId: id });
+ });
+});
diff --git a/src/main/agents.ts b/src/main/agents.ts
index 6bdfd3f..6082991 100644
--- a/src/main/agents.ts
+++ b/src/main/agents.ts
@@ -138,6 +138,11 @@ export function resolveOpenSession(
}
return { command: a.buildCommand('new'), sessionId: null };
}
- if (typeof opts.sessionId === 'string') return { command: a.buildCommand('resume', opts.sessionId), sessionId: opts.sessionId };
+ if (typeof opts.sessionId === 'string') {
+ const command = a.buildCommand('resume', opts.sessionId);
+ // An id the provider refuses falls back to its "continue" command, which opens some OTHER
+ // conversation — so the tile must not be saved as holding the id it asked for.
+ return { command, sessionId: opts.sessionId && command.includes(opts.sessionId) ? opts.sessionId : null };
+ }
return { command: a.buildCommand('continue'), sessionId: opts.latestId };
}
diff --git a/src/main/aiSummary.test.ts b/src/main/aiSummary.test.ts
index 920815d..c7cdf31 100644
--- a/src/main/aiSummary.test.ts
+++ b/src/main/aiSummary.test.ts
@@ -1,5 +1,9 @@
-import { describe, it, expect } from 'vitest';
-import { makeAiSummarizer, buildSummaryPrompt, normalizeAiSummary } from './aiSummary';
+import { describe, it, expect, vi } from 'vitest';
+import { spawnSync } from 'node:child_process';
+import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { makeAiSummarizer, buildSummaryPrompt, normalizeAiSummary, cliInvocation, killChildTree } from './aiSummary';
const SID = 'a0b1c2d3-e4f5-6789-abcd-ef0123456789';
const OTHER = 'b0b1c2d3-e4f5-6789-abcd-ef0123456789';
@@ -71,12 +75,14 @@ describe('makeAiSummarizer', () => {
it('caches a failure so a missing CLI is not retried every tick', async () => {
let calls = 0;
- const s = makeAiSummarizer({ runners: { claude: async () => { calls++; throw new Error('claude exited 1'); } } });
+ const failures: string[] = [];
+ const s = makeAiSummarizer({ runners: { claude: async () => { calls++; throw new Error('claude exited 1'); } }, onFailure: (m) => failures.push(m) });
s.setEnabled(true);
s.get(SID, 1, 'work'); await s.idle();
expect(s.get(SID, 1, 'work')).toBeNull();
await s.idle();
expect(calls).toBe(1);
+ expect(failures).toEqual(['AI summary (claude) failed: claude exited 1']); // reported, once
});
it('never runs two generations at once', async () => {
@@ -162,3 +168,51 @@ describe('makeAiSummarizer', () => {
expect(s.get(SID, 1, 'work', { provider: 'codex' })).toBe('codex만 있어도 동작');
});
});
+
+describe('starting an agent CLI without shell: true', () => {
+ const CLAUDE_ARGS = ['-p', '--model', 'haiku', '--strict-mcp-config', '--mcp-config', '{"mcpServers":{}}'];
+
+ it('starts a real executable directly, arguments untouched', () => {
+ expect(cliInvocation('C:\\Users\\me\\.local\\bin\\claude.exe', CLAUDE_ARGS, 'win32'))
+ .toEqual({ file: 'C:\\Users\\me\\.local\\bin\\claude.exe', args: CLAUDE_ARGS });
+ expect(cliInvocation('claude', CLAUDE_ARGS, 'darwin')).toEqual({ file: 'claude', args: CLAUDE_ARGS });
+ });
+
+ it('routes a .cmd shim through cmd.exe with every argument quoted, verbatim', () => {
+ const how = cliInvocation('C:\\npm\\claude.cmd', CLAUDE_ARGS, 'win32', 'C:\\Windows\\system32\\cmd.exe');
+ expect(how.file).toBe('C:\\Windows\\system32\\cmd.exe');
+ expect(how.windowsVerbatimArguments).toBe(true);
+ expect(how.args.slice(0, 3)).toEqual(['/d', '/s', '/c']);
+ // The JSON's quotes survive as \" inside a quoted argument, every cmd metacharacter ^-escaped.
+ expect(how.args[3]).toContain('^^^"{\\^^^"mcpServers\\^^^":{}}^^^"');
+ });
+
+ // The round trip that actually matters: through cmd.exe, through an npm-style shim that forwards
+ // %*, into a program — and out the other side byte for byte. `shell: true` turned the MCP config
+ // into `{mcpServers:{}}` on exactly this path.
+ it.runIf(process.platform === 'win32')('a shim receives the exact arguments (npm-style %* forwarding)', () => {
+ const dir = mkdtempSync(join(tmpdir(), 'devdeck cli '));
+ try {
+ const bin = join(dir, 'with space');
+ mkdirSync(bin);
+ writeFileSync(join(bin, 'echo.js'), 'process.stdout.write(JSON.stringify(process.argv.slice(2)))');
+ writeFileSync(join(bin, 'fake.cmd'), [
+ '@ECHO off', 'GOTO start', ':find_dp0', 'SET dp0=%~dp0', 'EXIT /b', ':start', 'SETLOCAL', 'CALL :find_dp0',
+ `"${process.execPath}" "%dp0%\\echo.js" %*`,
+ ].join('\r\n'));
+ const args = [...CLAUDE_ARGS, 'a b', 'x&y|z', '(paren)', 'say "hi"', 'trailing\\', ''];
+ const how = cliInvocation(join(bin, 'fake.cmd'), args);
+ const out = spawnSync(how.file, how.args, { windowsVerbatimArguments: how.windowsVerbatimArguments, windowsHide: true }).stdout.toString();
+ expect(JSON.parse(out)).toEqual(args);
+ } finally { rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); }
+ });
+
+ it('killChildTree: plain kill off Windows, and when there is no pid to walk from', () => {
+ const a = { pid: 10, kill: vi.fn() };
+ killChildTree(a, 'linux');
+ expect(a.kill).toHaveBeenCalledTimes(1);
+ const b = { pid: undefined, kill: vi.fn() };
+ killChildTree(b, 'win32');
+ expect(b.kill).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/main/aiSummary.ts b/src/main/aiSummary.ts
index 43bc6cd..bee4afb 100644
--- a/src/main/aiSummary.ts
+++ b/src/main/aiSummary.ts
@@ -1,10 +1,12 @@
-import { spawn } from 'node:child_process';
+import { spawn, type ChildProcess } from 'node:child_process';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { cleanSummaryText, SUMMARY_MAX } from '../shared/sessionSummary';
import { codexExecFinalMessage } from '../shared/codexParse';
import type { AgentId } from '../shared/types';
+import { resolveAgentCliPath } from './cliExecutable';
+import { taskkillTree } from './ptyHost';
/**
* Optional AI layer for the cockpit's per-session summary line.
@@ -72,22 +74,77 @@ export function normalizeAiSummary(raw: string): string {
return line.length > SUMMARY_MAX ? line.slice(0, SUMMARY_MAX - 1) + '…' : line;
}
+/** A process start with nothing left for a shell to reinterpret. */
+export interface CliInvocation { file: string; args: string[]; windowsVerbatimArguments?: boolean }
+
+// Characters cmd.exe gives meaning to. Each one is ^-escaped so it reaches the program literally.
+const CMD_META = /([()\][%!^"`<>&|;, *?])/g;
+
+/** One argument, quoted for the MSVCRT parser the program uses, then escaped for cmd.exe. */
+function cmdArgument(arg: string, viaBatch: boolean): string {
+ let quoted = arg.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\*)$/, '$1$1');
+ quoted = `"${quoted}"`.replace(CMD_META, '^$1');
+ // A batch file re-reads its arguments when it expands %* into its own command line, so cmd parses
+ // them twice and they need escaping twice (npm's .cmd shims all forward through %*).
+ return viaBatch ? quoted.replace(CMD_META, '^$1') : quoted;
+}
+
+/**
+ * How to start an agent CLI without `shell: true`.
+ *
+ * `shell: true` on Windows joins the argv with plain spaces and hands it to cmd.exe unquoted, so an
+ * argument is whatever cmd and the program's own parser make of it: `--mcp-config '{"mcpServers":{}}'`
+ * arrived at Claude as `{mcpServers:{}}` and every summary failed. A real .exe is started directly.
+ * A .cmd/.bat shim cannot be (CreateProcess refuses them), so it goes through cmd.exe with each
+ * argument quoted and escaped here, verbatim, rather than left to the shell.
+ */
+export function cliInvocation(
+ command: string, args: string[],
+ platform: NodeJS.Platform = process.platform, comspec: string = process.env.ComSpec || 'cmd.exe',
+): CliInvocation {
+ if (platform !== 'win32' || !/\.(cmd|bat)$/i.test(command)) return { file: command, args };
+ const line = [command.replace(CMD_META, '^$1'), ...args.map((a) => cmdArgument(a, true))].join(' ');
+ return { file: comspec, args: ['/d', '/s', '/c', `"${line}"`], windowsVerbatimArguments: true };
+}
+
+/**
+ * Stop a CLI child and everything under it. On Windows `child.kill()` ends only the process it
+ * started — cmd.exe for a shim — and the real claude/codex node process kept running, spending quota
+ * on an answer nobody was waiting for. `taskkill /T` takes the tree; the plain kill follows.
+ */
+export function killChildTree(child: Pick
, platform: NodeJS.Platform = process.platform): void {
+ const plainKill = (): void => { try { child.kill(); } catch { /* already gone */ } };
+ if (platform !== 'win32' || typeof child.pid !== 'number') { plainKill(); return; }
+ void taskkillTree(child.pid).then(plainKill);
+}
+
+/** Positive results only: a CLI installed after DevDeck started must still be found next time. */
+const _resolvedCli = new Map<'claude' | 'codex', string>();
+function cliPath(provider: 'claude' | 'codex'): string {
+ const hit = _resolvedCli.get(provider);
+ if (hit) return hit;
+ const found = resolveAgentCliPath(provider);
+ if (found !== provider) _resolvedCli.set(provider, found);
+ return found;
+}
+
/**
* Run an agent CLI headlessly and return its stdout.
*
- * The prompt goes in on STDIN, never as an argv element: on Windows these CLIs are `.cmd` shims, which
- * need `shell: true`, and session text on a shell command line would be a command-injection hole.
- * Every argv entry passed here is a fixed literal.
+ * The prompt goes in on STDIN, never as an argv element: session text on a command line — and on
+ * Windows these can still pass through cmd.exe, see cliInvocation — would be a command-injection
+ * hole. Every argv entry passed here is a fixed literal.
*/
function runCli(bin: string, args: string[], input: string, cwd: string, scrubEnv: (key: string) => boolean): Promise {
return new Promise((resolve, reject) => {
const env = { ...process.env };
for (const k of Object.keys(env)) if (scrubEnv(k)) delete env[k];
- const child = spawn(bin, args, { cwd, env, windowsHide: true, shell: process.platform === 'win32' });
+ const how = cliInvocation(bin, args);
+ const child = spawn(how.file, how.args, { cwd, env, windowsHide: true, windowsVerbatimArguments: how.windowsVerbatimArguments });
let out = '';
let settled = false;
const done = (fn: () => void) => { if (settled) return; settled = true; clearTimeout(timer); fn(); };
- const timer = setTimeout(() => done(() => { child.kill(); reject(new Error('summary timeout')); }), AI_TIMEOUT_MS);
+ const timer = setTimeout(() => done(() => { killChildTree(child); reject(new Error('summary timeout')); }), AI_TIMEOUT_MS);
child.stdout.on('data', (b: Buffer) => { if (out.length < 32_000) out += b.toString('utf8'); });
child.stderr.on('data', () => { /* the CLIs chat on stderr; the exit code is what matters */ });
child.on('error', (err) => done(() => reject(err)));
@@ -106,7 +163,7 @@ function runCli(bin: string, args: string[], input: string, cwd: string, scrubEn
*/
export function spawnClaudeSummary(input: string, cwd: string): Promise {
return runCli(
- 'claude',
+ cliPath('claude'),
['-p', '--model', 'haiku', '--strict-mcp-config', '--mcp-config', '{"mcpServers":{}}'],
input, cwd,
(k) => k === 'CLAUDECODE' || k.startsWith('CLAUDE_CODE_'),
@@ -126,7 +183,7 @@ export function spawnClaudeSummary(input: string, cwd: string): Promise
*/
export function spawnCodexSummary(input: string, cwd: string): Promise {
return runCli(
- 'codex',
+ cliPath('codex'),
['exec', '--json', '--ephemeral', '--ignore-user-config', '--ignore-rules', '--skip-git-repo-check', '-s', 'read-only', '-'],
input, cwd,
() => false,
@@ -146,8 +203,13 @@ const DEFAULT_RUNNERS: Partial> = {
codex: (input) => spawnCodexSummary(input, summaryCwd()),
};
-export function makeAiSummarizer(deps: { runners?: Partial>; now?: () => number; cooldownMs?: number } = {}): AiSummarizer {
+export function makeAiSummarizer(deps: {
+ runners?: Partial>; now?: () => number; cooldownMs?: number;
+ /** Where a failed generation is reported (the diagnostics log, when the caller has one). */
+ onFailure?: (message: string) => void;
+} = {}): AiSummarizer {
const runners = deps.runners ?? DEFAULT_RUNNERS;
+ const onFailure = deps.onFailure ?? ((message: string) => console.warn('DevDeck:', message));
const now = deps.now ?? (() => Date.now());
const cooldownMs = deps.cooldownMs ?? AI_COOLDOWN_MS;
const cache = new Map();
@@ -172,8 +234,11 @@ export function makeAiSummarizer(deps: { runners?: Partial ({
+ ipcMain: { handle: () => {}, on: () => {} },
+ dialog: { showOpenDialog: vi.fn() },
+ shell: {},
+ clipboard: { readImage: vi.fn() },
+ app: { getPath: () => '', getVersion: () => '0.0.0', isPackaged: false },
+}));
+
+import { createDeckApi, type DeckApiConfig } from './deckApi';
+import type { DeckApi } from './methods';
+import { remoteCallContext } from '../link/hostServer';
+import { qualifyRemoteId } from '../../shared/link/machine';
+
+const ALLOWED_ROOT = join(process.cwd(), 'allowed-root');
+
+/** A pty table whose terminals exit when killed, the way node-pty reports it: asynchronously. */
+function makePtys() {
+ const sessions = new Map void }>();
+ return {
+ killed: [] as string[],
+ create(id: string, _f: string, _a: string[], _c: string, _w: number, _h: number, _d: unknown, onExit: (e: { exitCode: number }) => void) {
+ sessions.set(id, { onExit });
+ },
+ list() { return [...sessions.keys()].map((id) => ({ id, projectPath: ALLOWED_ROOT, sessionId: null, agentId: 'claude', startedAtMs: 0, label: null })); },
+ kill(id: string) {
+ const s = sessions.get(id); if (!s) return;
+ sessions.delete(id); this.killed.push(id);
+ queueMicrotask(() => s.onExit({ exitCode: 1 }));
+ },
+ exit(id: string) { const s = sessions.get(id); sessions.delete(id); s?.onExit({ exitCode: 0 }); },
+ note: () => false, pid: () => null, buffer: () => ({ data: '', cols: 0, rows: 0 }),
+ };
+}
+
+type Link = { machines: () => { machineId: string; state: string }[]; call: (m: string, method: string, args: unknown[]) => Promise; notify: ReturnType; detach: ReturnType; attach: ReturnType };
+
+function build(link: Link | null) {
+ const ptys = makePtys();
+ const bundle = createDeckApi({
+ win: { on: () => {}, isDestroyed: () => true, webContents: { send: () => {} } },
+ defaultBaseDir: ALLOWED_ROOT,
+ store: { getFolders: () => [{ path: ALLOWED_ROOT, kind: 'root' }], getAgent: () => 'claude', getSessionSummary: () => true, getAiSessionSummary: () => false, getCockpitSessions: () => [], setCockpitSessions: () => {}, setLastOpened: () => {} },
+ sendError: vi.fn(), defaultLanguage: 'en', ptyHost: ptys, ptyAvailable: true,
+ tray: { applyCounts: vi.fn(), setAlertImage: vi.fn() }, shutdown: null, shutdownLog: null, bootTimeMs: () => 0,
+ link: () => link,
+ } as unknown as DeckApiConfig);
+ const exits: unknown[] = [];
+ bundle.events.subscribe((channel, payload) => { if (channel === 'cockpit:exit') exits.push(payload); });
+ return { api: bundle.methods as DeckApi, ptys, exits };
+}
+
+const call = (api: DeckApi, name: string, ...args: unknown[]) => Promise.resolve(api[name].handler(...args));
+const flush = () => new Promise((r) => setTimeout(r, 0));
+
+describe('closing a session', () => {
+ let link: Link;
+ beforeEach(() => {
+ link = { machines: () => [{ machineId: '0b6b3c1e-6f7a-4c2d-9a8e-1f2e3d4c5b6a', state: 'connected' }], call: vi.fn(async () => ({ ok: true })), notify: vi.fn(), detach: vi.fn(), attach: vi.fn() };
+ });
+
+ it('tells every deck showing it that it was CLOSED, not that it crashed', async () => {
+ const { api, ptys, exits } = build(link);
+ const res = await call(api, 'cockpit:open', { projectPath: ALLOWED_ROOT, sessionId: null, cols: 80, rows: 24, mode: 'new', agentId: 'claude' }) as { id: string };
+ expect(res.id).toBeTruthy();
+ await call(api, 'cockpit:closeSession', res.id);
+ await flush();
+ expect(exits).toContainEqual({ id: res.id, exitCode: 1, closed: true, closedBy: 'local' });
+ });
+
+ it('marks a close requested by a paired machine as remote', async () => {
+ const { api, exits } = build(link);
+ const res = await call(api, 'cockpit:open', { projectPath: ALLOWED_ROOT, sessionId: null, cols: 80, rows: 24, mode: 'new', agentId: 'claude' }) as { id: string };
+ await remoteCallContext.run('remote', () => call(api, 'cockpit:closeSession', res.id));
+ await flush();
+ expect(exits).toContainEqual({ id: res.id, exitCode: 1, closed: true, closedBy: 'remote' });
+ });
+
+ it('an exit nobody asked for stays a plain exit', async () => {
+ const { api, ptys, exits } = build(link);
+ const res = await call(api, 'cockpit:open', { projectPath: ALLOWED_ROOT, sessionId: null, cols: 80, rows: 24, mode: 'new', agentId: 'claude' }) as { id: string };
+ ptys.exit(res.id);
+ expect(exits).toContainEqual({ id: res.id, exitCode: 0 });
+ });
+
+ it('the legacy notify close is marked too', async () => {
+ const { api, exits } = build(link);
+ const res = await call(api, 'cockpit:open', { projectPath: ALLOWED_ROOT, sessionId: null, cols: 80, rows: 24, mode: 'new', agentId: 'claude' }) as { id: string };
+ api['cockpit:close'].handler(res.id);
+ await flush();
+ expect(exits).toContainEqual({ id: res.id, exitCode: 1, closed: true, closedBy: 'local' });
+ });
+
+ const remoteId = qualifyRemoteId('0b6b3c1e-6f7a-4c2d-9a8e-1f2e3d4c5b6a', 'C:\\repo#3');
+
+ it('a remote close is ASKED, and answered', async () => {
+ const { api } = build(link);
+ expect(await call(api, 'cockpit:closeSession', remoteId)).toEqual({ ok: true });
+ expect(link.call).toHaveBeenCalledWith('0b6b3c1e-6f7a-4c2d-9a8e-1f2e3d4c5b6a', 'cockpit:closeSession', ['C:\\repo#3']);
+ expect(link.detach).toHaveBeenCalledWith(remoteId);
+ });
+
+ it('an unreachable machine is reported as offline, so the close can be sent later', async () => {
+ link.machines = () => [{ machineId: '0b6b3c1e-6f7a-4c2d-9a8e-1f2e3d4c5b6a', state: 'offline' }];
+ const { api } = build(link);
+ expect(await call(api, 'cockpit:closeSession', remoteId)).toEqual({ ok: false, reason: 'offline' });
+ expect(link.call).not.toHaveBeenCalled();
+ link.machines = () => [{ machineId: '0b6b3c1e-6f7a-4c2d-9a8e-1f2e3d4c5b6a', state: 'connected' }];
+ link.call = vi.fn(async () => { throw new Error('link is closed'); });
+ expect(await call(api, 'cockpit:closeSession', remoteId)).toMatchObject({ ok: false, reason: 'offline' });
+ });
+
+ it('a refusal says so instead of pretending the session closed', async () => {
+ link.call = vi.fn(async () => { throw new Error('not permitted: cockpit:closeSession'); });
+ const { api } = build(link);
+ expect(await call(api, 'cockpit:closeSession', remoteId)).toEqual({ ok: false, reason: 'denied' });
+ });
+
+ it('a session already gone there is a close already done', async () => {
+ link.call = vi.fn(async () => { throw new Error('not a session on this machine: cockpit:closeSession'); });
+ const { api } = build(link);
+ expect(await call(api, 'cockpit:closeSession', remoteId)).toEqual({ ok: true });
+ });
+
+ it('falls back to the old notify for a host that predates the answered close', async () => {
+ link.call = vi.fn(async () => { throw new Error('no such method: cockpit:closeSession'); });
+ const { api } = build(link);
+ expect(await call(api, 'cockpit:closeSession', remoteId)).toEqual({ ok: true });
+ expect(link.notify).toHaveBeenCalledWith('0b6b3c1e-6f7a-4c2d-9a8e-1f2e3d4c5b6a', 'cockpit:close', ['C:\\repo#3']);
+ });
+
+ it('waits out the host rate limit rather than dropping part of a bulk close', async () => {
+ let n = 0;
+ link.call = vi.fn(async () => { if (n++ < 2) throw new Error('too many requests: cockpit:closeSession'); return { ok: true }; });
+ const { api } = build(link);
+ expect(await call(api, 'cockpit:closeSession', remoteId)).toEqual({ ok: true });
+ expect(link.call).toHaveBeenCalledTimes(3);
+ });
+
+ it('refuses to relay a qualified id for a remote caller', async () => {
+ const { api } = build(link);
+ expect(await remoteCallContext.run('remote', () => call(api, 'cockpit:closeSession', remoteId))).toEqual({ ok: false, reason: 'error' });
+ expect(link.call).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/main/api/deckApi.projects.test.ts b/src/main/api/deckApi.projects.test.ts
new file mode 100644
index 0000000..8acaedf
--- /dev/null
+++ b/src/main/api/deckApi.projects.test.ts
@@ -0,0 +1,94 @@
+import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
+import { mkdirSync, mkdtempSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import type { GitInfo, StoreEntry } from '../../shared/types';
+
+vi.mock('electron', () => ({
+ ipcMain: { handle: () => {}, on: () => {} },
+ dialog: { showOpenDialog: vi.fn() },
+ shell: {},
+ clipboard: { readImage: vi.fn() },
+ app: { getPath: () => '', getVersion: () => '0.0.0', isPackaged: false },
+}));
+
+// git is what makes a deck list slow; a counted, controllable stand-in shows how many lists ran it.
+const { gitCalls, gitGate } = vi.hoisted(() => ({ gitCalls: [] as string[], gitGate: { release: null as null | (() => void) } }));
+vi.mock('../gitInfo', async (importOriginal) => ({
+ ...(await importOriginal()),
+ getGitInfo: async (dir: string): Promise => {
+ gitCalls.push(dir);
+ if (gitGate.release === null) await new Promise((resolve) => { gitGate.release = resolve; });
+ return { branch: 'main', lastCommitMs: 1_000, lastSubject: 'x', uncommitted: 0, ahead: null, repoUrl: null };
+ },
+}));
+
+import { createDeckApi, type DeckApiConfig } from './deckApi';
+import type { DeckApi } from './methods';
+
+let root: string;
+let api: DeckApi;
+const entries = new Map>();
+
+beforeAll(() => {
+ root = mkdtempSync(join(tmpdir(), 'devdeck-deck-'));
+ mkdirSync(join(root, 'alpha', '.git'), { recursive: true });
+ mkdirSync(join(root, 'beta', '.git'), { recursive: true });
+ api = createDeckApi({
+ win: { on: () => {}, isDestroyed: () => true, webContents: { send: () => {} } },
+ defaultBaseDir: root,
+ store: {
+ getFolders: () => [{ path: root, kind: 'root' }],
+ getThresholds: () => null,
+ get: (p: string): StoreEntry => ({ note: '', pinned: false, hidden: false, lastOpened: null, todos: [], ...entries.get(p) }),
+ getSessionSummary: () => true,
+ getAiSessionSummary: () => false,
+ },
+ sendError: vi.fn(),
+ defaultLanguage: 'en',
+ ptyHost: { list: () => [] },
+ ptyAvailable: true,
+ tray: { applyCounts: vi.fn(), setAlertImage: vi.fn() },
+ shutdown: null,
+ bootTimeMs: () => 0,
+ } as unknown as DeckApiConfig).methods;
+});
+afterAll(() => rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }));
+beforeEach(() => { gitCalls.length = 0; gitGate.release = null; entries.clear(); });
+
+type Row = { path: string; pinned: boolean; note: string };
+const list = (): Promise => api['projects:list'].handler() as Promise;
+const until = async (cond: () => boolean): Promise => { while (!cond()) await new Promise((r) => setTimeout(r, 1)); };
+
+describe('projects:list', () => {
+ it('lists asked for while one is being built share its git work', async () => {
+ const first = list();
+ await until(() => gitGate.release !== null); // the first list is now waiting on git
+ const second = list();
+ const third = list();
+ gitGate.release!();
+ const [a, b, c] = await Promise.all([first, second, third]);
+ expect(gitCalls.sort()).toEqual([join(root, 'alpha'), join(root, 'beta')]); // once per project, not three times
+ expect(b).toEqual(a);
+ expect(c).toEqual(a);
+ expect(a).not.toBe(b); // each caller gets rows of its own
+ });
+
+ it('a pin or note changed while a shared list is building shows in every answer', async () => {
+ const first = list();
+ await until(() => gitGate.release !== null);
+ const second = list();
+ entries.set(join(root, 'beta'), { pinned: true, note: 'changed mid-build' });
+ gitGate.release!();
+ for (const rows of await Promise.all([first, second])) {
+ expect(rows[0]).toMatchObject({ path: join(root, 'beta'), pinned: true, note: 'changed mid-build' });
+ }
+ });
+
+ it('a list asked for after one finished runs git again', async () => {
+ gitGate.release = () => {}; // no gate: git answers at once
+ await list();
+ await list();
+ expect(gitCalls).toHaveLength(4);
+ });
+});
diff --git a/src/main/api/deckApi.surface.test.ts b/src/main/api/deckApi.surface.test.ts
index c2c8cad..c9ae341 100644
--- a/src/main/api/deckApi.surface.test.ts
+++ b/src/main/api/deckApi.surface.test.ts
@@ -296,5 +296,5 @@ it('allows revision-checked task saves only to write-authorized devices', () =>
expect(mayCallRemotely(api['project:saveTodos'], ['observe'])).toBe(false);
expect(mayCallRemotely(api['project:saveTodos'], ['write'])).toBe(true);
expect(() => api['project:setTodos'].handler(ALLOWED_ROOT, [])).toThrow('TASKS_CLIENT_OUTDATED');
- expect(() => api['project:saveTodos'].handler(join(process.cwd(), 'outside'), [], 0)).toThrow('allowed folders');
+ return expect(Promise.resolve().then(() => api['project:saveTodos'].handler(join(process.cwd(), 'outside'), [], 0))).rejects.toThrow('allowed folders');
});
diff --git a/src/main/api/deckApi.ts b/src/main/api/deckApi.ts
index 56d1f96..cf2327a 100644
--- a/src/main/api/deckApi.ts
+++ b/src/main/api/deckApi.ts
@@ -12,12 +12,12 @@ import { applyOpenAtLogin, effectiveOpenAtLogin } from '../autostart';
import { scanFolders, isRepo } from '../scanner';
import { getGitInfo, getRepoUrl, getGitBranchDirty, getRecentCommits } from '../gitInfo';
import { getProvider, availableAgents, resolveOpenSession, resolveProjectOpenCommand } from '../agents';
-import { toAgentId, type AgentId, type Folder, type OpenMode, type ProjectOpenIntent, type SessionMeta } from '../../shared/types';
+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 { basename, cwdKey } from '../../shared/paths';
import { isAllowedExternalUrl, isSafeRepoUrl, isOpenableTerminalLink } from '../../shared/externalUrl';
import { makeTtlCache } from '../../shared/ttlCache';
-import { buildProjectList } from '../projects';
+import { gatherProjects, withEntries, type GatheredProject } from '../projects';
import { createProject } from '../createProject';
import { openProjects, openInEditor, resolveShellPath, makeCliGuard } from '../launcher';
import type { WtTab } from '../../shared/wtArgs';
@@ -35,7 +35,7 @@ import { UsageCoordinator, antigravityUsage } from '../usageProviders';
import { pickAdoptedSessionId, pickDriftedSessionId, type PersistedSession } from '../../shared/cockpitPersist';
import { makeAgentProbe } from '../agentProcess';
import { loginPowerShellCommand } from '../cliExecutable';
-import { listSessionStats, listSessionIds } from '../sessions';
+import { listSessionStats, listSessionIds, countSessionFiles } from '../sessions';
import { listCodexSessionStats, indexCodexSessionsByCwd, readCodexSessionMeta } from '../codexSessions';
import { indexAntigravitySessionsByCwd } from '../antigravitySessions';
import { makeProjectSessionScan } from '../sessionScan';
@@ -74,6 +74,9 @@ const REPO_URL = 'https://github.com/writingdeveloper/devdeck';
/** Paste-image ceiling. A screenshot is around a megabyte; this is generous and still far below the
* link's frame limit once base64 expansion is counted. */
const MAX_PASTE_IMAGE_BYTES = 5 * 1024 * 1024;
+const REMOTE_IMAGE_BYTES_PER_HOUR = 200 * 1024 * 1024;
+/** The note box says so too (maxLength): a note longer than this was cut off silently on save. */
+const MAX_NOTE_CHARS = 10_000;
export interface DeckApiConfig {
win: BrowserWindow;
@@ -149,7 +152,8 @@ export function createDeckApi(cfg: DeckApiConfig): DeckApiBundle {
// Opt-in AI layer for the cockpit's per-session summary line (see aiSummary.ts). Constructed
// regardless of the setting — it is inert while disabled — so toggling it needs no restart.
- const aiSummarizer = makeAiSummarizer();
+ // A failed summary used to vanish into a cached null with no trace of why; it belongs in this machine's log.
+ const aiSummarizer = makeAiSummarizer({ onFailure: (message) => cfg.diagnostics?.write('warn', 'ai', message) });
aiSummarizer.setEnabled(cfg.store.getAiSessionSummary());
const activeAgent = (): AgentId => {
@@ -194,6 +198,7 @@ export function createDeckApi(cfg: DeckApiConfig): DeckApiBundle {
const makeDeckScan = () => makeProjectSessionScan({
installed: availableAgents(),
perProject: { claude: (p, limit) => getProvider('claude').listSessions(p, limit) },
+ perProjectCount: { claude: (p) => countSessionFiles(p, CLAUDE_PROJECTS) },
indexed: {
codex: () => cachedIndex(codexIndexCache, CODEX_SESSIONS, () => indexCodexSessionsByCwd(CODEX_SESSIONS)),
antigravity: () => cachedIndex(antigravityIndexCache, ANTIGRAVITY_DIR, () => indexAntigravitySessionsByCwd(ANTIGRAVITY_DIR)),
@@ -207,40 +212,50 @@ export function createDeckApi(cfg: DeckApiConfig): DeckApiBundle {
lastUserMessage: (p, session) => getProvider(session.agentId).lastUserMessage(p, session.id),
entry: (p) => cfg.store.get(p),
});
- // ONE build at a time, shared by every caller. The deck gives up on an answer after 150s and asks
- // again on its next refresh, but giving up does not stop the walk here — so on a machine that had
- // just booted (cold disk, every repository's git and the whole Codex store still to read) each retry
- // started a second and third walk on top of the first, and none finished: SIHYEONG-4080 logged five
- // 150s timeouts in a row on 2026-10-12. A late caller now joins the walk already under way.
- let projectListInFlight: ReturnType | null = null;
- invoke('projects:list', allow('observe'), () => {
- if (projectListInFlight) return projectListInFlight;
- const scan = makeDeckScan();
- // Where a slow build spent its time, summed per stage across the worker pool — the only way to
- // tell git from the transcript stores after the fact on a machine nobody was watching.
- const spent = { scan: 0, git: 0, sessions: 0, cue: 0 };
- const timed = async (stage: keyof typeof spent, work: () => Promise | T): Promise => {
- const t = Date.now();
- try { return await work(); } finally { spent[stage] += Date.now() - t; }
- };
- const started = Date.now();
- let count = 0;
- projectListInFlight = buildProjectList({
- nowMs: started,
- thresholds: effThresholds(),
- scan: () => timed('scan', memoScan),
- git: (dir) => timed('git', () => getGitInfo(dir)),
- sessions: (p) => timed('sessions', () => scan.sessions(p)),
- resumeCue: (p, session) => timed('cue', () => getProvider(session.agentId).lastUserMessage(p, session.id)),
- getEntry: (p) => cfg.store.get(p),
- }).then((list) => { count = list.length; return list; }).finally(() => {
- projectListInFlight = null;
- const ms = Date.now() - started;
- if (ms >= SLOW_PROJECT_LIST_MS) {
- cfg.diagnostics?.write('warn', 'deck', `projects:list took ${ms}ms for ${count} projects (summed: scan=${spent.scan} git=${spent.git} sessions=${spent.sessions} cue=${spent.cue}ms)`);
- }
- });
- return projectListInFlight;
+ /**
+ * The git + session-store half of a deck list already being built, shared by every caller.
+ *
+ * Callers arrive in bunches — the 45s refresh, every window focus, the task board, a paired machine
+ * polling this deck — and the deck gives up on an answer after 150s and asks again without stopping
+ * the walk here. Each used to start another full walk on top of the first: SIHYEONG-4080 logged five
+ * 150s timeouts in a row on 2026-10-12. A late caller joins the walk under way, and each lays the
+ * store entries over it itself at the end, so a pin or note changed meanwhile is never lost.
+ * Cleared on completion, not on a timer: the next refresh must see git as it is then.
+ */
+ const gatherInFlight = new Map>();
+ invoke('projects:list', allow('observe'), async () => {
+ const thresholds = effThresholds();
+ const key = JSON.stringify(thresholds);
+ let gathering = gatherInFlight.get(key);
+ if (!gathering) {
+ const scan = makeDeckScan();
+ // Where a slow build spent its time, summed per stage across the worker pool — the only way to
+ // tell git from the transcript stores after the fact on a machine nobody was watching.
+ const spent = { scan: 0, git: 0, sessions: 0, cue: 0 };
+ const timed = async (stage: keyof typeof spent, work: () => Promise | T): Promise => {
+ const t = Date.now();
+ try { return await work(); } finally { spent[stage] += Date.now() - t; }
+ };
+ const startedMs = Date.now();
+ gathering = gatherProjects({
+ nowMs: startedMs,
+ thresholds,
+ scan: () => timed('scan', memoScan),
+ git: (dir) => timed('git', () => getGitInfo(dir)),
+ sessions: (p) => timed('sessions', () => scan.sessions(p)),
+ resumeCue: (p, session) => timed('cue', () => getProvider(session.agentId).lastUserMessage(p, session.id)),
+ sessionCount: (p) => timed('sessions', () => scan.count(p)),
+ });
+ gatherInFlight.set(key, gathering);
+ const started = gathering;
+ void started.then((list) => {
+ const ms = Date.now() - startedMs;
+ if (ms >= SLOW_PROJECT_LIST_MS) {
+ cfg.diagnostics?.write('warn', 'deck', `projects:list took ${ms}ms for ${list.length} projects (summed: scan=${spent.scan} git=${spent.git} sessions=${spent.sessions} cue=${spent.cue}ms)`);
+ }
+ }, () => undefined).finally(() => { if (gatherInFlight.get(key) === started) gatherInFlight.delete(key); });
+ }
+ return withEntries(await gathering, (p) => cfg.store.get(p));
});
invoke('project:memory', allow('observe'), (projectPath: string, fresh?: boolean) => {
const projectPathString = String(projectPath);
@@ -251,9 +266,27 @@ export function createDeckApi(cfg: DeckApiConfig): DeckApiBundle {
// These persist to state.json keyed by `path`. Guard with the same allowlist every other path-taking
// handler uses — a compromised renderer must not be able to write store entries (10KB notes, todo
// lists) for arbitrary paths outside any scanned folder and grow state.json unboundedly.
- invoke('project:setNote', allow('write'), (path: string, note: string) => {
+ /**
+ * A paired machine may write only to projects this machine actually scanned. The folder allowlist is
+ * a string check, and any path under a scanned folder passes it whether or not it exists — so a
+ * device with `write` could grow state.json with entries for invented paths, each write rewriting
+ * the whole file (and its backup) on the main thread.
+ */
+ async function writableProject(path: string): Promise {
if (!isAllowedPath(effFolders(), path)) throw new Error('Project is no longer in the allowed folders');
- cfg.store.setNote(path, String(note).slice(0, 10000));
+ if (!calledFromRemote()) return;
+ const known = await memoScan().catch(() => [] as Awaited>);
+ if (!known.some((p) => p.path === path)) throw new Error('Not a project on this machine');
+ }
+ invoke('project:setNote', allow('write'), async (path: string, note: string) => {
+ await writableProject(String(path));
+ cfg.store.setNote(path, String(note).slice(0, MAX_NOTE_CHARS));
+ });
+ // Versioned, so a note saved on another device in the meantime is noticed instead of erased. A
+ // distinct method: an older host answers "no such method" and the caller falls back to setNote.
+ invoke('project:saveNote', allow('write'), async (path: string, note: unknown, expectedRevision: number) => {
+ await writableProject(String(path));
+ return cfg.store.saveNote(String(path), String(note ?? '').slice(0, MAX_NOTE_CHARS), Number(expectedRevision));
});
// Keep the old channel only to explain the upgrade, never to accept unversioned writes.
invoke('project:setTodos', allow('write'), (path: string) => {
@@ -261,16 +294,16 @@ export function createDeckApi(cfg: DeckApiConfig): DeckApiBundle {
throw new Error('TASKS_CLIENT_OUTDATED: Update DevDeck on both devices before editing tasks');
});
// A distinct method prevents older hosts from silently ignoring a revision argument.
- invoke('project:saveTodos', allow('write'), (path: string, todos: unknown, expectedRevision: number) => {
- if (!isAllowedPath(effFolders(), path)) throw new Error('Project is no longer in the allowed folders');
+ invoke('project:saveTodos', allow('write'), async (path: string, todos: unknown, expectedRevision: number) => {
+ await writableProject(String(path));
return cfg.store.saveTodos(path, todos, expectedRevision);
});
- invoke('project:setPinned', allow('write'), (path: string, pinned: boolean) => {
- if (!isAllowedPath(effFolders(), path)) throw new Error('Project is no longer in the allowed folders');
+ invoke('project:setPinned', allow('write'), async (path: string, pinned: boolean) => {
+ await writableProject(String(path));
cfg.store.setPinned(path, pinned);
});
- invoke('project:setHidden', allow('write'), (path: string, hidden: boolean) => {
- if (!isAllowedPath(effFolders(), path)) throw new Error('Project is no longer in the allowed folders');
+ invoke('project:setHidden', allow('write'), async (path: string, hidden: boolean) => {
+ await writableProject(String(path));
cfg.store.setHidden(path, hidden);
});
@@ -284,6 +317,7 @@ export function createDeckApi(cfg: DeckApiConfig): DeckApiBundle {
* thing `memoScan` does for the folder walk, for the same reason.
*/
const usageInFlight = new Map>();
+ let remoteUsageQueue: Promise = Promise.resolve();
invoke('usage:report', allow('observe'), async (sinceMs: number) => {
const ms = (Number.isFinite(sinceMs) || sinceMs === Infinity) ? sinceMs : 0;
@@ -294,7 +328,13 @@ export function createDeckApi(cfg: DeckApiConfig): DeckApiBundle {
const key = usageInFlightKey(ms);
const running = usageInFlight.get(key);
if (running) return running;
- const started = buildUsageReport(ms);
+ // A paired machine asking for a different day each time would start a whole-store walk per ask, all
+ // at once. Its scans run one after another; this machine's own are never queued behind them.
+ let started: ReturnType;
+ if (calledFromRemote()) {
+ started = remoteUsageQueue.catch(() => undefined).then(() => buildUsageReport(ms));
+ remoteUsageQueue = started;
+ } else started = buildUsageReport(ms);
usageInFlight.set(key, started);
// Cleared on completion rather than on a timer: the point is to share one walk, not to cache its
// answer — the next refresh must see what is on disk now.
@@ -481,6 +521,9 @@ export function createDeckApi(cfg: DeckApiConfig): DeckApiBundle {
cfg.sendError(`Path outside allowed folders: ${p}`);
return;
}
+ // openPath RUNS a file it is given; this channel is for folders only.
+ const isDir = await stat(p).then((s) => s.isDirectory(), () => false);
+ if (!isDir) { cfg.sendError(`Not a folder: ${p}`); return; }
const err = await shell.openPath(p);
if (err) cfg.sendError(err);
});
@@ -615,7 +658,13 @@ export function createDeckApi(cfg: DeckApiConfig): DeckApiBundle {
(e) => {
ptyBatch.flush(); // flush buffered output before the exit notice
cfg.diagnostics?.write(e.exitCode === 0 ? 'info' : 'warn', 'pty', `exit ${id} code=${e.exitCode}`);
- emit('cockpit:exit', { id, exitCode: e.exitCode });
+ // Says whether somebody CLOSED it, and from where. Every deck showing this terminal hears the
+ // exit, but only the one that pressed close knew it was deliberate — the others kept a dead
+ // "exited" tile, saved it, and brought it back as a previous session (or, after an update
+ // restart, re-ran it). Optional fields: an older viewer reads only `id`/`exitCode`.
+ const closedBy = closing.get(id);
+ closing.delete(id);
+ emit('cockpit:exit', closedBy ? { id, exitCode: e.exitCode, closed: true, closedBy } : { id, exitCode: e.exitCode });
publishSessions();
},
{ projectPath: req.projectPath, sessionId: resolved.sessionId, agentId: a.id },
@@ -662,6 +711,18 @@ export function createDeckApi(cfg: DeckApiConfig): DeckApiBundle {
// lands on top of the older, wider one.
emit('cockpit:resized', { id: target, cols: c, rows: r });
});
+ /** Ids this machine is killing on request, and who asked — read by the exit handler above. */
+ const closing = new Map();
+ function closeLocal(target: string): void {
+ ptyBatch.drop(target);
+ const by = calledFromRemote() ? 'remote' : 'local';
+ cfg.diagnostics?.write('info', 'pty', `close ${target} by=${by}`);
+ if (cfg.ptyHost.list().some((s) => s.id === target)) closing.set(target, by);
+ cfg.ptyHost.kill(target);
+ publishSessions();
+ }
+ // Fire-and-forget, kept for paired machines on an older build. It cannot say whether the close
+ // happened, which is why a viewer now uses `cockpit:closeSession` below.
send('cockpit:close', allowSession('control'), (id: string) => {
const target = String(id);
if (isRemoteId(target)) {
@@ -670,10 +731,47 @@ export function createDeckApi(cfg: DeckApiConfig): DeckApiBundle {
remote(target, (link, machineId, hostId) => link.notify(machineId, 'cockpit:close', [hostId]));
return;
}
- ptyBatch.drop(target);
- cfg.diagnostics?.write('info', 'pty', `close ${target}`);
- cfg.ptyHost.kill(target);
- publishSessions();
+ closeLocal(target);
+ });
+ /**
+ * Close a session and SAY whether it closed.
+ *
+ * A remote close used to be a notify: offline, without the `control` permission, or simply dropped,
+ * it vanished — the tile disappeared here while the agent went on running there, and the next time
+ * that machine listed its sessions the tile was adopted straight back. The answer lets the deck keep
+ * the close pending until the machine is reachable, or put the tile back and say why.
+ */
+ invoke('cockpit:closeSession', allowSession('control'), async (id: string): Promise => {
+ const target = String(id);
+ if (!isRemoteId(target)) { closeLocal(target); return { ok: true }; }
+ if (refuseRelay(target, 'cockpit:closeSession')) return { ok: false, reason: 'error' };
+ const link = cfg.link?.();
+ if (!link) return { ok: false, reason: 'offline' };
+ const { machineId, hostId } = parseRemoteId(target);
+ if (link.machines().find((m) => m.machineId === machineId)?.state !== 'connected') return { ok: false, reason: 'offline' };
+ // The host meters requests, and a bulk close of a dozen remote tiles is a dozen of them at once.
+ for (let attempt = 0; ; attempt++) {
+ try {
+ await link.call(machineId, 'cockpit:closeSession', [hostId]);
+ link.detach(target);
+ return { ok: true };
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ if (/^too many requests/.test(message) && attempt < 6) { await new Promise((r) => setTimeout(r, 400 * (attempt + 1))); continue; }
+ // A host on an older build has only the notify. Send that; it is what every close used to be.
+ if (/^no such method/.test(message)) { link.detach(target); link.notify(machineId, 'cockpit:close', [hostId]); return { ok: true }; }
+ // Not running there any more: closing it is already done.
+ if (/^not a session on this machine/.test(message)) { link.detach(target); return { ok: true }; }
+ if (/^not permitted/.test(message)) return { ok: false, reason: 'denied' };
+ return { ok: false, reason: /offline|closed|timed out|unknown machine/.test(message) ? 'offline' : 'error', error: message };
+ }
+ }
+ });
+ // A tile that stopped showing a remote terminal without closing it (it was retired or rebound to a
+ // new id): stop asking the host to stream it, or every reconnect re-attaches an id that is gone.
+ send('cockpit:detach', localOnly, (id: string) => {
+ const target = String(id);
+ if (isRemoteId(target)) cfg.link?.()?.detach(target);
});
// Cockpit session persistence: remember the open sessions so a quit/crash doesn't lose them.
@@ -801,20 +899,25 @@ export function createDeckApi(cfg: DeckApiConfig): DeckApiBundle {
// rollout store is flat, so this walks all of it.
const wantsCodex = list.some((raw) => agentFor((raw as { agentId?: unknown })?.agentId).id === 'codex');
const codexByCwd = wantsCodex ? await indexCodexSessionsByCwd(CODEX_SESSIONS) : null;
- return list.map((raw) => {
+ const out: boolean[] = [];
+ for (const raw of list) {
const it = (raw && typeof raw === 'object' ? raw : {}) as Record;
const projectPath = typeof it.projectPath === 'string' ? it.projectPath : '';
const sessionId = typeof it.sessionId === 'string' ? it.sessionId : '';
- if (!sessionId || !projectPath || !isAllowedPath(folders, projectPath)) return true;
+ if (!sessionId || !projectPath || !isAllowedPath(folders, projectPath)) { out.push(true); continue; }
const owner = agentFor(it.agentId).id;
- if (owner === 'antigravity') return true; // records no per-tile id — nothing to verify
+ if (owner === 'antigravity') { out.push(true); continue; } // records no per-tile id — nothing to verify
if (owner === 'codex') {
- return (codexByCwd?.get(cwdKey(projectPath)) ?? []).some((s) => s.id === sessionId);
+ out.push((codexByCwd?.get(cwdKey(projectPath)) ?? []).some((s) => s.id === sessionId));
+ continue;
}
+ // Awaited (the listing is async so a 200-entry restore list never holds the main thread), and
+ // still read once per distinct project.
let ids = claudeIds.get(projectPath);
- if (!ids) { ids = new Set(listSessionIds(projectPath, CLAUDE_PROJECTS)); claudeIds.set(projectPath, ids); }
- return ids.has(sessionId);
- });
+ if (!ids) { ids = new Set(await listSessionIds(projectPath, CLAUDE_PROJECTS)); claudeIds.set(projectPath, ids); }
+ out.push(ids.has(sessionId));
+ }
+ return out;
});
/**
* When each saved entry's conversation was last written — its transcript's mtime — answered as a
@@ -829,6 +932,14 @@ export function createDeckApi(cfg: DeckApiConfig): DeckApiBundle {
const claudeStats = new Map>();
const wantsCodex = list.some((raw) => agentFor((raw as { agentId?: unknown })?.agentId).id === 'codex');
const codexByCwd = wantsCodex ? await indexCodexSessionsByCwd(CODEX_SESSIONS) : null;
+ // Claude's per-project stat pass is async (it stats every transcript), so each project is read
+ // once up front and the answers below stay a plain lookup.
+ for (const raw of list) {
+ const it = (raw && typeof raw === 'object' ? raw : {}) as Record;
+ const projectPath = typeof it.projectPath === 'string' ? it.projectPath : '';
+ if (!projectPath || claudeStats.has(projectPath) || agentFor(it.agentId).id !== 'claude' || !isAllowedPath(folders, projectPath)) continue;
+ claudeStats.set(projectPath, new Map((await listSessionStats(projectPath, CLAUDE_PROJECTS)).map((s) => [s.id, s.mtimeMs])));
+ }
return list.map((raw): number | null => {
const it = (raw && typeof raw === 'object' ? raw : {}) as Record;
const projectPath = typeof it.projectPath === 'string' ? it.projectPath : '';
@@ -839,12 +950,7 @@ export function createDeckApi(cfg: DeckApiConfig): DeckApiBundle {
if (owner === 'codex') {
return (codexByCwd?.get(cwdKey(projectPath)) ?? []).find((s) => s.id === sessionId)?.mtimeMs ?? null;
}
- let stats = claudeStats.get(projectPath);
- if (!stats) {
- stats = new Map(listSessionStats(projectPath, CLAUDE_PROJECTS).map((s) => [s.id, s.mtimeMs]));
- claudeStats.set(projectPath, stats);
- }
- return stats.get(sessionId) ?? null;
+ return claudeStats.get(projectPath)?.get(sessionId) ?? null;
});
});
// Live session-id drift check (/clear starts a brand-new session id in the same terminal — the
@@ -852,11 +958,21 @@ export function createDeckApi(cfg: DeckApiConfig): DeckApiBundle {
// sends the tile's timing evidence; this stats the project's session files and adopts a new id only
// when unambiguous (pickDriftedSessionId). Claude and Codex have per-file session stores;
// Antigravity does not.
+ /**
+ * Whether a liveSessionId answer may be recorded on a terminal. The call needs only `observe`, and the
+ * timing evidence it carries is the caller's, so a paired machine could otherwise point any terminal
+ * here at any conversation — and every deck reconciling against this machine would follow. Only this
+ * machine's own window records, and only on a terminal of the project and provider it asked about.
+ */
+ function notablePty(ptyId: unknown, projectPath: string, agentId: string): boolean {
+ if (calledFromRemote() || typeof ptyId !== 'string' || !ptyId) return false;
+ return cfg.ptyHost.list().some((s) => s.id === ptyId && s.projectPath === projectPath && s.agentId === agentId);
+ }
invoke('cockpit:liveSessionId', allow('observe'), async (projectPath: string, opts: { currentId: string | null; claimedIds: string[]; openedAtMs: number; sinceMs: number; lastDataAtMs: number; agentId?: AgentId; ptyId?: string }) => {
if (!isAllowedPath(effFolders(), String(projectPath))) return null;
if (!opts || typeof opts !== 'object') return null;
const a = agentFor(opts.agentId).id;
- const stats = a === 'claude' ? listSessionStats(String(projectPath), CLAUDE_PROJECTS)
+ const stats = a === 'claude' ? await listSessionStats(String(projectPath), CLAUDE_PROJECTS)
: a === 'codex' ? await listCodexSessionStats(String(projectPath), CODEX_SESSIONS)
: null;
if (!stats) return null;
@@ -866,7 +982,14 @@ export function createDeckApi(cfg: DeckApiConfig): DeckApiBundle {
const lastDataAtMs = Number(opts.lastDataAtMs) || 0;
// No id at all (the tile's provider was just re-detected, so its old id went with the old
// provider): nothing to drift FROM, and a `-c` resume writes a file born before the tile opened.
- if (!currentId) return pickAdoptedSessionId(stats, { claimedIds, sinceMs, lastDataAtMs });
+ if (!currentId) {
+ const picked = pickAdoptedSessionId(stats, { claimedIds, sinceMs, lastDataAtMs });
+ // Recorded on the pty for the same reason as a drift below: without it this machine keeps
+ // announcing the session with no conversation, and an open of that conversation from another
+ // deck does not find it running and starts a second agent on it.
+ if (picked && notablePty(opts.ptyId, String(projectPath), a) && cfg.ptyHost.note(opts.ptyId!, { sessionId: picked })) publishSessions();
+ return picked;
+ }
const drifted = pickDriftedSessionId(stats, {
currentId,
claimedIds,
@@ -876,8 +999,8 @@ export function createDeckApi(cfg: DeckApiConfig): DeckApiBundle {
});
// Recorded on the pty as well, so a machine listing what is running here reports the conversation
// a tile actually MOVED to (after /clear) rather than the one it happened to open on.
- if (drifted && typeof opts.ptyId === 'string' && opts.ptyId) {
- if (cfg.ptyHost.note(opts.ptyId, { sessionId: drifted })) publishSessions();
+ if (drifted && notablePty(opts.ptyId, String(projectPath), a)) {
+ if (cfg.ptyHost.note(opts.ptyId!, { sessionId: drifted })) publishSessions();
}
return drifted;
});
@@ -892,7 +1015,11 @@ export function createDeckApi(cfg: DeckApiConfig): DeckApiBundle {
invoke('cockpit:liveAgent', allowSession('observe'), async (id: string) => {
const pid = cfg.ptyHost.pid(String(id));
if (!pid) return null;
- try { return await probeAgent(pid); } catch { return null; }
+ let found: Awaited> = null;
+ try { found = await probeAgent(pid); } catch { return null; }
+ // The provider a tile is really running is part of what this machine announces about it.
+ if (typeof found === 'string' && cfg.ptyHost.note(String(id), { agentId: found })) publishSessions();
+ return found;
});
// Live git branch + dirty count for a cockpit session's project. Re-read on a slow tick so a
@@ -908,6 +1035,13 @@ export function createDeckApi(cfg: DeckApiConfig): DeckApiBundle {
// Tray attention indicator (Discord-style): the renderer supplies the red-dotted icon once + live needs-you counts.
send('tray:alertImage', localOnly, (dataUrl: string) => cfg.tray.setAlertImage(String(dataUrl)));
+ send('tray:labels', localOnly, (labels: unknown) => {
+ if (!labels || typeof labels !== 'object') return;
+ const pick = (k: string): string | undefined => { const v = (labels as Record)[k]; return typeof v === 'string' && v.length <= 80 ? v : undefined; };
+ const next: Partial = {};
+ for (const k of ['open', 'shutdownIdle', 'shutdownNow', 'cancelShutdown', 'quit', 'waiting', 'overdue'] as const) { const v = pick(k); if (v) next[k] = v; }
+ cfg.tray.setLabels?.(next as import('../../shared/trayState').TrayLabels);
+ });
// Partial merge: the cockpit sends {attention, turn} and the deck sends {overdue} independently —
// each sender updates only the fields it owns, so one can't zero the other's counts.
send('tray:counts', localOnly, (counts: { attention?: number; turn?: number; overdue?: number }) => {
@@ -973,6 +1107,7 @@ export function createDeckApi(cfg: DeckApiConfig): DeckApiBundle {
* Deliberately narrow: it accepts a PNG and nothing else, writes only into the OS temp directory
* under the name the existing sweeper already cleans up, and never takes a caller-supplied path.
*/
+ const remoteImageWindow = { startMs: 0, bytes: 0 };
invoke('cockpit:receiveImage', allow('control'), (base64: unknown) => {
if (typeof base64 !== 'string' || base64.length > MAX_PASTE_IMAGE_BYTES * 2) return null;
let png: Buffer;
@@ -982,6 +1117,12 @@ export function createDeckApi(cfg: DeckApiConfig): DeckApiBundle {
const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
if (png.length < PNG_MAGIC.length || !png.subarray(0, PNG_MAGIC.length).equals(PNG_MAGIC)) return null;
if (png.length > MAX_PASTE_IMAGE_BYTES) return null;
+ // The sweeper clears these after a day; until then a paired machine could fill the disk at the
+ // request rate. A screenshot an hour's worth of pasting does not come near this.
+ const now = Date.now();
+ if (now - remoteImageWindow.startMs > 3_600_000) { remoteImageWindow.startMs = now; remoteImageWindow.bytes = 0; }
+ if (remoteImageWindow.bytes + png.length > REMOTE_IMAGE_BYTES_PER_HOUR) return null;
+ remoteImageWindow.bytes += png.length;
const file = join(tmpdir(), `${PASTE_IMAGE_PREFIX}${randomUUID()}.png`);
try { writeFileSync(file, png); } catch { return null; }
return file;
diff --git a/src/main/claudeTasks.ts b/src/main/claudeTasks.ts
index 7b61eb5..04d2ab0 100644
--- a/src/main/claudeTasks.ts
+++ b/src/main/claudeTasks.ts
@@ -44,7 +44,9 @@ export function readActiveTaskForm(
const dir = join(tasksDir, sessionId);
let names: string[];
try {
- names = readdirSync(dir).filter((n) => n.endsWith('.json')).sort(byNumber).slice(0, MAX_FILES);
+ // The NEWEST files: tasks are numbered in creation order, so the first fifty of a long session's
+ // list are the oldest, and the task actually in progress was never read once it passed fifty.
+ names = readdirSync(dir).filter((n) => n.endsWith('.json')).sort(byNumber).slice(-MAX_FILES);
} catch {
return null; // no task list for this session
}
diff --git a/src/main/cliExecutable.test.ts b/src/main/cliExecutable.test.ts
new file mode 100644
index 0000000..370a655
--- /dev/null
+++ b/src/main/cliExecutable.test.ts
@@ -0,0 +1,71 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+
+const { whereOutput } = vi.hoisted(() => ({ whereOutput: vi.fn((): string => '') }));
+vi.mock('node:child_process', () => ({ execFileSync: vi.fn(() => Buffer.from(whereOutput())) }));
+
+import { execFileSync } from 'node:child_process';
+import { resolveAgentCliPath, _clearCliPathCache } from './cliExecutable';
+
+let dir: string;
+// The per-user install locations are checked after `where`; point them at an empty dir so what is
+// installed on the machine running the tests cannot answer for the stub.
+const SAVED = { APPDATA: process.env.APPDATA, LOCALAPPDATA: process.env.LOCALAPPDATA, USERPROFILE: process.env.USERPROFILE };
+beforeEach(() => {
+ dir = mkdtempSync(join(tmpdir(), 'devdeck-cli-'));
+ Object.assign(process.env, { APPDATA: dir, LOCALAPPDATA: dir, USERPROFILE: dir });
+ _clearCliPathCache();
+ vi.mocked(execFileSync).mockClear();
+});
+afterEach(() => {
+ for (const [k, v] of Object.entries(SAVED)) { if (v === undefined) delete process.env[k]; else process.env[k] = v; }
+ rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
+});
+
+// `where` is a synchronous child process (~50 ms of a frozen main process); the Codex usage refresh
+// asked it on every poll.
+describe('resolveAgentCliPath lookup cache', () => {
+ it('runs `where` once for repeated asks, and again once the found file is gone', () => {
+ const exe = join(dir, 'codex.cmd');
+ writeFileSync(exe, '');
+ whereOutput.mockReturnValue(`${exe}\r\n`);
+ expect(resolveAgentCliPath('codex', process.env, 'win32')).toBe(exe);
+ expect(resolveAgentCliPath('codex', process.env, 'win32')).toBe(exe);
+ expect(execFileSync).toHaveBeenCalledTimes(1);
+
+ rmSync(exe); // uninstalled: the remembered path must not be handed out
+ whereOutput.mockReturnValue('');
+ expect(resolveAgentCliPath('codex', process.env, 'win32')).toBe('codex');
+ expect(execFileSync).toHaveBeenCalledTimes(2);
+ });
+
+ it('never remembers a miss — a CLI installed later is found on the next ask', () => {
+ whereOutput.mockReturnValue('');
+ const env = process.env;
+ const before = resolveAgentCliPath('claude', env, 'win32');
+ resolveAgentCliPath('claude', env, 'win32');
+ expect(execFileSync).toHaveBeenCalledTimes(2);
+ const exe = join(dir, 'claude.exe');
+ writeFileSync(exe, '');
+ whereOutput.mockReturnValue(exe);
+ expect(resolveAgentCliPath('claude', env, 'win32')).toBe(exe);
+ expect(before).not.toBe(exe);
+ });
+
+ it('does not cache lookups made with an injected environment or existence check', () => {
+ const exe = join(dir, 'codex.exe');
+ writeFileSync(exe, '');
+ whereOutput.mockReturnValue(exe);
+ const exists = (): boolean => true;
+ resolveAgentCliPath('codex', process.env, 'win32', exists);
+ resolveAgentCliPath('codex', process.env, 'win32', exists);
+ expect(execFileSync).toHaveBeenCalledTimes(2);
+ });
+
+ it('is not consulted off Windows', () => {
+ expect(resolveAgentCliPath('codex', process.env, 'linux')).toBe('codex');
+ expect(execFileSync).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/main/cliExecutable.ts b/src/main/cliExecutable.ts
index 798553e..c9ae51f 100644
--- a/src/main/cliExecutable.ts
+++ b/src/main/cliExecutable.ts
@@ -32,6 +32,19 @@ export function resolveAgentCliPath(
exists: (path: string) => boolean = existsSync,
): string {
if (platform !== 'win32') return providerId;
+ // Only the production lookup is remembered; an injected env / exists is a test asking a question.
+ const cacheable = env === process.env && exists === existsSync;
+ const key = `${providerId}\n${env.PATH ?? env.Path ?? ''}`;
+ if (cacheable) {
+ const hit = _found.get(key);
+ if (hit && Date.now() - hit.at < FOUND_TTL_MS && exists(hit.path)) return hit.path;
+ }
+ const found = lookUp(providerId, env, exists);
+ if (cacheable && found !== providerId) _found.set(key, { path: found, at: Date.now() });
+ return found;
+}
+
+function lookUp(providerId: LoginProvider, env: NodeJS.ProcessEnv, exists: (path: string) => boolean): string {
try {
const found = execFileSync('where', [providerId], { env, windowsHide: true })
.toString().split(/\r?\n/).map((line) => line.trim()).find((line) => /\.(?:cmd|exe)$/i.test(line) && exists(line));
@@ -40,6 +53,19 @@ export function resolveAgentCliPath(
return knownWindowsCandidates(providerId, env).find(exists) ?? providerId;
}
+/**
+ * Paths already found, briefly. `where` is a synchronous child process — ~50 ms here during which the
+ * main process does nothing else — and the Codex usage refresh ran it on every poll. Positive results
+ * only (a CLI installed after DevDeck started must still be found on the next ask), re-checked for
+ * existence on every hit (an uninstall is noticed at once), keyed by PATH (a changed PATH is a new
+ * question), and expired after a few minutes so a different install that now comes first on PATH is
+ * picked up without a restart.
+ */
+const FOUND_TTL_MS = 5 * 60_000;
+const _found = new Map();
+/** Test-only: forget remembered CLI paths. */
+export function _clearCliPathCache(): void { _found.clear(); }
+
/** Fixed, provider-owned login command for the visible PowerShell setup terminal. */
export function loginPowerShellCommand(providerId: LoginProvider): string {
const executable = resolveAgentCliPath(providerId).replace(/'/g, "''");
diff --git a/src/main/codexSessions.test.ts b/src/main/codexSessions.test.ts
index bc6e1c5..96a7028 100644
--- a/src/main/codexSessions.test.ts
+++ b/src/main/codexSessions.test.ts
@@ -199,3 +199,70 @@ describe('the head cache', () => {
expect(await listCodexRolloutHeads(dir)).toEqual([]);
});
});
+
+describe('per-project passes and the shared head cache', () => {
+ // The cockpit asks one project's question per Codex tile per tick; a pass for one project skips the
+ // stat of rollouts already known to be another project's. Everything a full pass would find for
+ // THIS project must still be found.
+ it('a per-project pass still sees this project\'s new, changed, deleted and late-valid rollouts', async () => {
+ const mine = writeRollout(`rollout-${OLD_ID}.jsonl`, rollout(OLD_ID, PROJECT));
+ writeRollout(`rollout-${OTHER_ID}.jsonl`, rollout(OTHER_ID, OTHER_PROJECT));
+ const late = writeRollout(`rollout-${NEW_ID}.jsonl`, ''); // created before its head is written
+ utimesSync(mine, 1000, 1000);
+ expect((await listCodexRolloutHeads(dir)).map((h) => h.id).sort()).toEqual([OLD_ID, OTHER_ID]); // the empty one has no head yet
+
+ writeFileSync(late, rollout(NEW_ID, PROJECT)); // the head lands later
+ utimesSync(late, 3000, 3000);
+ writeFileSync(mine, rollout(OLD_ID, PROJECT, [user('first'), user('appended')])); // this project's file grows
+ utimesSync(mine, 2000, 2000);
+ const extra = '44444444-4444-4444-4444-444444444444';
+ writeRollout(`rollout-${extra}.jsonl`, rollout(extra, PROJECT)); // a brand-new one
+
+ const stats = await listCodexSessionStats(PROJECT, dir);
+ expect(stats.map((s) => s.id).sort()).toEqual([OLD_ID, NEW_ID, extra].sort());
+ expect(stats.find((s) => s.id === OLD_ID)?.mtimeMs).toBe(2_000_000);
+ expect((await listCodexSessionStats(OTHER_PROJECT, dir)).map((s) => s.id)).toEqual([OTHER_ID]);
+
+ rmSync(mine);
+ expect((await listCodexSessionIds(PROJECT, dir)).sort()).toEqual([NEW_ID, extra].sort());
+ });
+
+ it('a per-project pass agrees with filtering a full pass', async () => {
+ for (let i = 0; i < 150; i++) {
+ const id = `a0b1c2d3-0000-0000-0000-${String(i).padStart(12, '0')}`;
+ const file = writeRollout(`rollout-${id}.jsonl`, rollout(id, i % 3 === 0 ? PROJECT : OTHER_PROJECT));
+ utimesSync(file, 1000 + i, 1000 + i);
+ }
+ const full = (await listCodexRolloutHeads(dir)).filter((h) => h.cwd === PROJECT).map((h) => h.id).sort();
+ expect((await listCodexSessionIds(PROJECT, dir)).sort()).toEqual(full);
+ _clearCodexHeadCache(); // and from a cold cache, where nothing can be skipped
+ expect((await listCodexSessionIds(PROJECT, dir)).sort()).toEqual(full);
+ });
+
+ it('callers sharing one pass each get their own array', async () => {
+ writeRollout(`rollout-${OLD_ID}.jsonl`, rollout(OLD_ID, PROJECT));
+ writeRollout(`rollout-${NEW_ID}.jsonl`, rollout(NEW_ID, PROJECT));
+ const [a, b] = await Promise.all([listCodexRolloutHeads(dir), listCodexRolloutHeads(dir)]);
+ expect(a).not.toBe(b);
+ expect(a).toEqual(b);
+ a.reverse();
+ expect(b.map((h) => h.id)).not.toEqual(a.map((h) => h.id));
+ });
+
+ // The deck asks for every project's resume cue on each refresh. Resolving the id used to be a pass
+ // over the whole store per project; the deck's own index pass has already read every head.
+ it('resolves a session from the head cache, and follows the rollout as it grows', async () => {
+ const file = writeRollout(`rollout-${NEW_ID}.jsonl`, rollout(NEW_ID, PROJECT, [user('first ask')]));
+ await listCodexRolloutHeads(dir);
+ expect(await lastUserMessageForCodexSession(PROJECT, NEW_ID, dir)).toBe('first ask');
+ writeFileSync(file, rollout(NEW_ID, PROJECT, [user('first ask'), user('the newer ask')]));
+ expect(await lastUserMessageForCodexSession(PROJECT, NEW_ID, dir)).toBe('the newer ask');
+ expect(await lastUserMessageForCodexSession(OTHER_PROJECT, NEW_ID, dir)).toBeNull(); // still project-scoped
+ });
+
+ it('finds a user message several chunks back, past multi-byte text', async () => {
+ const filler = JSON.stringify({ type: 'event_msg', payload: { type: 'agent_message', message: '한글 출력 '.repeat(40_000) } });
+ writeRollout(`rollout-${NEW_ID}.jsonl`, rollout(NEW_ID, PROJECT, [user('older'), user('깊이 묻힌 요청'), filler, filler, filler]));
+ expect(await lastUserMessageForCodexSession(PROJECT, NEW_ID, dir)).toBe('깊이 묻힌 요청');
+ });
+});
diff --git a/src/main/codexSessions.ts b/src/main/codexSessions.ts
index 5401a85..72e2e44 100644
--- a/src/main/codexSessions.ts
+++ b/src/main/codexSessions.ts
@@ -1,10 +1,11 @@
-import { closeSync, existsSync, openSync, readSync, readdirSync, statSync } from 'node:fs';
-import { open, readdir, stat } from 'node:fs/promises';
+import { existsSync } from 'node:fs';
+import { open, readdir, stat, type FileHandle } from 'node:fs/promises';
import { setImmediate as yieldToLoop } from 'node:timers/promises';
import { join } from 'node:path';
import type { SessionMeta } from '../shared/types';
import { codexFirstUserMessage, codexLastUserMessage, codexSessionMeta, codexTailMeta, type CodexTailMeta } from '../shared/codexParse';
import { SESSION_ID_RE, cwdKey } from '../shared/paths';
+import { cachedTailAnswer, findInTail } from './sessions';
const HEAD_BYTES = 64 * 1024;
const TAIL_CHUNK_BYTES = 1024 * 1024;
@@ -65,59 +66,117 @@ async function rolloutFiles(dir: string): Promise {
* seconds during which the main process did nothing else — no IPC replies, no terminal output, no
* frames. That pass now costs one stat() per file.
*/
-interface CachedHead { mtimeMs: number; birthtimeMs: number; size: number; head: CodexRolloutHead | null }
+interface CachedHead {
+ mtimeMs: number; birthtimeMs: number; size: number; head: CodexRolloutHead | null;
+ /** cwdKey(head.cwd), worked out once — every per-project question compares against it. */
+ key: string | null;
+ /** The store the file was found under, so a lookup by id never answers from a different store. */
+ root: string;
+}
const _headCache = new Map();
/** Test-only: forget every cached head so a fixture rewritten in place is re-read. */
-export function _clearCodexHeadCache(): void { _headCache.clear(); }
+export function _clearCodexHeadCache(): void { _headCache.clear(); _resolved.clear(); }
/** Files to touch between yields. Big enough that the syscalls dominate, small enough to stay smooth. */
const HEAD_SCAN_BATCH = 64;
-/** Every readable rollout's head, whatever project it belongs to (one bounded read per file). */
-export async function listCodexRolloutHeads(dir: string): Promise {
+type FileStat = { mtimeMs: number; birthtimeMs: number; size: number };
+
+/**
+ * One pass over the store. With `onlyKey`, the pass answers for ONE project, and skips the stat of
+ * every rollout whose cached head already names a different one.
+ *
+ * Why that is safe: a head is written once and never rewritten (see above), and a rollout's path is
+ * its timestamp plus its session uuid, so a file known to belong to another project cannot come to
+ * belong to this one. Files with no usable head yet, files never seen, and this project's own files
+ * are all still statted (and re-read on any change) exactly as in a full pass. The directory walk is
+ * still complete, so new files are found and deleted ones still drop out of the cache.
+ *
+ * Why it matters: the cockpit asks this once per Codex tile per tick (drift detection), and on the
+ * store this was reported from that was 5,000 stats — ~160 ms of the four-thread libuv pool, which
+ * every other file read in the app queues behind — to learn about the few dozen files of one project.
+ *
+ * Each batch's stats go out together (in order of results, so the output order is the walk order,
+ * as before): 5,000 one-at-a-time round-trips were most of a full pass's wall time.
+ */
+async function scanHeads(dir: string, onlyKey: string | null): Promise {
if (!existsSync(dir)) return [];
const files = await rolloutFiles(dir);
const heads: CodexRolloutHead[] = [];
- const seen = new Set();
- let sinceYield = 0;
- for (const file of files) {
- if (++sinceYield >= HEAD_SCAN_BATCH) { sinceYield = 0; await yieldToLoop(); }
- seen.add(file);
- let mtimeMs: number, birthtimeMs: number, size: number;
- try { ({ mtimeMs, birthtimeMs, size } = await stat(file)); } catch { continue; }
- const cached = _headCache.get(file);
- // Identity of the bytes we already read: same file, same length, same timestamps. An append
- // changes size and mtime, and a replaced file changes birthtime — neither can leave the head
- // it was parsed from intact while all three match.
- if (cached && cached.size === size && cached.mtimeMs === mtimeMs && cached.birthtimeMs === birthtimeMs) {
- if (cached.head) heads.push({ ...cached.head, mtimeMs, birthtimeMs, size });
- continue;
- }
- const raw = await readHead(file);
- const meta = raw === null ? null : codexSessionMeta(raw);
- if (!meta || !meta.cwd || !SESSION_ID_RE.test(meta.id)) {
- // Remembered as unusable too: a file that is not a rollout stays not a rollout, and re-reading
- // 64 KiB from it every pass is the same waste as re-reading a good one.
- _headCache.set(file, { mtimeMs, birthtimeMs, size, head: null });
- continue;
+ const seen = new Set(files);
+ for (let start = 0; start < files.length; start += HEAD_SCAN_BATCH) {
+ if (start > 0) await yieldToLoop();
+ const batch = files.slice(start, start + HEAD_SCAN_BATCH);
+ const stats = await Promise.all(batch.map(async (file): Promise => {
+ const cached = _headCache.get(file);
+ if (onlyKey !== null && cached?.head && cached.key !== onlyKey) return 'elsewhere';
+ try { return await stat(file); } catch { return null; }
+ }));
+ for (let i = 0; i < batch.length; i++) {
+ const file = batch[i];
+ const st = stats[i];
+ if (st === null || st === 'elsewhere') continue;
+ const { mtimeMs, birthtimeMs, size } = st;
+ const cached = _headCache.get(file);
+ // Identity of the bytes we already read: same file, same length, same timestamps. An append
+ // changes size and mtime, and a replaced file changes birthtime — neither can leave the head
+ // it was parsed from intact while all three match.
+ if (cached && cached.size === size && cached.mtimeMs === mtimeMs && cached.birthtimeMs === birthtimeMs) {
+ if (cached.head && (onlyKey === null || cached.key === onlyKey)) heads.push({ ...cached.head, mtimeMs, birthtimeMs, size });
+ continue;
+ }
+ const raw = await readHead(file);
+ const meta = raw === null ? null : codexSessionMeta(raw);
+ if (!meta || !meta.cwd || !SESSION_ID_RE.test(meta.id)) {
+ // Remembered as unusable too: a file that is not a rollout stays not a rollout, and re-reading
+ // 64 KiB from it every pass is the same waste as re-reading a good one.
+ _headCache.set(file, { mtimeMs, birthtimeMs, size, head: null, key: null, root: dir });
+ continue;
+ }
+ const head: CodexRolloutHead = {
+ file, id: meta.id, cwd: meta.cwd, mtimeMs, birthtimeMs, size, firstMessage: codexFirstUserMessage(raw!),
+ };
+ const key = cwdKey(meta.cwd);
+ _headCache.set(file, { mtimeMs, birthtimeMs, size, head, key, root: dir });
+ if (onlyKey === null || key === onlyKey) heads.push(head);
}
- const head: CodexRolloutHead = {
- file, id: meta.id, cwd: meta.cwd, mtimeMs, birthtimeMs, size, firstMessage: codexFirstUserMessage(raw!),
- };
- _headCache.set(file, { mtimeMs, birthtimeMs, size, head });
- heads.push(head);
}
// Rollouts are deleted too; without this the cache would only ever grow.
if (_headCache.size > seen.size) for (const key of [..._headCache.keys()]) if (!seen.has(key)) _headCache.delete(key);
return heads;
}
+/**
+ * Passes in progress, by store + project. The deck's refresh asks for the whole store twice at once
+ * (its session index and the usage report), and tiles restored together tick together; each used to
+ * walk and stat the store on its own. A caller arriving mid-pass now shares it — and gets its own
+ * copy of the array, because callers sort what they are given.
+ */
+const _passes = new Map>();
+
+async function sharedPass(dir: string, onlyKey: string | null): Promise {
+ const id = `${onlyKey ?? '*'}\n${dir}`;
+ let pass = _passes.get(id);
+ if (!pass) {
+ pass = scanHeads(dir, onlyKey);
+ _passes.set(id, pass);
+ const started = pass;
+ // Cleared on completion, never on a timer: the point is to share one walk, not to cache its answer.
+ void started.catch(() => undefined).finally(() => { if (_passes.get(id) === started) _passes.delete(id); });
+ }
+ return (await pass).slice();
+}
+
+/** Every readable rollout's head, whatever project it belongs to (one bounded read per file). */
+export async function listCodexRolloutHeads(dir: string): Promise {
+ return sharedPass(dir, null);
+}
+
async function rolloutHeads(projectPath: string, dir: string): Promise {
// Match the recorded CWD as a canonical key: a rollout for a similarly named project must never
// leak in, while the same project spelled with other separators / drive case still matches.
- const key = cwdKey(projectPath);
- return (await listCodexRolloutHeads(dir)).filter((h) => cwdKey(h.cwd) === key);
+ return sharedPass(dir, cwdKey(projectPath));
}
/**
@@ -171,15 +230,37 @@ export async function listCodexSessionStats(projectPath: string, dir: string): P
* only has to be re-validated for existence; the TTL is there for ids that appear later.
*/
const FILE_TTL_MS = 5 * 60_000;
-const _fileCache = new Map();
+const _resolved = new Map();
+
+/**
+ * The rollout the head cache already knows for this id and project, when exactly one does.
+ *
+ * The miss path below is a pass over the store, and it was taken per PROJECT: the deck asks for every
+ * project's resume cue on each refresh, and every five minutes each Codex project's entry expired and
+ * walked and statted all 5,000 rollouts again — 100 projects meant 100 passes, measured at 18 seconds
+ * of saturated file I/O on a synthetic store of that size. But the deck's own session index has just
+ * made that same pass, so the head cache already holds the answer. Two matches (the same session id
+ * in two files) are left to the walk, which picks one the way it always has.
+ */
+function knownRollout(dir: string, key: string, id: string): string | null {
+ let found: string | null = null;
+ for (const [file, entry] of _headCache) {
+ if (entry.root !== dir || entry.key !== key || entry.head?.id !== id) continue;
+ if (found) return null;
+ found = file;
+ }
+ return found;
+}
async function resolveRolloutFile(projectPath: string, id: string, dir: string, now = Date.now()): Promise {
- const key = `${cwdKey(projectPath)} ${id}`;
- const hit = _fileCache.get(key);
+ const projectKey = cwdKey(projectPath);
+ const key = `${projectKey}\u0000${id}`;
+ const hit = _resolved.get(key);
if (hit && now - hit.at < FILE_TTL_MS && existsSync(hit.file)) return hit.file;
- const file = (await rolloutHeads(projectPath, dir)).find((head) => head.id === id)?.file;
- if (!file) { _fileCache.delete(key); return null; }
- _fileCache.set(key, { file, at: now });
+ const known = knownRollout(dir, projectKey, id);
+ const file = known && existsSync(known) ? known : (await rolloutHeads(projectPath, dir)).find((head) => head.id === id)?.file;
+ if (!file) { _resolved.delete(key); return null; }
+ _resolved.set(key, { file, at: now });
return file;
}
@@ -204,15 +285,16 @@ export async function readCodexSessionMeta(projectPath: string, id: string, dir:
if (!file) return emptyCodexMeta();
let size: number;
let mtimeMs: number;
- try { ({ size, mtimeMs } = statSync(file)); } catch { return emptyCodexMeta(); }
+ try { ({ size, mtimeMs } = await stat(file)); } catch { return emptyCodexMeta(); }
const cached = _metaCache.get(file);
if (cached && cached.mtimeMs === mtimeMs) return cached.value;
- let fd: number | null = null;
+ // Async: this was a 2 MB readSync on every tick of a working Codex tile (its mtime moves each turn).
+ let handle: FileHandle | null = null;
try {
const length = Math.min(SUMMARY_TAIL_BYTES, size);
- fd = openSync(file, 'r');
+ handle = await open(file, 'r');
const buffer = Buffer.alloc(length);
- const bytesRead = readSync(fd, buffer, 0, length, size - length);
+ const { bytesRead } = await handle.read(buffer, 0, length, size - length);
const text = buffer.toString('utf8', 0, bytesRead);
// Drop the leading partial line unless we read from the very start of the file.
const newline = text.indexOf('\n');
@@ -230,45 +312,19 @@ export async function readCodexSessionMeta(projectPath: string, id: string, dir:
} catch {
return emptyCodexMeta();
} finally {
- if (fd !== null) {
- try { closeSync(fd); } catch { /* file disappeared */ }
- }
+ await handle?.close().catch(() => { /* file disappeared */ });
}
}
/**
* Find the last user message without trusting an id as a path component. We locate a matching,
- * metadata-validated rollout first, then scan its final 8 MiB backward in bounded chunks.
+ * metadata-validated rollout first, then scan its final 8 MiB backward in bounded chunks — async,
+ * searching each read's new lines only, and answered from cache while the rollout is unchanged
+ * (see findInTail / cachedTailAnswer).
*/
export async function lastUserMessageForCodexSession(projectPath: string, id: string, dir: string): Promise {
if (!SESSION_ID_RE.test(id)) return null;
const file = await resolveRolloutFile(projectPath, id, dir); // cached: the deck asks this per project
if (!file) return null;
-
- let fd: number | null = null;
- try {
- fd = openSync(file, 'r');
- const size = statSync(file).size;
- let position = size;
- let accumulated = Buffer.alloc(0);
- while (position > 0 && size - position < TAIL_MAX_BYTES) {
- const length = Math.min(TAIL_CHUNK_BYTES, position);
- position -= length;
- const chunk = Buffer.alloc(length);
- const bytesRead = readSync(fd, chunk, 0, length, position);
- accumulated = Buffer.concat([chunk.subarray(0, bytesRead), accumulated]);
- const text = accumulated.toString('utf8');
- const firstNewline = text.indexOf('\n');
- const completeLines = position === 0 ? text : firstNewline < 0 ? '' : text.slice(firstNewline + 1);
- const message = codexLastUserMessage(completeLines);
- if (message) return message;
- }
- return null;
- } catch {
- return null;
- } finally {
- if (fd !== null) {
- try { closeSync(fd); } catch { /* file disappeared */ }
- }
- }
+ return cachedTailAnswer(file, (handle, size) => findInTail(handle, size, TAIL_MAX_BYTES, TAIL_CHUNK_BYTES, codexLastUserMessage));
}
diff --git a/src/main/codexUsage.test.ts b/src/main/codexUsage.test.ts
index f11d8a9..a77cc5f 100644
--- a/src/main/codexUsage.test.ts
+++ b/src/main/codexUsage.test.ts
@@ -206,6 +206,18 @@ describe('getCodexUsage protocol', () => {
expect(h.child.kill).toHaveBeenCalledTimes(1);
});
+ it('stops the child through the tree kill, so app-server under a .cmd shim does not outlive it', async () => {
+ const killChild = vi.fn();
+ const h = harness({ killChild });
+ const p = h.run();
+ await tick();
+ h.timers[0].fn();
+ await p;
+ expect(killChild).toHaveBeenCalledTimes(1);
+ expect(killChild).toHaveBeenCalledWith(h.child);
+ expect(h.child.kill).not.toHaveBeenCalled();
+ });
+
it('read timeout after a successful handshake => offline', async () => {
const h = harness();
const p = h.run();
diff --git a/src/main/codexUsage.ts b/src/main/codexUsage.ts
index 1f0a1bf..c2ac287 100644
--- a/src/main/codexUsage.ts
+++ b/src/main/codexUsage.ts
@@ -5,6 +5,7 @@
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
import { clampPercent, parseResetTime, safeUsageLabel, type ProviderUsage, type UsageCredits, type UsageLimit } from '../shared/usageWindows';
import { resolveAgentCliPath } from './cliExecutable';
+import { cliInvocation, killChildTree } from './aiSummary';
const STARTUP_TIMEOUT_MS = 8_000;
const REQUEST_TIMEOUT_MS = 12_000;
@@ -20,6 +21,8 @@ export interface CodexUsageDeps {
/** Injected in tests so timers don't depend on real time. */
setTimer?: (fn: () => void, ms: number) => unknown;
clearTimer?: (handle: unknown) => void;
+ /** How the child is stopped; defaults to a process-tree kill (see killChildTree). */
+ killChild?: (child: ChildProcessWithoutNullStreams) => void;
}
function result(state: ProviderUsage['state'], now: number, over: Partial = {}): ProviderUsage {
@@ -144,7 +147,8 @@ export function getCodexUsage(deps: CodexUsageDeps): Promise {
child.removeAllListeners('error');
child.removeAllListeners('exit');
try { child.stdin?.end(); } catch { /* already closed */ }
- if (!killed) { killed = true; try { child.kill(); } catch { /* already gone */ } }
+ // A tree kill: through a .cmd shim, child.kill() would end cmd.exe and leave app-server running.
+ if (!killed) { killed = true; try { (deps.killChild ?? killChildTree)(child); } catch { /* already gone */ } }
resolve(value);
};
@@ -204,10 +208,11 @@ function loginOrOffline(error: unknown, now: number): ProviderUsage {
/** Production spawn: the official CLI, stdio pipes only, no shell. */
export function spawnCodexAppServer(): ChildProcessWithoutNullStreams {
- const command = resolveAgentCliPath('codex');
- // npm installs a `codex.cmd` shim on Windows. CreateProcess cannot execute that shim directly;
- // route the fixed command through cmd.exe there (there is no user-controlled argv in this call).
- return spawn(command, ['app-server'], {
- stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true, shell: process.platform === 'win32',
+ // npm installs a `codex.cmd` shim on Windows, which CreateProcess cannot execute directly;
+ // cliInvocation routes only that case through cmd.exe, with the argument quoted, and starts a
+ // real codex.exe directly — no `shell: true` either way.
+ const how = cliInvocation(resolveAgentCliPath('codex'), ['app-server']);
+ return spawn(how.file, how.args, {
+ stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true, windowsVerbatimArguments: how.windowsVerbatimArguments,
}) as ChildProcessWithoutNullStreams;
}
diff --git a/src/main/codexUsageScan.test.ts b/src/main/codexUsageScan.test.ts
index 14c4739..f42eeaa 100644
--- a/src/main/codexUsageScan.test.ts
+++ b/src/main/codexUsageScan.test.ts
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
-import { appendFileSync, mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs';
+import { appendFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { tmpdir } from 'node:os';
import { scanCodexUsage } from './codexUsageScan';
@@ -159,3 +159,65 @@ describe('scanCodexUsage', () => {
expect(yields).toBeGreaterThan(1);
});
});
+
+describe('the index between scans', () => {
+ // Every scan used to read, validate, deep-clone, re-serialize and rewrite the whole index
+ // synchronously, changed or not (~80 ms on a 5,000-rollout store). Between scans it now lives in
+ // memory; the file is written only when something changed, and read again when it is not ours.
+ const nextTotal = { input_tokens: 150, cached_input_tokens: 70, cache_write_input_tokens: 0, output_tokens: 15 };
+ const appendTurn = (file: string): void => appendFileSync(file, token(nextTotal, nextTotal, '2026-08-08T10:03:00.000Z') + '\n');
+ const pause = (): Promise => new Promise((resolve) => setTimeout(resolve, 20));
+
+ it('does not rewrite the index when nothing changed', async () => {
+ writeFileSync(rolloutPath(), firstRollout().join('\n') + '\n');
+ await scanCodexUsage({ sessionsDir, cachePath, sinceMs: Infinity });
+ const before = statSync(cachePath).mtimeMs;
+ await pause();
+ const again = await scanCodexUsage({ sessionsDir, cachePath, sinceMs: Infinity });
+ expect(again.global.input).toBe(40);
+ expect(statSync(cachePath).mtimeMs).toBe(before);
+ });
+
+ it('writes the index after an append, and later scans resume from it without recounting', async () => {
+ const file = rolloutPath();
+ writeFileSync(file, firstRollout().join('\n') + '\n');
+ await scanCodexUsage({ sessionsDir, cachePath, sinceMs: Infinity });
+ appendTurn(file);
+ const grown = await scanCodexUsage({ sessionsDir, cachePath, sinceMs: Infinity });
+ expect(grown.global).toEqual({ input: 80, cacheRead: 70, cacheWrite: 0, output: 15 });
+ const saved = JSON.parse(readFileSync(cachePath, 'utf8')) as { files: { offset: number }[] };
+ expect(saved.files[0].offset).toBe(statSync(file).size);
+ let bytes = 0;
+ for (let i = 0; i < 2; i++) {
+ const report = await scanCodexUsage({ sessionsDir, cachePath, sinceMs: Infinity, onBytesRead: (n) => { bytes += n; } });
+ expect(report.global).toEqual({ input: 80, cacheRead: 70, cacheWrite: 0, output: 15 });
+ }
+ expect(bytes).toBe(0);
+ });
+
+ it('honours an index file replaced by someone else since the last scan', async () => {
+ writeFileSync(rolloutPath(), firstRollout().join('\n') + '\n');
+ await scanCodexUsage({ sessionsDir, cachePath, sinceMs: Infinity });
+ const saved = JSON.parse(readFileSync(cachePath, 'utf8')) as { files: { buckets: { totals: { input: number } }[] }[] };
+ saved.files[0].buckets[0].totals.input = 999; // still a valid index — just different numbers
+ await pause();
+ writeFileSync(cachePath, JSON.stringify(saved));
+ const report = await scanCodexUsage({ sessionsDir, cachePath, sinceMs: Infinity });
+ expect(report.global.input).toBe(999);
+ });
+
+ it('two scans at once each count an append once, and leave a readable index behind', async () => {
+ const file = rolloutPath();
+ writeFileSync(file, firstRollout().join('\n') + '\n');
+ await scanCodexUsage({ sessionsDir, cachePath, sinceMs: Infinity });
+ appendTurn(file);
+ const [a, b] = await Promise.all([
+ scanCodexUsage({ sessionsDir, cachePath, sinceMs: Infinity }),
+ scanCodexUsage({ sessionsDir, cachePath, sinceMs: Infinity }),
+ ]);
+ expect(a.global.input).toBe(80);
+ expect(b.global.input).toBe(80);
+ expect((await scanCodexUsage({ sessionsDir, cachePath, sinceMs: Infinity })).global.input).toBe(80);
+ expect(() => JSON.parse(readFileSync(cachePath, 'utf8'))).not.toThrow();
+ });
+});
diff --git a/src/main/codexUsageScan.ts b/src/main/codexUsageScan.ts
index 822a93b..23fbeea 100644
--- a/src/main/codexUsageScan.ts
+++ b/src/main/codexUsageScan.ts
@@ -1,4 +1,5 @@
-import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, renameSync, writeFileSync } from 'node:fs';
+import { closeSync, existsSync, openSync, readFileSync, readSync } from 'node:fs';
+import { mkdir, rename, stat, writeFile } from 'node:fs/promises';
import { dirname } from 'node:path';
import { basename, cwdKey } from '../shared/paths';
import {
@@ -114,11 +115,65 @@ function loadIndex(cachePath: string): CodexUsageIndex {
}
}
-function saveIndex(cachePath: string, index: CodexUsageIndex): void {
- mkdirSync(dirname(cachePath), { recursive: true });
- const temp = `${cachePath}.tmp`;
- writeFileSync(temp, JSON.stringify(index), 'utf8');
- renameSync(temp, cachePath);
+/**
+ * The index as the last scan left it, per cache path, plus the size and mtime of the file that scan
+ * wrote.
+ *
+ * Every scan used to read the whole index back from disk, validate every entry, deep-clone every
+ * entry through JSON, then stringify and write all of it again — all synchronous, back to back, for
+ * every rollout on the machine, on every deck refresh and window focus. With 5,000 rollouts that is a
+ * 4.5 MB file and a ~80 ms stretch in which the main process answered nothing, even when not one
+ * rollout had changed. The index on disk is only there to survive a restart; between scans the one
+ * in memory is the same thing. It is still read from disk when there is none in memory yet, or when
+ * the file is no longer the one this process wrote (so an index replaced underneath us is honoured).
+ */
+interface LiveIndex { files: IndexedFile[]; written: { size: number; mtimeMs: number } | null }
+const _live = new Map();
+
+async function previousFiles(cachePath: string): Promise {
+ const live = _live.get(cachePath);
+ if (live?.written) {
+ try {
+ const st = await stat(cachePath);
+ if (st.size === live.written.size && st.mtimeMs === live.written.mtimeMs) return live.files;
+ } catch { /* gone: fall through to the same answer a fresh read gives */ }
+ }
+ // Stat BEFORE reading, so a write landing in between makes the next scan read again, not trust this.
+ const st = await stat(cachePath).catch(() => null);
+ const { files } = loadIndex(cachePath);
+ _live.set(cachePath, { files, written: st ? { size: st.size, mtimeMs: st.mtimeMs } : null });
+ return files;
+}
+
+/** Already read to the end of the file as it is now — exactly when updateFile would read nothing. */
+function caughtUp(file: IndexedFile, head: CodexRolloutHead): boolean {
+ return file.size === head.size && file.mtimeMs === head.mtimeMs && file.offset === head.size;
+}
+
+/**
+ * Saves in progress, by path, chained so two scans finishing together never write the same temp
+ * file at once; the later scan's save lands last, as it did when both were synchronous.
+ */
+const _saving = new Map>();
+
+function saveIndex(cachePath: string, index: CodexUsageIndex): Promise {
+ const run = async (): Promise => {
+ try {
+ await mkdir(dirname(cachePath), { recursive: true });
+ const temp = `${cachePath}.tmp`;
+ await writeFile(temp, JSON.stringify(index), 'utf8');
+ await rename(temp, cachePath);
+ const st = await stat(cachePath);
+ _live.set(cachePath, { files: index.files, written: { size: st.size, mtimeMs: st.mtimeMs } });
+ } catch {
+ // Unwritable cache: the scan's answer stands, and the next scan reads from disk again.
+ _live.delete(cachePath);
+ }
+ };
+ const next = (_saving.get(cachePath) ?? Promise.resolve()).then(run);
+ _saving.set(cachePath, next);
+ void next.finally(() => { if (_saving.get(cachePath) === next) _saving.delete(cachePath); });
+ return next;
}
function freshFile(head: CodexRolloutHead): IndexedFile {
@@ -297,15 +352,23 @@ function reportFromFiles(files: IndexedFile[], sinceMs: number, exists: (path: s
export async function scanCodexUsage(options: ScanCodexUsageOptions): Promise {
const heads = await listCodexRolloutHeads(options.sessionsDir);
if (!heads.length) return emptyProviderUsage('codex');
- const previous = loadIndex(options.cachePath);
- const previousByFile = new Map(previous.files.map((file) => [file.file, file]));
+ const previous = await previousFiles(options.cachePath);
+ const previousByFile = new Map(previous.map((file) => [file.file, file]));
const files: IndexedFile[] = [];
+ let changed = previous.length !== heads.length;
for (const head of heads) {
const old = previousByFile.get(head.file);
+ if (old && resumable(old, head) && caughtUp(old, head)) {
+ // Nothing to read, so nothing to change: the entry is shared as-is instead of deep-cloned.
+ // Entries are never mutated once a scan has published them — only fresh clones are folded into.
+ files.push(old);
+ continue;
+ }
const file = resumable(old, head) ? cloneFile(old!) : freshFile(head);
await updateFile(file, head, options);
files.push(file);
+ changed = true;
}
- saveIndex(options.cachePath, { version: INDEX_VERSION, files });
+ if (changed) await saveIndex(options.cachePath, { version: INDEX_VERSION, files });
return reportFromFiles(files, options.sinceMs, options.exists ?? existsSync);
}
diff --git a/src/main/createProject.ts b/src/main/createProject.ts
index 52e4946..0db1449 100644
--- a/src/main/createProject.ts
+++ b/src/main/createProject.ts
@@ -23,7 +23,9 @@ export interface CreateDeps {
function defaultGitInit(cwd: string): boolean {
try {
- execFileSync('git', ['init'], { cwd, stdio: 'ignore', windowsHide: true });
+ // Bounded: this runs on the main thread, and a git stalled by a network drive or an antivirus
+ // scan would otherwise freeze the whole app for as long as it takes.
+ execFileSync('git', ['init'], { cwd, stdio: 'ignore', windowsHide: true, timeout: 15_000, killSignal: 'SIGKILL' });
return true;
} catch {
return false;
diff --git a/src/main/errorGuard.test.ts b/src/main/errorGuard.test.ts
index ef17c25..cf9d260 100644
--- a/src/main/errorGuard.test.ts
+++ b/src/main/errorGuard.test.ts
@@ -72,28 +72,28 @@ describe('installAppCrashHandlers', () => {
});
describe('makeCrashRecovery', () => {
- // A crashed renderer loses its `live` session map, but the node-pty conptys it owned keep
- // running in main with no owner. On reload the renderer restores sessions from the persisted
- // list and spawns FRESH ptys — so the orphans must be reaped first or every crash leaks
- // (and double-runs) a full set of terminal processes.
+ // A crashed renderer loses its `live` session map, but the project ptys keep running in main and
+ // the reloaded renderer re-adopts them (`cockpit:liveSessions`). Only DevDeck's internal login
+ // shells — which nothing can re-adopt — are reaped; killing project sessions would destroy the
+ // agents' in-flight work (and sessions remote viewers are using) for nothing.
const detail = { reason: 'crashed', exitCode: 11 };
- it('render-process-gone: logs, reaps orphaned ptys, then reloads the window', () => {
+ it('render-process-gone: logs, reaps only the internal ptys, then reloads the window', () => {
const calls: string[] = [];
const respond = makeCrashRecovery({
log: (kind) => calls.push(`log:${kind}`),
- reapPtys: () => calls.push('reap'),
+ reapInternalPtys: () => calls.push('reap-internal'),
reloadWindow: () => calls.push('reload'),
});
respond('render-process-gone', detail);
- expect(calls).toEqual(['log:render-process-gone', 'reap', 'reload']);
+ expect(calls).toEqual(['log:render-process-gone', 'reap-internal', 'reload']);
});
it('child-process-gone (GPU/utility): logs only — the renderer and its sessions are still alive', () => {
const calls: string[] = [];
const respond = makeCrashRecovery({
log: (kind) => calls.push(`log:${kind}`),
- reapPtys: () => calls.push('reap'),
+ reapInternalPtys: () => calls.push('reap-internal'),
reloadWindow: () => calls.push('reload'),
});
respond('child-process-gone', { type: 'GPU', reason: 'crashed', exitCode: 139 });
@@ -104,7 +104,7 @@ describe('makeCrashRecovery', () => {
const calls: string[] = [];
const respond = makeCrashRecovery({
log: () => calls.push('log'),
- reapPtys: () => { throw new Error('kill failed'); },
+ reapInternalPtys: () => { throw new Error('kill failed'); },
reloadWindow: () => calls.push('reload'),
});
expect(() => respond('render-process-gone', detail)).not.toThrow();
diff --git a/src/main/errorGuard.ts b/src/main/errorGuard.ts
index eef7b2d..e3e4d76 100644
--- a/src/main/errorGuard.ts
+++ b/src/main/errorGuard.ts
@@ -37,22 +37,27 @@ export interface AppCrashDetail { reason: string; exitCode: number; type?: strin
*/
export interface CrashRecoveryDeps {
log: (kind: AppCrashKind, detail: AppCrashDetail) => void;
- reapPtys: () => void;
+ /** Kill only DevDeck's own terminals (provider-login shells) — never project sessions. */
+ reapInternalPtys: () => void;
reloadWindow: () => void;
}
/**
* What to actually DO when a crash event fires (installAppCrashHandlers only observes).
- * A dead renderer loses its in-memory session map while its node-pty conptys keep running in main
- * with no owner; the reload then restores sessions from the persisted list with FRESH ptys. Reap
- * the orphans first, or every renderer crash leaks — and double-runs — a full set of terminals.
+ *
+ * Project sessions are LEFT RUNNING. They used to be killed here, back when a reloaded renderer
+ * could only restore by spawning fresh ptys; it now asks main for the sessions already running
+ * (`cockpit:liveSessions`) and re-adopts them, and `cockpit:open` reuses a running conversation. So
+ * killing them bought nothing and destroyed whatever the agents were in the middle of — and the
+ * sessions a paired machine was watching along with them. Only the internal login shells are reaped:
+ * they are never listed, so nothing can re-adopt them and they would otherwise be orphaned for good.
* A GPU/utility crash (`child-process-gone`) leaves the renderer alive, so it is log-only.
*/
export function makeCrashRecovery(deps: CrashRecoveryDeps): (kind: AppCrashKind, detail: AppCrashDetail) => void {
return (kind, detail) => {
try { deps.log(kind, detail); } catch { /* logging is best-effort */ }
if (kind !== 'render-process-gone') return;
- try { deps.reapPtys(); } catch { /* a failed kill must not block the reload */ }
+ try { deps.reapInternalPtys(); } catch { /* a failed kill must not block the reload */ }
try { deps.reloadWindow(); } catch { /* window may already be tearing down */ }
};
}
diff --git a/src/main/gitInfo.test.ts b/src/main/gitInfo.test.ts
index 895264f..2461f28 100644
--- a/src/main/gitInfo.test.ts
+++ b/src/main/gitInfo.test.ts
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
-import { getGitInfo, getRepoUrl, getGitBranchDirty, getRecentCommits, _clearRemoteCache } from './gitInfo';
+import { getGitInfo, getRepoUrl, getGitBranchDirty, getRecentCommits, _clearRemoteCache, createGitRunner, gitEnv } from './gitInfo';
beforeEach(() => _clearRemoteCache());
@@ -169,3 +169,24 @@ describe('the git command is bounded', () => {
}
});
});
+
+describe('the production git runner', () => {
+ // `git status` refreshes the index opportunistically under index.lock unless told not to, and that
+ // collides with the agent's own `git add`/`git commit` in the same repository.
+ it('runs every git call with optional locks and terminal prompts disabled', async () => {
+ const exec = vi.fn(async () => ({ stdout: 'out' }));
+ const run = createGitRunner(exec);
+ expect(await run(['-C', 'C:/g/x', 'status', '--porcelain'])).toBe('out');
+ const [file, args, opts] = exec.mock.calls[0] as unknown as [string, string[], { env: NodeJS.ProcessEnv; windowsHide: boolean; timeout: number }];
+ expect(file).toBe('git');
+ expect(args).toEqual(['-C', 'C:/g/x', 'status', '--porcelain']);
+ expect(opts.env.GIT_OPTIONAL_LOCKS).toBe('0');
+ expect(opts.env.GIT_TERMINAL_PROMPT).toBe('0');
+ expect(opts.windowsHide).toBe(true);
+ expect(opts.timeout).toBeGreaterThan(0);
+ });
+
+ it('keeps the rest of the environment (PATH, HOME) intact', () => {
+ expect(gitEnv({ PATH: '/bin', HOME: '/h', GIT_OPTIONAL_LOCKS: '1' })).toEqual({ PATH: '/bin', HOME: '/h', GIT_OPTIONAL_LOCKS: '0', GIT_TERMINAL_PROMPT: '0' });
+ });
+});
diff --git a/src/main/gitInfo.ts b/src/main/gitInfo.ts
index 52b9b19..4eefd9e 100644
--- a/src/main/gitInfo.ts
+++ b/src/main/gitInfo.ts
@@ -32,17 +32,39 @@ const GIT_TIMEOUT_MS = 10_000;
*/
const GIT_MAX_BUFFER = 32 * 1024 * 1024;
-const defaultRunner: GitRunner = async (args) => {
- const { stdout } = await execFileAsync('git', args, {
- windowsHide: true,
+/**
+ * The environment every DevDeck git call runs with.
+ *
+ * GIT_OPTIONAL_LOCKS=0: `git status` otherwise takes index.lock opportunistically to refresh the
+ * index's stat cache — and DevDeck runs it on every project, every refresh, in the very repositories
+ * an agent is committing in. The agent's own `git add`/`git commit` then fails with "Unable to create
+ * index.lock: File exists" whenever the two land together. The refresh is only an optimization;
+ * a read-only dashboard has no business taking a write lock for it.
+ * GIT_TERMINAL_PROMPT=0: a credential prompt nobody can see would sit there until the timeout.
+ */
+export function gitEnv(base: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
+ return { ...base, GIT_OPTIONAL_LOCKS: '0', GIT_TERMINAL_PROMPT: '0' };
+}
+
+type ExecFileAsync = (file: string, args: string[], opts: Parameters[2]) => Promise<{ stdout: string | Buffer }>;
+
+/** The production runner, over an injectable execFile so its options are testable. */
+export function createGitRunner(exec: ExecFileAsync = execFileAsync as unknown as ExecFileAsync): GitRunner {
+ return async (args) => {
+ const { stdout } = await exec('git', args, {
+ env: gitEnv(),
+ windowsHide: true,
timeout: GIT_TIMEOUT_MS,
- // SIGTERM is the default and is ignored on Windows for a process that is not responding; git
- // spawns children (credential helpers, pagers) and this has to actually end them.
- killSignal: 'SIGKILL',
- maxBuffer: GIT_MAX_BUFFER,
- });
- return stdout;
-};
+ // SIGTERM is the default and is ignored on Windows for a process that is not responding; git
+ // spawns children (credential helpers, pagers) and this has to actually end them.
+ killSignal: 'SIGKILL',
+ maxBuffer: GIT_MAX_BUFFER,
+ });
+ return String(stdout);
+ };
+}
+
+const defaultRunner: GitRunner = createGitRunner();
async function safe(run: GitRunner, args: string[]): Promise {
try {
diff --git a/src/main/ipc.cockpit.test.ts b/src/main/ipc.cockpit.test.ts
index 3e8019d..3ada73b 100644
--- a/src/main/ipc.cockpit.test.ts
+++ b/src/main/ipc.cockpit.test.ts
@@ -62,6 +62,7 @@ import { cwdKey } from '../shared/paths';
const ALLOWED_ROOT = join(process.cwd(), 'cockpit-allowed-root');
let storedAgent = 'claude';
const ptyCreate = vi.fn();
+const ptyNote = vi.fn((_id: string, _patch: Record) => false);
beforeAll(() => {
registerIpc({
@@ -74,7 +75,7 @@ beforeAll(() => {
},
sendError: vi.fn(),
defaultLanguage: 'en',
- ptyHost: { create: ptyCreate, list: () => [], pid: (id: string) => (id === 'live#1' ? 4321 : null) },
+ ptyHost: { create: ptyCreate, list: () => [], pid: (id: string) => (id === 'live#1' ? 4321 : null), note: ptyNote },
ptyAvailable: true,
tray: {},
shutdown: null,
@@ -194,6 +195,8 @@ describe('cockpit:liveAgent', () => {
probe.mockClear();
expect(await liveAgent(null, 'live#1')).toBe('claude');
expect(probe).toHaveBeenCalledWith(4321);
+ // ...and recorded on the pty, so what this machine announces names the agent really running.
+ expect(ptyNote).toHaveBeenCalledWith('live#1', { agentId: 'claude' });
probe.mockClear();
expect(await liveAgent(null, 'gone#9')).toBeNull();
@@ -381,13 +384,12 @@ describe('cockpit:conversationsLastUsed', () => {
// had just booted, none of them ever finished.
describe('projects:list', () => {
it('shares the build already under way instead of starting another', async () => {
+ // Each caller gets its own answer (the store entries are laid over the shared build at the end, so
+ // a pin changed meanwhile is not lost); that the BUILD runs once is counted in
+ // api/deckApi.projects.test.ts. Here: concurrent asks agree, and a later ask still answers.
const list = handlers.get('projects:list')!;
- const first = list(null);
- const second = list(null);
- expect(second).toBe(first);
- await first;
- const third = list(null);
- expect(third).not.toBe(first); // once it has landed, the next ask builds fresh
- await third;
+ const [first, second] = await Promise.all([list(null), list(null)]);
+ expect(second).toEqual(first);
+ expect(await list(null)).toEqual(first);
});
});
diff --git a/src/main/ipc.guard.test.ts b/src/main/ipc.guard.test.ts
index 4bb5519..4040077 100644
--- a/src/main/ipc.guard.test.ts
+++ b/src/main/ipc.guard.test.ts
@@ -115,23 +115,23 @@ describe('project store-setter path guards', () => {
const outside = join(process.cwd(), 'elsewhere', 'proj');
const inside = join(ALLOWED_ROOT, 'proj');
- it('ignores writes to a path outside the allowed folders', () => {
+ it('ignores writes to a path outside the allowed folders', async () => {
for (const s of Object.values(storeSpies)) s.mockClear();
- expect(() => handlers.get('project:setNote')!(null, outside, 'x')).toThrow('allowed folders');
+ await expect(Promise.resolve().then(() => handlers.get('project:setNote')!(null, outside, 'x'))).rejects.toThrow('allowed folders');
expect(() => handlers.get('project:setTodos')!(null, outside, [])).toThrow('allowed folders');
- expect(() => handlers.get('project:setPinned')!(null, outside, true)).toThrow('allowed folders');
- expect(() => handlers.get('project:setHidden')!(null, outside, true)).toThrow('allowed folders');
+ await expect(Promise.resolve().then(() => handlers.get('project:setPinned')!(null, outside, true))).rejects.toThrow('allowed folders');
+ await expect(Promise.resolve().then(() => handlers.get('project:setHidden')!(null, outside, true))).rejects.toThrow('allowed folders');
expect(storeSpies.setNote).not.toHaveBeenCalled();
expect(storeSpies.setTodos).not.toHaveBeenCalled();
expect(storeSpies.setPinned).not.toHaveBeenCalled();
expect(storeSpies.setHidden).not.toHaveBeenCalled();
});
- it('allows writes to a project under an allowed folder', () => {
+ it('allows writes to a project under an allowed folder', async () => {
for (const s of Object.values(storeSpies)) s.mockClear();
- handlers.get('project:setNote')!(null, inside, 'hi');
- handlers.get('project:setPinned')!(null, inside, true);
- handlers.get('project:setHidden')!(null, inside, true);
+ await handlers.get('project:setNote')!(null, inside, 'hi');
+ await handlers.get('project:setPinned')!(null, inside, true);
+ await handlers.get('project:setHidden')!(null, inside, true);
expect(storeSpies.setNote).toHaveBeenCalledWith(inside, 'hi');
expect(storeSpies.setPinned).toHaveBeenCalledWith(inside, true);
expect(storeSpies.setHidden).toHaveBeenCalledWith(inside, true);
diff --git a/src/main/launcher.test.ts b/src/main/launcher.test.ts
index ffb26e4..e25c049 100644
--- a/src/main/launcher.test.ts
+++ b/src/main/launcher.test.ts
@@ -71,8 +71,17 @@ describe('editorSpec', () => {
expect(editorSpec('/g/a', 'darwin')).toEqual({ command: 'code', args: ['/g/a'], shell: false });
expect(editorSpec('/g/a', 'linux')).toEqual({ command: 'code', args: ['/g/a'], shell: false });
});
- it('windows: quoted path through a shell (code is code.cmd)', () => {
- expect(editorSpec('C:\\g\\a', 'win32')).toEqual({ command: 'code "C:\\g\\a"', args: [], shell: true });
+ it('windows: code.cmd through cmd.exe, the path escaped and passed verbatim (no shell expansion)', () => {
+ const spec = editorSpec('C:\\g\\a', 'win32', 'cmd.exe');
+ expect(spec).toMatchObject({ command: 'cmd.exe', shell: false, verbatim: true });
+ expect(spec.args.slice(0, 3)).toEqual(['/d', '/s', '/c']);
+ expect(spec.args[3]).toContain('code.cmd');
+ expect(spec.args[3]).toContain('C:\\g\\a');
+ });
+ it('windows: a %VAR% or & in the folder name reaches code literally', () => {
+ const line = editorSpec('C:\\g\\%TEMP% & x', 'win32', 'cmd.exe').args[3];
+ expect(line).not.toMatch(/(^|[^^])%TEMP/);
+ expect(line).not.toMatch(/(^|[^^])&/);
});
});
diff --git a/src/main/launcher.ts b/src/main/launcher.ts
index 251b134..7484a62 100644
--- a/src/main/launcher.ts
+++ b/src/main/launcher.ts
@@ -3,6 +3,7 @@ import { promisify } from 'node:util';
import { existsSync } from 'node:fs';
import { win32 as pathWin32 } from 'node:path';
import { buildWtArgs, type WtTab } from '../shared/wtArgs';
+import { cliInvocation } from './aiSummary';
import { buildMacLaunch, buildLinuxLaunch, type LaunchCmd } from '../shared/posixLaunch';
export type SpawnFn = (file: string, args: string[]) => void;
@@ -139,16 +140,21 @@ function defaultSpawn(onError?: (msg: string) => void): SpawnFn {
};
}
-export interface EditorSpec { command: string; args: string[]; shell: boolean; }
+export interface EditorSpec { command: string; args: string[]; shell: boolean; verbatim?: boolean }
/**
* How to launch VS Code at a directory. On POSIX `code` is a plain script — pass the
* dir as an args-array entry (no shell = injection-safe). On Windows `code` is
- * `code.cmd`, which Node can only run through a shell, so quote the path (Windows
- * paths cannot contain `"`, so the quoting is safe).
+ * `code.cmd`, which only cmd.exe can run. It used to go through `shell: true` as
+ * `code ""`, and cmd expanded what was inside the quotes: a folder named
+ * `%TEMP%` opened the temp directory. The path is now escaped for cmd (twice — the
+ * shim forwards it through %*) and passed verbatim.
*/
-export function editorSpec(dir: string, platform: NodeJS.Platform = process.platform): EditorSpec {
- if (platform === 'win32') return { command: `code "${dir}"`, args: [], shell: true };
+export function editorSpec(dir: string, platform: NodeJS.Platform = process.platform, comspec: string = process.env.ComSpec || 'cmd.exe'): EditorSpec {
+ if (platform === 'win32') {
+ const run = cliInvocation('code.cmd', [dir], 'win32', comspec);
+ return { command: run.file, args: run.args, shell: false, verbatim: true };
+ }
return { command: 'code', args: [dir], shell: false };
}
@@ -162,7 +168,7 @@ export interface EditorOptions {
export function openInEditor(dir: string, opts: EditorOptions = {}): void {
const spec = editorSpec(dir, opts.platform ?? process.platform);
if (opts.spawnFn) { opts.spawnFn(spec.command, spec.args, spec.shell); return; }
- const child = spawn(spec.command, spec.args, { detached: true, stdio: 'ignore', shell: spec.shell, windowsHide: false });
+ const child = spawn(spec.command, spec.args, { detached: true, stdio: 'ignore', shell: spec.shell, windowsHide: false, windowsVerbatimArguments: spec.verbatim === true });
child.on('error', (err) => {
console.error('DevDeck: failed to open editor', err);
opts.onError?.(`Failed to open editor (is the \`code\` CLI on your PATH?): ${(err as Error).message}`);
diff --git a/src/main/link/clientLink.ts b/src/main/link/clientLink.ts
index 7a36dc6..738098c 100644
--- a/src/main/link/clientLink.ts
+++ b/src/main/link/clientLink.ts
@@ -21,7 +21,7 @@ import { fingerprintsMatch } from './selfSignedCert';
import { dialOrder, type KnownHost } from './devices';
import {
LINK_FEATURES, LINK_PROTOCOL, peerSupports, protocolMatches,
- type LinkErrorCode, type LinkMessage, type ReadyMessage,
+ type ErrorMessage, type LinkErrorCode, type LinkMessage, type ReadyMessage,
} from './protocol';
import type { LinkIdentity } from './identity';
@@ -70,14 +70,30 @@ export interface DialOptions {
pairingToken?: string;
/** Per-address connect timeout. Kept short: the point is to fall through to the next candidate. */
connectTimeoutMs?: number;
+ /** From the TLS handshake completing to the host's `ready`. See DEFAULT_HANDSHAKE_TIMEOUT_MS. */
+ handshakeTimeoutMs?: number;
requestTimeoutMs?: number;
heartbeat?: HeartbeatOptions;
onEvent?: (channel: string, payload: unknown) => void;
+ /**
+ * A refusal the host sent on an established link (an output-backlog reset, say). Kept apart from
+ * `onEvent` so the caller can tell the host's own error frames from event channels the host
+ * merely NAMED. Without it, the frame is handed to `onEvent` as `link:error`, as it always was.
+ */
+ onHostError?: (message: ErrorMessage) => void;
onPty?: (sessionId: string, bytes: Buffer) => void;
onClose?: (reason: string | null) => void;
}
const DEFAULT_CONNECT_TIMEOUT_MS = 4_000;
+
+/**
+ * TLS done, `ready` not yet. The connect timeout stops at the TLS handshake and the heartbeat starts
+ * only after `ready`, so a host that completed TLS and then went silent — wedged main process, a
+ * proxy swallowing the stream — left the dial pending forever, and with it every `reconnect()`, which
+ * waits on the dial in flight. Generous: a host answers hello with ready in one round trip.
+ */
+const DEFAULT_HANDSHAKE_TIMEOUT_MS = 15_000;
const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
/**
@@ -203,16 +219,28 @@ function handshake(
pending.clear();
};
+ let handshakeTimer: NodeJS.Timeout | undefined;
+
const connection: LinkConnection = attachConnection(socket, {
onMessage: (message) => onMessage(message),
onPty: (sessionId, bytes) => options.onPty?.(sessionId, bytes),
onClose: (reason) => {
+ clearTimeout(handshakeTimer);
settleAllPending(reason ?? 'disconnected');
if (!established) done({ ok: false, failure: { kind: 'unreachable', tried: [address], attempts: [{ address, reason: 'other' }], lastError: reason ?? 'closed' } });
else options.onClose?.(reason);
},
}, options.heartbeat);
+ // Reported as this address timing out, which is what it is from here: something answered and then
+ // never finished saying who it is. Settled before closing, for the same reason as the refusals below.
+ handshakeTimer = setTimeout(() => {
+ if (established) return;
+ done({ ok: false, failure: { kind: 'unreachable', tried: [address], attempts: [{ address, reason: 'timed-out' }], lastError: 'handshake timed out' } });
+ connection.close('handshake timeout');
+ }, options.handshakeTimeoutMs ?? DEFAULT_HANDSHAKE_TIMEOUT_MS);
+ handshakeTimer.unref?.();
+
function onMessage(message: LinkMessage): void {
switch (message.t) {
case 'hello': {
@@ -237,6 +265,7 @@ function handshake(
}
case 'ready': {
established = true;
+ clearTimeout(handshakeTimer);
// Enforced only against a host that said it answers pings. An older host is watched by TCP
// keepalive alone — which is what watched every host before this existed, so nothing regresses.
connection.startHeartbeat(peerSupports(message, 'ping'));
@@ -253,7 +282,8 @@ function handshake(
return;
}
// Established links surface mid-session refusals (e.g. an output backlog reset) as events.
- options.onEvent?.('link:error', message);
+ if (options.onHostError) options.onHostError(message);
+ else options.onEvent?.('link:error', message);
return;
}
case 'res': {
diff --git a/src/main/link/connection.ts b/src/main/link/connection.ts
index 064e153..11bfbf1 100644
--- a/src/main/link/connection.ts
+++ b/src/main/link/connection.ts
@@ -40,6 +40,8 @@ export interface LinkConnection {
probe(deadlineMs: number): void;
/** When something last arrived from the peer, by this connection's clock. */
readonly lastFrameAt: number;
+ /** The largest frame accepted from the peer. A host keeps it small until the peer is let in. */
+ maxFrameBytes: number;
}
export interface HeartbeatOptions {
@@ -54,8 +56,13 @@ export interface HeartbeatOptions {
export const DEFAULT_HEARTBEAT: HeartbeatOptions = { idleMs: 15_000, timeoutMs: 45_000 };
-export function attachConnection(socket: Duplex, handlers: ConnectionHandlers, heartbeat: HeartbeatOptions = DEFAULT_HEARTBEAT): LinkConnection {
- const decoder = makeFrameDecoder();
+export function attachConnection(
+ socket: Duplex,
+ handlers: ConnectionHandlers,
+ heartbeat: HeartbeatOptions = DEFAULT_HEARTBEAT,
+ limits: { maxFrameBytes?: number } = {},
+): LinkConnection {
+ const decoder = makeFrameDecoder({ maxFrameBytes: limits.maxFrameBytes });
let closed = false;
const now = heartbeat.now ?? Date.now;
let lastFrameAt = now();
@@ -183,6 +190,8 @@ export function attachConnection(socket: Duplex, handlers: ConnectionHandlers, h
if (!probeTicker) { probeTicker = setInterval(() => tick(true), 50); probeTicker.unref?.(); }
},
get lastFrameAt() { return lastFrameAt; },
+ get maxFrameBytes() { return decoder.maxFrameBytes; },
+ set maxFrameBytes(value: number) { decoder.maxFrameBytes = value; },
sendPty(sessionId, chunk) {
try {
write(encodePtyFrame(sessionId, chunk));
diff --git a/src/main/link/devices.test.ts b/src/main/link/devices.test.ts
index 70ac1c4..fb787a3 100644
--- a/src/main/link/devices.test.ts
+++ b/src/main/link/devices.test.ts
@@ -1,7 +1,7 @@
import { describe, it, expect } from 'vitest';
import {
sanitizePairedDevices, sanitizeKnownHosts, findPairedDevice,
- upsertPairedDevice, removePairedDevice, upsertKnownHost, removeKnownHost,
+ upsertPairedDevice, upsertPairedDeviceReporting, removePairedDevice, upsertKnownHost, removeKnownHost,
dialOrder, noteHostReached, type PairedDevice, type KnownHost,
} from './devices';
@@ -158,3 +158,21 @@ describe('noteHostReached', () => {
expect(noteHostReached(host(), { address: 'x.example', machineName: '', nowMs: 1 }).machineName).toBe('SIHYEONG-MAIN');
});
});
+
+describe('upsertPairedDeviceReporting', () => {
+ const fp = (i: number): string => (i.toString(16).toUpperCase().padStart(2, '0').repeat(32).match(/../g) ?? []).join(':');
+
+ it('names the device the cap pushed out, so the host can say so', () => {
+ const full = Array.from({ length: 32 }, (_, i) => device({ fingerprint: fp(i + 1), machineName: `m${i + 1}` }));
+ const { devices, evicted } = upsertPairedDeviceReporting(full, device({ fingerprint: fp(200), machineName: 'new' }));
+ expect(devices).toHaveLength(32);
+ expect(evicted.map((d) => d.machineName)).toEqual(['m1']);
+ expect(devices.at(-1)?.machineName).toBe('new');
+ });
+
+ it('evicts nobody when re-pairing a device already in a full ledger', () => {
+ const full = Array.from({ length: 32 }, (_, i) => device({ fingerprint: fp(i + 1) }));
+ expect(upsertPairedDeviceReporting(full, device({ fingerprint: fp(5) })).evicted).toEqual([]);
+ expect(upsertPairedDevice(full, device({ fingerprint: fp(5) }))).toHaveLength(32);
+ });
+});
diff --git a/src/main/link/devices.ts b/src/main/link/devices.ts
index 84a5c20..e5703e6 100644
--- a/src/main/link/devices.ts
+++ b/src/main/link/devices.ts
@@ -142,8 +142,21 @@ export function findPairedDevice(devices: readonly PairedDevice[], fingerprint:
* longer on screen would grant access nobody agreed to.
*/
export function upsertPairedDevice(devices: readonly PairedDevice[], device: PairedDevice): PairedDevice[] {
+ return upsertPairedDeviceReporting(devices, device).devices;
+}
+
+/**
+ * `upsertPairedDevice`, also naming whoever the cap pushed out. Pairing a 33rd device drops the
+ * oldest — which un-pairs a machine nobody chose to un-pair, so the host has to be able to say so.
+ */
+export function upsertPairedDeviceReporting(
+ devices: readonly PairedDevice[],
+ device: PairedDevice,
+): { devices: PairedDevice[]; evicted: PairedDevice[] } {
const rest = devices.filter((d) => !fingerprintsMatch(d.fingerprint, device.fingerprint));
- return [...rest, device].slice(-MAX_DEVICES);
+ const all = [...rest, device];
+ const cut = Math.max(0, all.length - MAX_DEVICES);
+ return { devices: all.slice(cut), evicted: all.slice(0, cut) };
}
export function removePairedDevice(devices: readonly PairedDevice[], fingerprint: unknown): PairedDevice[] {
diff --git a/src/main/link/hostServer.ts b/src/main/link/hostServer.ts
index 02ee00f..df70ff2 100644
--- a/src/main/link/hostServer.ts
+++ b/src/main/link/hostServer.ts
@@ -22,14 +22,17 @@
import * as tls from 'node:tls';
import { AsyncLocalStorage } from 'node:async_hooks';
import type { DeckApiBundle } from '../api/deckApi';
-import { mayCallRemotely, sessionIdArgOf } from '../api/methods';
+import { mayCallRemotely, sessionIdArgOf, type ApiMethod } from '../api/methods';
import { attachConnection, type HeartbeatOptions, type LinkConnection } from './connection';
import { makeTokenBucket, type TokenBucket } from './tokenBucket';
import { isRemoteId } from '../../shared/link/machine';
import { fingerprintsMatch } from './selfSignedCert';
-import { findPairedDevice, upsertPairedDevice, type PairedDevice } from './devices';
+import { findPairedDevice, upsertPairedDeviceReporting, type PairedDevice } from './devices';
import { tokensMatch } from './inviteCode';
-import { LINK_FEATURES, LINK_PROTOCOL, peerSupports, protocolMatches, type LinkErrorCode, type LinkMessage } from './protocol';
+import {
+ LINK_FEATURES, LINK_PROTOCOL, MAX_FRAME_BYTES, PRE_AUTH_MAX_FRAME_BYTES, peerSupports, protocolMatches,
+ type LinkErrorCode, type LinkMessage,
+} from './protocol';
import { sanitizeMachineName, isValidMachineId } from '../../shared/link/machine';
import { sanitizePermissions, type LinkPermission } from '../../shared/link/permissions';
import type { LinkIdentity } from './identity';
@@ -93,6 +96,10 @@ export interface HostServerOptions {
sessionIds: () => string[] | null;
/** Requests a connection may make: `capacity` at once, refilling at `refillPerMs`. */
rateLimit?: { capacity: number; refillPerMs: number };
+ /** The same, for fire-and-forget calls other than keystrokes (see the `notify` case). */
+ notifyRateLimit?: { capacity: number; refillPerMs: number };
+ /** Connections not yet let in that may be open at once (see MAX_UNAUTHENTICATED_CONNECTIONS). */
+ maxUnauthenticated?: number;
heartbeat?: HeartbeatOptions;
}
@@ -144,9 +151,40 @@ const MAX_ATTACHED_PER_CONNECTION = 64;
/** Sixty requests in hand, six a second after that — a deck refresh is a handful, a runaway loop is not. */
const DEFAULT_RATE_LIMIT = { capacity: 60, refillPerMs: 6 / 1000 };
+/**
+ * Fire-and-forget calls other than keystrokes: resizes, closes. A window drag or a "close all" is a
+ * burst of dozens, which this must absorb; a loop sending thousands a second is not something any
+ * viewer does.
+ */
+const DEFAULT_NOTIFY_RATE_LIMIT = { capacity: 60, refillPerMs: 30 / 1000 };
+
+/**
+ * Connections that have not been let in yet, open at once. Each is cheap but not free — a TLS
+ * session, a socket, a grace timer — and anyone who can reach the port can open them. A person
+ * pairing a machine needs one.
+ */
+const MAX_UNAUTHENTICATED_CONNECTIONS = 8;
+
+/** TLS handshakes that never finish are bounded too; the default would hold each for two minutes. */
+const TLS_HANDSHAKE_TIMEOUT_MS = 15_000;
+
/** A refusal is logged at most this often per connection, so the limiter cannot itself fill the log. */
const REFUSAL_LOG_INTERVAL_MS = 10_000;
+/**
+ * Rows a device that was never let in may write to the audit log: a burst, then one a minute. The log
+ * keeps its newest 500 rows, so without this anyone who can reach the port could push a month of
+ * real history out of it by connecting in a loop. What is held back is counted on the next row.
+ */
+const PRE_AUTH_LOG_BURST = 20;
+const PRE_AUTH_LOG_REFILL_PER_MS = 1 / 60_000;
+
+/**
+ * The only event channels a paired machine is sent. The hub carries whatever this process publishes,
+ * and a channel added for the local window later must not start leaking to every paired device.
+ */
+const REMOTE_EVENT_CHANNELS = new Set(['cockpit:data', 'cockpit:exit', 'cockpit:resized', 'cockpit:sessions']);
+
interface Session {
connection: LinkConnection;
unauthenticatedTimer?: NodeJS.Timeout;
@@ -158,7 +196,10 @@ interface Session {
/** Display metadata only: authentication always uses the pinned certificate and pairing token. */
peerIdentity?: { machineId: string; machineName: string };
requests: TokenBucket;
+ notifies: TokenBucket;
lastRefusalLogMs: number;
+ /** Refusals the throttle held back since the last one it logged. */
+ suppressedRefusals: number;
/** What the client's hello announced it can do. */
peerPings: boolean;
}
@@ -184,6 +225,24 @@ export function startHostServer(options: HostServerOptions): Promise
session.connection.close(code);
};
+ const preAuthLog = makeTokenBucket({ capacity: PRE_AUTH_LOG_BURST, refillPerMs: PRE_AUTH_LOG_REFILL_PER_MS, now: options.now });
+ let preAuthSuppressed = 0;
+
+ /**
+ * Every audit row goes through here. A paired device's rows are written as they come (its own
+ * refusals are throttled per connection, below); a peer that was never let in shares one budget
+ * across ALL such peers, because opening another connection is free.
+ */
+ const record = (session: Session | null, entry: HostLogEntry): void => {
+ if (session?.device) { options.log(entry); return; }
+ if (!preAuthLog.take()) { preAuthSuppressed += 1; return; }
+ if (preAuthSuppressed > 0) {
+ entry = { ...entry, detail: `${entry.detail} (+${preAuthSuppressed} more from unpaired peers)` };
+ preAuthSuppressed = 0;
+ }
+ options.log(entry);
+ };
+
const server = tls.createServer({
key: options.identity.keyPem,
cert: options.identity.certPem,
@@ -193,6 +252,7 @@ export function startHostServer(options: HostServerOptions): Promise
requestCert: true,
rejectUnauthorized: false,
minVersion: 'TLSv1.3',
+ handshakeTimeout: TLS_HANDSHAKE_TIMEOUT_MS,
});
server.on('secureConnection', (socket) => {
@@ -201,11 +261,23 @@ export function startHostServer(options: HostServerOptions): Promise
// No certificate at all: nothing to identify, nothing to pair. Refuse before reading a byte.
if (!fingerprint) {
- options.log({ at: options.now(), kind: 'rejected', machineName: '', fingerprint: '', detail: 'no client certificate' });
+ record(null, { at: options.now(), kind: 'rejected', machineName: '', fingerprint: '', detail: 'no client certificate' });
socket.destroy();
return;
}
+ // A paired device is never turned away by this — only strangers count against it, and a stranger
+ // over the limit is dropped before it costs a timer or a frame.
+ if (!known) {
+ let waiting = 0;
+ for (const s of sessions) if (!s.device) waiting += 1;
+ if (waiting >= (options.maxUnauthenticated ?? MAX_UNAUTHENTICATED_CONNECTIONS)) {
+ record(null, { at: options.now(), kind: 'rejected', machineName: '', fingerprint, detail: 'too many unpaired connections' });
+ socket.destroy();
+ return;
+ }
+ }
+
const session: Session = {
connection: undefined as unknown as LinkConnection,
device: known,
@@ -214,7 +286,9 @@ export function startHostServer(options: HostServerOptions): Promise
pairAttempts: 0,
helloSeen: false,
requests: makeTokenBucket({ ...(options.rateLimit ?? DEFAULT_RATE_LIMIT), now: options.now }),
+ notifies: makeTokenBucket({ ...(options.notifyRateLimit ?? DEFAULT_NOTIFY_RATE_LIMIT), now: options.now }),
lastRefusalLogMs: 0,
+ suppressedRefusals: 0,
peerPings: false,
};
@@ -230,13 +304,15 @@ export function startHostServer(options: HostServerOptions): Promise
onClose: () => {
if (session.unauthenticatedTimer) clearTimeout(session.unauthenticatedTimer);
sessions.delete(session);
- options.log({
+ record(session, {
at: options.now(), kind: 'disconnected',
machineName: session.device?.machineName ?? '', fingerprint, detail: '',
});
announce();
},
- }, options.heartbeat);
+ // Until `ready`, nothing this peer may say is larger than a hello; the full ceiling is granted
+ // in sendReady.
+ }, options.heartbeat, { maxFrameBytes: PRE_AUTH_MAX_FRAME_BYTES });
sessions.add(session);
// An unknown device that connects and then says nothing must not sit here holding a socket. This
@@ -287,26 +363,30 @@ export function startHostServer(options: HostServerOptions): Promise
// expired code ends up reported as "not paired" and sends someone to check their firewall.
if (message.wantsPairing !== true) {
fail(session, 'unpaired', 'This machine is not paired. Generate a connection code on the host.');
- options.log({ at: options.now(), kind: 'rejected', machineName: '', fingerprint, detail: 'unpaired device' });
+ record(session, { at: options.now(), kind: 'rejected', machineName: '', fingerprint, detail: 'unpaired device' });
}
return;
}
case 'pair': {
if (!session.helloSeen) { fail(session, 'protocol-mismatch', 'pair before hello'); return; }
+ // Already paired: it was let in on its certificate the moment it said hello. Redeeming a code
+ // now would spend an invite the person made for some OTHER machine, and swap this device's
+ // permissions for that invite's — a change nobody approved for it.
+ if (session.device) return;
pair(session, message.token, fingerprint);
return;
}
case 'req': {
if (!session.helloSeen) { fail(session, 'protocol-mismatch', 'request before hello'); return; }
- const method = options.api.methods[message.method];
+ const method = methodNamed(message.method);
const args = Array.isArray(message.args) ? message.args : [];
- if (!authorized(session, message.method)) {
+ if (!method || !authorized(session, message.method)) {
session.connection.send({
t: 'res', id: message.id, ok: false,
code: method ? 'permission-denied' : 'method-not-available',
error: method ? `not permitted: ${message.method}` : `no such method: ${message.method}`,
});
- denied(session, fingerprint, message.method);
+ deniedThrottled(session, fingerprint, String(message.method).slice(0, 80));
return;
}
if (!sessionArgAllowed(session, fingerprint, message.method, args)) {
@@ -335,12 +415,29 @@ export function startHostServer(options: HostServerOptions): Promise
case 'notify': {
if (!session.helloSeen) { fail(session, 'protocol-mismatch', 'request before hello'); return; }
const args = Array.isArray(message.args) ? message.args : [];
- if (!authorized(session, message.method)) { denied(session, fingerprint, message.method); return; }
+ const method = methodNamed(message.method);
+ if (!method || !authorized(session, message.method)) { deniedThrottled(session, fingerprint, String(message.method).slice(0, 80)); return; }
+ // A notify is a `send` method's transport and nothing else. Without this it was a way to run
+ // any `invoke` method — a project scan, a session spawn — with no reply and outside the
+ // request budget, because notifies are deliberately not counted against that budget.
+ if (method.channel !== 'send') { deniedThrottled(session, fingerprint, `notify:${message.method}`); return; }
if (!sessionArgAllowed(session, fingerprint, message.method, args)) return;
- // Not rate-limited: these are keystrokes and resizes, bursty by nature, and a dropped keystroke
- // is worse than a slow scan. The output backlog guard below bounds what they can cost.
+ // Keystrokes are never limited: they are bursty by nature (a paste is hundreds at once), and a
+ // dropped keystroke is worse than a slow scan. Everything else — resizes, closes — gets a
+ // budget generous enough for a window drag, so a runaway loop cannot spin a handler forever.
+ if (message.method !== 'cockpit:input' && !session.notifies.take()) {
+ deniedThrottled(session, fingerprint, `rate-limited:${message.method}`);
+ return;
+ }
try {
- remoteCallContext.run('remote', () => { options.api.methods[message.method].handler(...args); });
+ remoteCallContext.run('remote', () => {
+ const result = method.handler(...args) as unknown;
+ // An async `send` handler that rejects has nobody to reject to. Left alone it becomes an
+ // unhandled rejection in the main process, on a call another machine can repeat at will.
+ if (result && typeof (result as PromiseLike).then === 'function') {
+ Promise.resolve(result).catch(() => { /* fire-and-forget: nowhere to report it */ });
+ }
+ });
} catch { /* a fire-and-forget call has nowhere to report to; the global trap logs it */ }
return;
}
@@ -348,10 +445,13 @@ export function startHostServer(options: HostServerOptions): Promise
if (!session.helloSeen) { fail(session, 'protocol-mismatch', 'request before hello'); return; }
// Watching a session's output is reading it, and resizing it is driving it — so attach needs
// both, and asks through the same methods a local viewer would.
- if (!authorized(session, 'cockpit:resize')) { denied(session, fingerprint, 'attach'); return; }
+ if (!authorized(session, 'cockpit:resize')) { deniedThrottled(session, fingerprint, 'attach'); return; }
const sessionId = message.sessionId;
if (!isLocalSessionId(sessionId)) { deniedThrottled(session, fingerprint, `attach:${String(sessionId).slice(0, 80)}`); return; }
- if (!session.attached.has(sessionId) && session.attached.size >= MAX_ATTACHED_PER_CONNECTION) {
+ // A viewer re-sends attach on every resize. Only a NEW one changes who is watching, and
+ // announcing each repeat sent the whole connection list to the window per resize frame.
+ if (session.attached.has(sessionId)) return;
+ if (session.attached.size >= MAX_ATTACHED_PER_CONNECTION) {
deniedThrottled(session, fingerprint, 'attach:too-many');
return;
}
@@ -360,8 +460,10 @@ export function startHostServer(options: HostServerOptions): Promise
return;
}
case 'detach': {
- session.attached.delete(String(message.sessionId));
- announce();
+ // Same gate as attach: a peer that was never let in has nothing to detach, and must not be
+ // able to make this machine re-announce its connections by asking.
+ if (!session.helloSeen || !session.device) return;
+ if (session.attached.delete(String(message.sessionId))) announce();
return;
}
default:
@@ -369,9 +471,20 @@ export function startHostServer(options: HostServerOptions): Promise
}
}
+ /**
+ * The table entry for a name a peer sent, own properties only. A bare index would hand back
+ * `Object.prototype` members for names like `constructor`, and the policy check reading `.remote`
+ * off a function throws — inside the socket's data callback.
+ */
+ function methodNamed(name: unknown): ApiMethod | undefined {
+ return typeof name === 'string' && Object.prototype.hasOwnProperty.call(options.api.methods, name)
+ ? options.api.methods[name]
+ : undefined;
+ }
+
function authorized(session: Session, methodName: string): boolean {
if (!session.device) return false;
- return mayCallRemotely(options.api.methods[methodName], session.device.permissions);
+ return mayCallRemotely(methodNamed(methodName), session.device.permissions);
}
/**
@@ -388,29 +501,30 @@ export function startHostServer(options: HostServerOptions): Promise
/** The session-id argument check for a method that declares one; refusals are audited. */
function sessionArgAllowed(session: Session, fingerprint: string, methodName: string, args: unknown[]): boolean {
- const index = sessionIdArgOf(options.api.methods[methodName]);
+ const index = sessionIdArgOf(methodNamed(methodName));
if (index === null) return true;
if (isLocalSessionId(args[index])) return true;
deniedThrottled(session, fingerprint, `${methodName}:${String(args[index]).slice(0, 80)}`);
return false;
}
- function denied(session: Session, fingerprint: string, methodName: string): void {
- options.log({
- at: options.now(), kind: 'denied',
- machineName: session.device?.machineName ?? '', fingerprint, detail: methodName,
- });
- }
-
/**
- * `denied`, at most once per interval per connection. A keystroke aimed at a session that just
- * exited, or a viewer over its request budget, would otherwise write a log row per attempt.
+ * A refusal, logged at most once per interval per connection, with a count of what was held back.
+ * Every refusal goes through here: a keystroke aimed at a session that just exited, a viewer over
+ * its budget, or a peer spraying method names would otherwise write a row per attempt — and the log
+ * keeps only its newest 500, so a flood of junk is a way to erase the rows that mattered.
*/
function deniedThrottled(session: Session, fingerprint: string, detail: string): void {
const t = options.now();
- if (t - session.lastRefusalLogMs < REFUSAL_LOG_INTERVAL_MS) return;
+ if (t - session.lastRefusalLogMs < REFUSAL_LOG_INTERVAL_MS) { session.suppressedRefusals += 1; return; }
session.lastRefusalLogMs = t;
- denied(session, fingerprint, detail);
+ const held = session.suppressedRefusals;
+ session.suppressedRefusals = 0;
+ record(session, {
+ at: t, kind: 'denied',
+ machineName: session.device?.machineName ?? '', fingerprint,
+ detail: held > 0 ? `${detail} (+${held} more)` : detail,
+ });
}
function pair(session: Session, token: unknown, fingerprint: string): void {
@@ -418,12 +532,12 @@ export function startHostServer(options: HostServerOptions): Promise
const now = options.now();
if (!invite || invite.expiresAtMs <= now) {
fail(session, 'token-expired', 'That connection code has expired. Generate a new one on the host.');
- options.log({ at: now, kind: 'rejected', machineName: '', fingerprint, detail: 'expired invite' });
+ record(session, { at: now, kind: 'rejected', machineName: '', fingerprint, detail: 'expired invite' });
return;
}
if (typeof token !== 'string' || !tokensMatch(invite.token, token)) {
session.pairAttempts += 1;
- options.log({ at: now, kind: 'rejected', machineName: '', fingerprint, detail: 'bad token' });
+ record(session, { at: now, kind: 'rejected', machineName: '', fingerprint, detail: 'bad token' });
if (session.pairAttempts >= MAX_PAIR_ATTEMPTS) {
fail(session, 'rate-limited', 'Too many attempts.');
return;
@@ -440,12 +554,22 @@ export function startHostServer(options: HostServerOptions): Promise
pairedAtMs: now,
lastSeenMs: now,
};
- options.savePairedDevices(upsertPairedDevice(options.pairedDevices(), device));
+ const { devices, evicted } = upsertPairedDeviceReporting(options.pairedDevices(), device);
+ options.savePairedDevices(devices);
session.device = device;
// Single use. A code that stays valid after it worked is a code that is still valid when it is
// pasted into the wrong window later.
options.consumeInvite();
options.log({ at: now, kind: 'paired', machineName: device.machineName, fingerprint, detail: device.permissions.join(',') });
+ // The ledger is capped, so pairing past it un-pairs the oldest device. That is a machine losing
+ // access without anyone pressing "revoke" — it is said in the log, and it takes effect now, the
+ // same as a revoke would.
+ for (const gone of evicted) {
+ options.log({ at: now, kind: 'rejected', machineName: gone.machineName, fingerprint: gone.fingerprint, detail: 'unpaired: paired-device limit reached' });
+ for (const s of [...sessions]) {
+ if (s !== session && fingerprintsMatch(s.device?.fingerprint, gone.fingerprint)) s.connection.close('unpaired');
+ }
+ }
sendReady(session, fingerprint);
}
@@ -453,6 +577,7 @@ export function startHostServer(options: HostServerOptions): Promise
const device = session.device;
if (!device) return;
if (session.unauthenticatedTimer) { clearTimeout(session.unauthenticatedTimer); session.unauthenticatedTimer = undefined; }
+ session.connection.maxFrameBytes = MAX_FRAME_BYTES;
session.connection.send({
t: 'ready',
machineId: options.machineId,
@@ -478,10 +603,19 @@ export function startHostServer(options: HostServerOptions): Promise
* binary frame; everything else goes to any connection allowed to observe.
*/
const unsubscribe = options.api.events.subscribe((channel, payload) => {
- if (sessions.size === 0) return;
+ if (sessions.size === 0 || !REMOTE_EVENT_CHANNELS.has(channel)) return;
const sessionId = channel === 'cockpit:data' || channel === 'cockpit:exit' || channel === 'cockpit:resized'
? String((payload as { id?: unknown })?.id ?? '')
: null;
+ // An exited session is not being watched any more, whoever was attached to it. Kept in the set,
+ // its id held `hasRemoteViewers()` true for good — vetoing idle shutdown and sleep for a viewer
+ // looking at a terminal that no longer exists. Released after the exit itself is delivered.
+ const releaseExited = (): void => {
+ if (channel !== 'cockpit:exit' || !sessionId) return;
+ let released = false;
+ for (const s of sessions) if (s.attached.delete(sessionId)) released = true;
+ if (released) announce();
+ };
for (const session of sessions) {
if (!session.device || session.connection.closed) continue;
if (sessionId !== null) {
@@ -502,6 +636,7 @@ export function startHostServer(options: HostServerOptions): Promise
}
session.connection.send({ t: 'evt', ch: channel, payload });
}
+ releaseExited();
});
return new Promise((resolve, reject) => {
diff --git a/src/main/link/link.integration.test.ts b/src/main/link/link.integration.test.ts
index 75e6c59..71f088d 100644
--- a/src/main/link/link.integration.test.ts
+++ b/src/main/link/link.integration.test.ts
@@ -646,3 +646,268 @@ describe('task concurrency over the actual TLS transport', () => {
}
});
});
+
+describe('hardening', () => {
+ const pairedAs = (permissions: LinkPermission[]): void => {
+ paired = [{ machineId: CLIENT_ID, machineName: 'laptop', fingerprint: clientIdentity.fingerprint, permissions, pairedAtMs: now, lastSeenMs: null }];
+ };
+ const settle = (ms = 60) => new Promise((r) => setTimeout(r, ms));
+ type RawConn = ReturnType;
+
+ /** A client driven at the frame level, for what a well-behaved ConnectedLink would never send. */
+ const raw = (port: number, identity: LinkIdentity, script: { onHello?: (conn: RawConn) => void } = {}) =>
+ new Promise<{ conn: RawConn; closed: Promise }>((resolve) => {
+ let markClosed: () => void = () => undefined;
+ const closed = new Promise((r) => { markClosed = r; });
+ const socket = tls.connect({
+ host: '127.0.0.1', port,
+ key: identity.keyPem, cert: identity.certPem,
+ rejectUnauthorized: false, checkServerIdentity: () => undefined, minVersion: 'TLSv1.3',
+ }, () => {
+ const conn = attachConnection(socket, {
+ onMessage: (message) => { if (message.t === 'hello') script.onHello?.(conn); },
+ onPty: () => undefined,
+ onClose: () => markClosed(),
+ });
+ resolve({ conn, closed });
+ });
+ socket.on('error', () => markClosed());
+ });
+ const hello = (conn: RawConn, over: Record = {}) =>
+ conn.send({ t: 'hello', protocol: LINK_PROTOCOL, appVersion: '1.34.1', machineId: CLIENT_ID, machineName: 'laptop', ...over } as never);
+ const closedWithin = (closed: Promise, ms: number) =>
+ Promise.race([closed.then(() => true), new Promise((r) => setTimeout(() => r(false), ms))]);
+
+ it('runs only `send` methods as a notify — never an invoke method, with no reply and no budget', async () => {
+ pairedAs(['observe', 'control', 'spawn']);
+ const host = await startHost();
+ const dialed = await connect(host.port);
+ if (!dialed.ok) throw new Error('expected a connection');
+ dialed.link.notify('cockpit:open', [{ projectPath: 'C:\\repo' }]);
+ dialed.link.notify('projects:list', []);
+ await settle();
+ expect(openedProjects).toEqual([]);
+ expect(log.some((l) => l.kind === 'denied' && l.detail === 'notify:cockpit:open')).toBe(true);
+ // And the same method still works the way it is meant to be called.
+ await expect(dialed.link.request('cockpit:open', [{ projectPath: 'C:\\repo' }])).resolves.toMatchObject({ id: 'sess-1' });
+ dialed.link.close();
+ });
+
+ it('budgets resizes and closes, but never keystrokes', async () => {
+ pairedAs(['observe', 'control']);
+ const host = await startHost({ notifyRateLimit: { capacity: 3, refillPerMs: 0 } });
+ let resized = 0;
+ methods['cockpit:resize'] = { channel: 'send', remote: allowSession('control'), handler: () => { resized += 1; } };
+ const dialed = await connect(host.port);
+ if (!dialed.ok) throw new Error('expected a connection');
+ for (let i = 0; i < 10; i++) dialed.link.notify('cockpit:resize', ['sess-1', 80, 24]);
+ for (let i = 0; i < 200; i++) dialed.link.notify('cockpit:input', ['sess-1', 'x']);
+ await settle(120);
+ expect(resized).toBe(3);
+ expect(typed).toHaveLength(200);
+ expect(dialed.link.closed).toBe(false);
+ dialed.link.close();
+ });
+
+ it('swallows a rejecting fire-and-forget handler instead of leaving an unhandled rejection', async () => {
+ pairedAs(['observe', 'control']);
+ const host = await startHost();
+ methods['cockpit:resize'] = { channel: 'send', remote: allowSession('control'), handler: async () => { throw new Error('async send failed'); } };
+ const unhandled: unknown[] = [];
+ const trap = (reason: unknown): void => { unhandled.push(reason); };
+ process.on('unhandledRejection', trap);
+ try {
+ const dialed = await connect(host.port);
+ if (!dialed.ok) throw new Error('expected a connection');
+ dialed.link.notify('cockpit:resize', ['sess-1', 80, 24]);
+ await settle(100);
+ expect(unhandled).toEqual([]);
+ dialed.link.close();
+ } finally {
+ process.off('unhandledRejection', trap);
+ }
+ });
+
+ it('does not trip over a method name that is an Object.prototype member', async () => {
+ pairedAs(['observe', 'control']);
+ const host = await startHost();
+ const dialed = await connect(host.port);
+ if (!dialed.ok) throw new Error('expected a connection');
+ await expect(dialed.link.request('constructor', [])).rejects.toThrow(/no such method: constructor/);
+ dialed.link.notify('__proto__', []);
+ await expect(dialed.link.request('projects:list', [])).resolves.toBeTruthy();
+ dialed.link.close();
+ });
+
+ it('sends a paired machine only the session channels, whatever else the hub carries', async () => {
+ pairedAs(['observe', 'control']);
+ const host = await startHost();
+ const seen: string[] = [];
+ const dialed = await connect(host.port, { onEvent: (ch) => { seen.push(ch); } });
+ if (!dialed.ok) throw new Error('expected a connection');
+ events.publish('settings:changed', { secret: true });
+ events.publish('cockpit:sessions', []);
+ await settle();
+ expect(seen).toEqual(['cockpit:sessions']);
+ dialed.link.close();
+ });
+
+ it('turns strangers away past a small number waiting, but never a paired device', async () => {
+ pairedAs(['observe']);
+ const host = await startHost({ maxUnauthenticated: 2 });
+ const stranger = () => raw(host.port, identityOf('stranger'));
+ const a = await stranger();
+ const b = await stranger();
+ await settle(30);
+ const c = await stranger();
+ expect(await closedWithin(c.closed, 1_000)).toBe(true);
+ expect(await closedWithin(a.closed, 50)).toBe(false);
+ const dialed = await connect(host.port);
+ expect(dialed.ok).toBe(true);
+ if (dialed.ok) dialed.link.close();
+ a.conn.close(); b.conn.close();
+ });
+
+ it('drops a peer that sends a large frame before it is let in, and serves large ones after', async () => {
+ const host = await startHost();
+ const big = 'x'.repeat(200 * 1024);
+ const unpaired = await raw(host.port, identityOf('stranger'), {
+ onHello: (conn) => { hello(conn, { wantsPairing: true, machineName: big }); },
+ });
+ expect(await closedWithin(unpaired.closed, 1_000)).toBe(true);
+
+ pairedAs(['observe']);
+ const dialed = await connect(host.port);
+ if (!dialed.ok) throw new Error('expected a connection');
+ await expect(dialed.link.request('projects:list', [big])).resolves.toBeTruthy();
+ dialed.link.close();
+ });
+
+ it('ignores a detach from a peer that was never let in', async () => {
+ let announced = 0;
+ await startHost({ onConnectionsChanged: () => { announced += 1; } });
+ const stranger = await raw(server!.port, identityOf('stranger'), {
+ onHello: (conn) => {
+ hello(conn, { wantsPairing: true });
+ for (let i = 0; i < 20; i++) conn.send({ t: 'detach', sessionId: 'sess-1' });
+ },
+ });
+ await settle(100);
+ expect(announced).toBe(0);
+ stranger.conn.close();
+ });
+
+ it('keeps strangers from pushing the audit history out with refusals', async () => {
+ const host = await startHost();
+ // One connection spraying calls it may not make...
+ const sprayer = await raw(host.port, identityOf('sprayer'), {
+ onHello: (conn) => {
+ hello(conn, { wantsPairing: true });
+ for (let i = 0; i < 100; i++) conn.send({ t: 'req', id: i, method: `junk:${i}`, args: [] });
+ for (let i = 0; i < 100; i++) conn.send({ t: 'notify', method: `junk:${i}`, args: [] });
+ },
+ });
+ await settle(150);
+ sprayer.conn.close();
+ // ...and a loop of connections that are each turned away.
+ for (let i = 0; i < 30; i++) await connect(host.port);
+ await settle(100);
+ const fromStrangers = log.length;
+ expect(fromStrangers).toBeLessThanOrEqual(20);
+ // A paired device's history is still written in full.
+ pairedAs(['observe']);
+ const dialed = await connect(host.port);
+ if (!dialed.ok) throw new Error('expected a connection');
+ expect(log.slice(fromStrangers).some((l) => l.kind === 'connected')).toBe(true);
+ dialed.link.close();
+ });
+
+ it("throttles a paired device's own refusals and counts what it held back", async () => {
+ pairedAs(['observe']);
+ const host = await startHost();
+ const dialed = await connect(host.port);
+ if (!dialed.ok) throw new Error('expected a connection');
+ for (let i = 0; i < 50; i++) dialed.link.notify('cockpit:input', ['sess-1', 'x']);
+ await settle(80);
+ expect(log.filter((l) => l.kind === 'denied')).toHaveLength(1);
+ now += 11_000;
+ dialed.link.notify('cockpit:input', ['sess-1', 'x']);
+ await settle(80);
+ const denials = log.filter((l) => l.kind === 'denied');
+ expect(denials).toHaveLength(2);
+ expect(denials[1].detail).toBe('cockpit:input (+49 more)');
+ expect(host.connections).toHaveLength(1);
+ dialed.link.close();
+ });
+
+ it('gives up on a host that finishes TLS and then says nothing', async () => {
+ // TLS completes, so the connect timeout is already cleared; the heartbeat only starts at `ready`.
+ const silent = tls.createServer({ key: hostIdentity.keyPem, cert: hostIdentity.certPem, requestCert: true, rejectUnauthorized: false, minVersion: 'TLSv1.3' }, () => undefined);
+ await new Promise((r) => silent.listen(0, '127.0.0.1', () => r()));
+ try {
+ const port = (silent.address() as { port: number }).port;
+ const started = Date.now();
+ const dialed = await connect(port, { handshakeTimeoutMs: 200 });
+ expect(Date.now() - started).toBeLessThan(2_000);
+ expect(dialed).toEqual({ ok: false, failure: expect.objectContaining({ kind: 'unreachable', attempts: [{ address: '127.0.0.1', reason: 'timed-out' }] }) });
+ } finally {
+ await new Promise((r) => silent.close(() => r()));
+ }
+ });
+
+ it('releases an exited session from everyone attached to it, after telling them it exited', async () => {
+ pairedAs(['observe', 'control']);
+ let announced = 0;
+ const host = await startHost({ onConnectionsChanged: () => { announced += 1; } });
+ const exits: unknown[] = [];
+ const dialed = await connect(host.port, { onEvent: (ch, payload) => { if (ch === 'cockpit:exit') exits.push(payload); } });
+ if (!dialed.ok) throw new Error('expected a connection');
+ dialed.link.attach('sess-1', 80, 24);
+ await settle(40);
+ const afterFirst = announced;
+ // Re-sent on every resize; only the first one changes who is watching.
+ for (let i = 0; i < 10; i++) dialed.link.attach('sess-1', 100 + i, 30);
+ await settle(40);
+ expect(announced).toBe(afterFirst);
+ expect(host.connections[0].attachedSessions).toEqual(['sess-1']);
+
+ events.publish('cockpit:exit', { id: 'sess-1', exitCode: 0 });
+ await settle(60);
+ expect(exits).toEqual([{ id: 'sess-1', exitCode: 0 }]);
+ expect(host.connections[0].attachedSessions).toEqual([]);
+ expect(announced).toBe(afterFirst + 1);
+ dialed.link.close();
+ });
+
+ it('lets an already-paired device keep its permissions and leaves the invite unspent', async () => {
+ pairedAs(['observe']);
+ const token = createPairingToken();
+ invite = { token, expiresAtMs: now + 300_000, permissions: ['observe', 'control', 'spawn', 'write', 'power'] };
+ const host = await startHost();
+ const dialed = await connect(host.port, { pairingToken: token });
+ if (!dialed.ok) throw new Error('expected a connection');
+ await settle(80);
+ expect(invite).not.toBeNull();
+ expect(paired).toHaveLength(1);
+ expect(paired[0].permissions).toEqual(['observe']);
+ await expect(dialed.link.request('cockpit:open', [{ projectPath: 'C:\\repo' }])).rejects.toThrow(/not permitted/);
+ dialed.link.close();
+ });
+
+ it('says so in the log when pairing past the cap un-pairs the oldest device', async () => {
+ const fp = (i: number): string => (i.toString(16).toUpperCase().padStart(2, '0').repeat(32).match(/../g) ?? []).join(':');
+ paired = Array.from({ length: 32 }, (_, i) => ({
+ machineId: `m-${i}`, machineName: `old-${i}`, fingerprint: fp(i + 1),
+ permissions: ['observe'] as LinkPermission[], pairedAtMs: now - 1000 + i, lastSeenMs: null,
+ }));
+ const token = createPairingToken();
+ invite = { token, expiresAtMs: now + 300_000, permissions: ['observe'] };
+ const host = await startHost();
+ const dialed = await connect(host.port, { pairingToken: token });
+ if (!dialed.ok) throw new Error('expected a connection');
+ expect(paired).toHaveLength(32);
+ expect(paired.some((d) => d.machineName === 'old-0')).toBe(false);
+ expect(log.some((l) => l.kind === 'rejected' && l.machineName === 'old-0' && l.detail.includes('limit'))).toBe(true);
+ dialed.link.close();
+ });
+});
diff --git a/src/main/link/linkService.portmap.test.ts b/src/main/link/linkService.portmap.test.ts
new file mode 100644
index 0000000..7ef0e00
--- /dev/null
+++ b/src/main/link/linkService.portmap.test.ts
@@ -0,0 +1,125 @@
+/**
+ * The router port mapping's lifecycle against the server it was asked for. The router itself is
+ * stubbed: what is under test is that an answer arriving for a server that has since been replaced
+ * (a port change, host mode off) is thrown away rather than adopted and renewed.
+ */
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import { mkdtempSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import type { PortMapResult, MapPortOptions } from './portMap';
+
+vi.mock('electron', () => ({ clipboard: { readText: () => '' } }));
+
+const asked: { options: MapPortOptions; answer: (r: PortMapResult) => void }[] = [];
+vi.mock('./portMap', () => ({
+ PORT_MAP_LIFETIME_S: 3600,
+ mapPort: (options: MapPortOptions) => new Promise((resolve) => { asked.push({ options, answer: resolve }); }),
+}));
+vi.mock('./defaultGateway', () => ({
+ defaultGateway: async () => '192.168.1.1',
+ localAddressFor: async () => '192.168.1.50',
+}));
+
+import { createLinkService, type LinkPersistence, type LinkService } from './linkService';
+import { makeMethodTable } from '../api/methods';
+import { makeEventHub } from '../api/events';
+import type { KnownHost, PairedDevice } from './devices';
+
+const dirs: string[] = [];
+const services: LinkService[] = [];
+
+function memoryStore(): LinkPersistence {
+ let hostMode = false;
+ let devices: PairedDevice[] = [];
+ let hosts: KnownHost[] = [];
+ let port = 0;
+ return {
+ getHostMode: () => hostMode, setHostMode: (on) => { hostMode = on; },
+ getPort: () => port, setPort: (p) => { port = p; },
+ getPairedDevices: () => devices, setPairedDevices: (d) => { devices = d; },
+ getKnownHosts: () => hosts, setKnownHosts: (h) => { hosts = h; },
+ };
+}
+
+/** setPort refuses 0, and the default port may well be taken by a real DevDeck on this machine. */
+async function freePort(): Promise {
+ const { createServer } = await import('node:net');
+ return new Promise((resolve) => {
+ const s = createServer();
+ s.listen(0, '127.0.0.1', () => { const port = (s.address() as { port: number }).port; s.close(() => resolve(port)); });
+ });
+}
+
+async function waitFor(cond: () => boolean, timeoutMs = 3_000): Promise {
+ const until = Date.now() + timeoutMs;
+ while (!cond()) {
+ if (Date.now() > until) throw new Error('condition never held');
+ await new Promise((r) => setTimeout(r, 10));
+ }
+}
+
+afterEach(async () => {
+ for (const s of services.splice(0)) await s.dispose();
+ for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
+ asked.length = 0;
+});
+
+describe('router port mapping', () => {
+ it('discards an answer for a server that a port change has already replaced', async () => {
+ const dir = mkdtempSync(join(tmpdir(), 'devdeck-portmap-'));
+ dirs.push(dir);
+ const service = createLinkService({
+ userDataDir: dir,
+ safeStorage: { isEncryptionAvailable: () => true, encryptString: (t) => Buffer.from(t), decryptString: (b) => b.toString() },
+ api: { methods: makeMethodTable().table, events: makeEventHub() },
+ machineId: '3f2a1b4c-5d6e-4f70-8a9b-0c1d2e3f4a5b',
+ machineName: () => 'host',
+ appVersion: '1.34.1',
+ store: memoryStore(),
+ onError: () => undefined,
+ });
+ services.push(service);
+
+ await service.setHostMode(true);
+ await waitFor(() => asked.length === 1);
+ const firstPort = asked[0].options.port;
+
+ // Changed before the router answered for the first server.
+ await service.setPort(await freePort());
+ await waitFor(() => asked.length === 2);
+ const secondPort = service.hostStatus().port;
+ expect(asked[1].options.port).toBe(secondPort);
+
+ asked[0].answer({ state: 'mapped', via: 'nat-pmp', externalAddress: '203.0.113.7', externalPort: firstPort, lifetimeS: 120 });
+ await new Promise((r) => setTimeout(r, 20));
+ // Neither published as this host's mapping nor (same path) renewed.
+ expect(service.hostStatus().portMap).toBeNull();
+
+ asked[1].answer({ state: 'mapped', via: 'nat-pmp', externalAddress: '203.0.113.7', externalPort: secondPort, lifetimeS: 120 });
+ await waitFor(() => service.hostStatus().portMap !== null);
+ expect(service.hostStatus().portMap?.externalPort).toBe(secondPort);
+ });
+
+ it('discards an answer that arrives after host mode was turned off', async () => {
+ const dir = mkdtempSync(join(tmpdir(), 'devdeck-portmap-'));
+ dirs.push(dir);
+ const service = createLinkService({
+ userDataDir: dir,
+ safeStorage: { isEncryptionAvailable: () => true, encryptString: (t) => Buffer.from(t), decryptString: (b) => b.toString() },
+ api: { methods: makeMethodTable().table, events: makeEventHub() },
+ machineId: '3f2a1b4c-5d6e-4f70-8a9b-0c1d2e3f4a5b',
+ machineName: () => 'host',
+ appVersion: '1.34.1',
+ store: memoryStore(),
+ onError: () => undefined,
+ });
+ services.push(service);
+ await service.setHostMode(true);
+ await waitFor(() => asked.length === 1);
+ await service.setHostMode(false);
+ asked[0].answer({ state: 'mapped', via: 'upnp', externalAddress: '203.0.113.7', externalPort: 1, lifetimeS: 120 });
+ await new Promise((r) => setTimeout(r, 20));
+ expect(service.hostStatus().portMap).toBeNull();
+ });
+});
diff --git a/src/main/link/linkService.test.ts b/src/main/link/linkService.test.ts
index 428bbe4..1cf7462 100644
--- a/src/main/link/linkService.test.ts
+++ b/src/main/link/linkService.test.ts
@@ -8,6 +8,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
+import * as tls from 'node:tls';
const clipboardText = { value: '' };
vi.mock('electron', () => ({
@@ -20,6 +21,9 @@ import { makeEventHub, type EventHub } from '../api/events';
import type { DeckApiBundle } from '../api/deckApi';
import type { SafeStorageLike } from './identity';
import type { KnownHost, PairedDevice } from './devices';
+import { attachConnection } from './connection';
+import { generateMachineCertificate } from './selfSignedCert';
+import { LINK_PROTOCOL } from './protocol';
const safeStorage: SafeStorageLike = {
isEncryptionAvailable: () => true,
@@ -53,6 +57,7 @@ interface Fixture {
const dirs: string[] = [];
const services: LinkService[] = [];
+const fakes: tls.Server[] = [];
function makeApi(opened: string[]): { bundle: DeckApiBundle; events: EventHub } {
const table = makeMethodTable();
@@ -109,6 +114,7 @@ async function waitFor(cond: () => boolean, timeoutMs = 4_000): Promise {
beforeEach(() => { clipboardText.value = ''; });
afterEach(async () => {
for (const s of services.splice(0)) await s.dispose();
+ for (const f of fakes.splice(0)) await new Promise((r) => f.close(() => r()));
for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
});
@@ -375,3 +381,129 @@ describe('the audit log', () => {
expect(entries.map((e) => e.kind)).toContain('connected');
});
});
+
+describe('removing a machine', () => {
+ it('stays removed — its closing link does not redial and save it back', async () => {
+ const host = makeService('host');
+ const viewer = makeService('viewer', { machineId: '11111111-2222-4333-8444-555555555555', reconnectStepsMs: [50] });
+ const code = (await host.service.createInvite()).invite!.code;
+ const added = await viewer.service.addMachine(code);
+ if (!added.ok) throw new Error('expected pairing');
+ viewer.service.removeMachine(added.machine.machineId);
+ expect(viewer.service.machines()).toEqual([]);
+
+ await new Promise((r) => setTimeout(r, 400)); // several retry steps' worth
+ expect(viewer.service.machines()).toEqual([]);
+ expect(host.service.hostStatus().connections).toEqual([]);
+ });
+
+ it('forgets the known host for good, across the retry the close used to arm', async () => {
+ const store = memoryStore(0);
+ const host = makeService('host');
+ const remote: string[] = [];
+ const viewer = makeService('viewer', {
+ machineId: '11111111-2222-4333-8444-555555555555', reconnectStepsMs: [50], store,
+ onRemoteEvent: (ch) => { remote.push(ch); },
+ });
+ const code = (await host.service.createInvite()).invite!.code;
+ const added = await viewer.service.addMachine(code);
+ if (!added.ok) throw new Error('expected pairing');
+ viewer.service.removeMachine(added.machine.machineId);
+ expect(store.getKnownHosts()).toEqual([]);
+ await new Promise((r) => setTimeout(r, 400));
+ host.events.publish('cockpit:sessions', [{ id: 'after-removal' }]);
+ await new Promise((r) => setTimeout(r, 80));
+ expect(store.getKnownHosts()).toEqual([]);
+ expect(remote).toEqual([]);
+ });
+
+ it('drops a dial that was still in flight when the machine was removed', async () => {
+ const store = memoryStore(0);
+ const host = makeService('host');
+ await host.service.setPort(await freePort());
+ const viewer = makeService('viewer', { machineId: '11111111-2222-4333-8444-555555555555', reconnectStepsMs: [50], store });
+ const code = (await host.service.createInvite()).invite!.code;
+ const added = await viewer.service.addMachine(code);
+ if (!added.ok) throw new Error('expected pairing');
+ await host.service.setHostMode(false);
+ await waitFor(() => viewer.service.machines()[0]?.state !== 'connected');
+ await host.service.setHostMode(true);
+ const redial = viewer.service.reconnect(added.machine.machineId); // in flight
+ viewer.service.removeMachine(added.machine.machineId);
+ await redial;
+ await new Promise((r) => setTimeout(r, 200));
+ expect(store.getKnownHosts()).toEqual([]);
+ expect(viewer.service.machines()).toEqual([]);
+ expect(host.service.hostStatus().connections).toEqual([]);
+ });
+});
+
+describe('what a host may push', () => {
+ /** A host speaking the protocol by hand, so it can send what a real host never would. */
+ async function fakeHost(push: (conn: ReturnType) => void) {
+ const identity = generateMachineCertificate({ commonName: 'fake-host' });
+ const machineId = '99999999-8888-4777-8666-555555555555';
+ const server = tls.createServer({ key: identity.keyPem, cert: identity.certPem, requestCert: true, rejectUnauthorized: false, minVersion: 'TLSv1.3' }, (socket) => {
+ const conn = attachConnection(socket, {
+ onMessage: (message) => {
+ if (message.t !== 'hello') return;
+ conn.send({ t: 'ready', machineId, machineName: 'fake', appVersion: '1.34.1', permissions: ['observe'], addresses: ['127.0.0.1'], port, features: ['ping'] });
+ setTimeout(() => push(conn), 30);
+ },
+ onPty: () => undefined,
+ onClose: () => undefined,
+ });
+ conn.send({ t: 'hello', protocol: LINK_PROTOCOL, appVersion: '1.34.1', machineId, machineName: 'fake', features: ['ping'] });
+ });
+ await new Promise((r) => server.listen(0, '127.0.0.1', () => r()));
+ const port = (server.address() as { port: number }).port;
+ fakes.push(server);
+ const known: KnownHost = { machineId, machineName: 'fake', fingerprint: identity.fingerprint, addresses: ['127.0.0.1'], port, lastAddress: null, lastSeenMs: null };
+ return { machineId, known };
+ }
+
+ it('forwards only session channels, and never a channel this process writes itself', async () => {
+ const { machineId, known } = await fakeHost((conn) => {
+ conn.send({ t: 'evt', ch: 'link:sessions', payload: { machineId: 'local', sessions: [{ id: 'forged' }] } });
+ conn.send({ t: 'evt', ch: 'link:error', payload: { t: 'error', code: 'unpaired', message: 'forged' } });
+ conn.send({ t: 'evt', ch: 'settings:changed', payload: {} });
+ conn.send({ t: 'evt', ch: 'cockpit:exit', payload: { id: 's1', exitCode: 0 } });
+ conn.send({ t: 'evt', ch: 'cockpit:sessions', payload: [{ id: 's1' }] });
+ // A real refusal on an established link still reaches the window, as it always did.
+ conn.send({ t: 'error', code: 'rate-limited', message: 'output backlog exceeded' });
+ });
+ const store = memoryStore(0);
+ store.setKnownHosts([known]);
+ const remote: { ch: string; payload: unknown }[] = [];
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
+ try {
+ makeService('viewer', {
+ machineId: '11111111-2222-4333-8444-555555555555', store,
+ onRemoteEvent: (ch, payload) => { remote.push({ ch, payload }); },
+ });
+ await waitFor(() => remote.length >= 3);
+ await new Promise((r) => setTimeout(r, 80));
+ } finally {
+ warn.mockRestore();
+ }
+ expect(remote).toEqual([
+ { ch: 'cockpit:exit', payload: { id: `link:${machineId}:s1`, exitCode: 0 } },
+ { ch: 'link:sessions', payload: { machineId, sessions: [{ id: `link:${machineId}:s1` }] } },
+ { ch: 'link:error', payload: { t: 'error', code: 'rate-limited', message: 'output backlog exceeded' } },
+ ]);
+ });
+});
+
+describe('remote viewers', () => {
+ it('counts only viewers of sessions that are still running', async () => {
+ let live = ['C:\\remote-repo#1'];
+ const host = makeService('host', { liveSessionIds: () => live });
+ const viewer = makeService('viewer', { machineId: '11111111-2222-4333-8444-555555555555' });
+ const code = (await host.service.createInvite()).invite!.code;
+ await viewer.service.addMachine(code);
+ viewer.service.attach(`link:${host.service.hostStatus().machineId}:C:\\remote-repo#1`, 80, 24);
+ await waitFor(() => host.service.hasRemoteViewers());
+ live = [];
+ expect(host.service.hasRemoteViewers()).toBe(false);
+ });
+});
diff --git a/src/main/link/linkService.ts b/src/main/link/linkService.ts
index 0d427e1..43f73a3 100644
--- a/src/main/link/linkService.ts
+++ b/src/main/link/linkService.ts
@@ -159,8 +159,20 @@ interface Outbound {
inflight: Promise | null;
/** Sessions this viewer has asked to watch; re-sent after a reconnect so the stream resumes. */
attached: Map;
+ /** Set by removeMachine. A removed entry may still have a dial in flight or a socket closing. */
+ removed: boolean;
+ /** A host sending event channels it has no business sending is noted once, not per frame. */
+ strayEventNoted: boolean;
}
+/**
+ * The event channels a host's pushes may arrive on. Anything else is dropped: this process forwards
+ * what it accepts to the window, and the window trusts channels like `link:sessions` to have been
+ * written HERE — a host that could send one could name any machine, this one included, as the owner
+ * of the sessions it lists.
+ */
+const HOST_EVENT_CHANNELS = new Set(['cockpit:data', 'cockpit:exit', 'cockpit:resized']);
+
export interface LinkService {
hostStatus(): HostStatus;
machines(): MachineStatus[];
@@ -245,15 +257,24 @@ export function createLinkService(options: LinkServiceOptions): LinkService {
* lapsed mapping closes the port silently.
*/
async function refreshPortMapping(): Promise {
- if (disposed || !server) return;
+ // Bound to the server it was started for. Every await below can outlive that server — host mode
+ // switched off, or a port change, which stops one server and starts another — and a refresh that
+ // only asked "is there A server" resumed against the new one: it published the old port's mapping
+ // as current and armed a renewal chain for a port nothing listens on, forever.
+ const owner = server;
+ const current = (): boolean => !disposed && owner !== null && server === owner;
+ if (!current() || !owner) return;
const gateway = await defaultGateway();
const localAddress = gateway ? await localAddressFor(gateway) : null;
- if (disposed || !server) return;
- portMap = await mapPort({ port: server.port, gateway, localAddress });
+ if (!current()) return;
+ const result = await mapPort({ port: owner.port, gateway, localAddress });
+ if (!current()) return;
+ portMap = result;
changed();
- if (portMap.state !== 'mapped') return; // nothing to keep alive
- const lifetime = portMap.lifetimeS ?? PORT_MAP_LIFETIME_S;
- portMapTimer = setTimeout(() => { void refreshPortMapping(); }, Math.max(60, lifetime / 2) * 1000);
+ if (result.state !== 'mapped') return; // nothing to keep alive
+ const lifetime = result.lifetimeS ?? PORT_MAP_LIFETIME_S;
+ if (portMapTimer) clearTimeout(portMapTimer);
+ portMapTimer = setTimeout(() => { portMapTimer = null; void refreshPortMapping(); }, Math.max(60, lifetime / 2) * 1000);
portMapTimer.unref?.();
}
@@ -272,15 +293,26 @@ export function createLinkService(options: LinkServiceOptions): LinkService {
const entry: Outbound = {
host, link: null, state: 'offline', problem: null,
attempt: 0, timer: null, inflight: null, attached: new Map(),
+ removed: false, strayEventNoted: false,
};
outbound.set(host.machineId, entry);
void connect(entry);
return entry;
}
+ /**
+ * Whether `entry` is still the one this service keeps for its machine. Everything that can fire
+ * after removeMachine — a socket's close, a dial resolving, a retry timer — checks this first.
+ * Closing a removed machine's link used to run its onClose, which armed a retry, which redialed
+ * and saved the machine straight back into the known-host list it had just been taken out of.
+ */
+ function isCurrent(entry: Outbound): boolean {
+ return !disposed && !entry.removed && outbound.get(entry.host.machineId) === entry;
+ }
+
/** Idempotent: concurrent callers share one dial and all wait for the same outcome. */
function connect(entry: Outbound): Promise {
- if (disposed || entry.link) return Promise.resolve();
+ if (!isCurrent(entry) || entry.link) return Promise.resolve();
if (entry.inflight) return entry.inflight;
entry.inflight = dial(entry).finally(() => { entry.inflight = null; });
return entry.inflight;
@@ -298,6 +330,7 @@ export function createLinkService(options: LinkServiceOptions): LinkService {
appVersion: options.appVersion,
heartbeat: options.heartbeat,
onEvent: (channel, payload) => {
+ if (!isCurrent(entry)) return;
// "What is running there" is re-addressed to a channel that NAMES the machine. Deriving it
// from the ids would work right up until the interesting case — an empty list, which is how a
// machine says its last session ended and has no id to read the machine from.
@@ -308,16 +341,36 @@ export function createLinkService(options: LinkServiceOptions): LinkService {
});
return;
}
+ if (!HOST_EVENT_CHANNELS.has(channel)) {
+ if (!entry.strayEventNoted) {
+ entry.strayEventNoted = true;
+ console.warn(`[link] dropped event channel ${JSON.stringify(String(channel).slice(0, 80))} from ${entry.host.machineName}`);
+ }
+ return;
+ }
options.onRemoteEvent?.(channel, qualifyPayload(entry.host.machineId, channel, payload));
},
- onPty: (sessionId, bytes) => options.onRemotePty?.(qualifyRemoteId(entry.host.machineId, sessionId), bytes),
+ // The host's own error frames, which are this file's to forward — unlike a `link:error` channel
+ // the host might name in an event, which onEvent above drops.
+ onHostError: (message) => {
+ if (!isCurrent(entry)) return;
+ options.onRemoteEvent?.('link:error', { t: 'error', code: message.code, message: message.message });
+ },
+ onPty: (sessionId, bytes) => { if (isCurrent(entry)) options.onRemotePty?.(qualifyRemoteId(entry.host.machineId, sessionId), bytes); },
onClose: () => {
entry.link = null;
+ if (!isCurrent(entry)) return;
entry.state = 'offline';
changed();
scheduleRetry(entry);
},
});
+ if (!isCurrent(entry)) {
+ // Removed (or the service disposed) while this dial was in flight. Whatever it reached is
+ // hung up on, and nothing is written back — not the host, not a retry.
+ if (result.ok) result.link.close('removed');
+ return;
+ }
if (!result.ok) {
entry.problem = result.failure;
// An impostor is called out as its own state and never retried on a timer: quietly reconnecting
@@ -351,7 +404,7 @@ export function createLinkService(options: LinkServiceOptions): LinkService {
}
function scheduleRetry(entry: Outbound, delayMs?: number): void {
- if (disposed || entry.timer || entry.state === 'impostor') return;
+ if (!isCurrent(entry) || entry.timer || entry.state === 'impostor') return;
const steps = options.reconnectStepsMs ?? RECONNECT_STEPS_MS;
const delay = delayMs ?? steps[Math.min(entry.attempt, steps.length - 1)];
entry.attempt += 1;
@@ -553,9 +606,15 @@ export function createLinkService(options: LinkServiceOptions): LinkService {
removeMachine(machineId) {
const entry = outbound.get(machineId);
if (entry) {
- if (entry.timer) clearTimeout(entry.timer);
- entry.link?.close('removed');
+ // In this order: once it is marked and out of the map, nothing its close sets off can bring
+ // it back (see isCurrent).
+ entry.removed = true;
outbound.delete(machineId);
+ if (entry.timer) { clearTimeout(entry.timer); entry.timer = null; }
+ entry.attached.clear();
+ const link = entry.link;
+ entry.link = null;
+ link?.close('removed');
}
options.store.setKnownHosts(removeKnownHost(options.store.getKnownHosts(), machineId));
changed();
@@ -652,7 +711,12 @@ export function createLinkService(options: LinkServiceOptions): LinkService {
auditLog(limit) { return log.recent(limit); },
clearAuditLog() { log.clear(); },
- hasRemoteViewers() { return connections.some((c) => c.attachedSessions.length > 0); },
+ hasRemoteViewers() {
+ // Only sessions that still exist count. The host drops an exited session from what viewers are
+ // attached to; this is the second check, for an id that left the running set some other way.
+ const live = options.liveSessionIds?.();
+ return connections.some((c) => c.attachedSessions.some((id) => !live || live.includes(id)));
+ },
async dispose() {
disposed = true;
diff --git a/src/main/link/protocol.test.ts b/src/main/link/protocol.test.ts
index 06230d5..ea8e041 100644
--- a/src/main/link/protocol.test.ts
+++ b/src/main/link/protocol.test.ts
@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest';
import {
- FrameKind, FRAME_HEADER_BYTES, LINK_PROTOCOL, MAX_FRAME_BYTES,
+ FrameKind, FRAME_HEADER_BYTES, LINK_PROTOCOL, MAX_FRAME_BYTES, PRE_AUTH_MAX_FRAME_BYTES,
encodeJsonFrame, encodePtyFrame, decodePtyPayload, makeFrameDecoder, parseLinkMessage, protocolMatches,
} from './protocol';
@@ -124,3 +124,48 @@ describe('protocolMatches', () => {
expect(protocolMatches(undefined)).toBe(false);
});
});
+
+describe('makeFrameDecoder limits and large frames', () => {
+ it('reassembles a large frame fed in small chunks, byte-identical', () => {
+ // A scrollback replay is the largest real frame and arrives in socket-sized reads. Gluing every
+ // read onto the buffer as it came copied the frame once per read — quadratic in its size.
+ const body = Buffer.alloc(4 * 1024 * 1024);
+ for (let i = 0; i < body.length; i++) body[i] = (i * 31) & 0xff;
+ const frame = encodePtyFrame('big', body);
+ const decoder = makeFrameDecoder();
+ const out = [];
+ const started = Date.now();
+ for (let i = 0; i < frame.length; i += 16 * 1024) out.push(...decoder.push(frame.subarray(i, i + 16 * 1024)));
+ expect(Date.now() - started).toBeLessThan(2_000);
+ expect(out).toHaveLength(1);
+ expect(decodePtyPayload(out[0].payload)!.bytes.equals(body)).toBe(true);
+ expect(decoder.pending).toBe(0);
+ });
+
+ it('keeps frames that share a chunk with the tail of a previous one intact', () => {
+ const a = encodeJsonFrame({ t: 'detach', sessionId: 'a' });
+ const b = encodeJsonFrame({ t: 'detach', sessionId: 'b' });
+ const c = encodeJsonFrame({ t: 'detach', sessionId: 'c' });
+ const all = Buffer.concat([a, b, c]);
+ const decoder = makeFrameDecoder();
+ const split = a.length + 2; // the second header straddles the boundary
+ const frames = [...decoder.push(all.subarray(0, split)), ...decoder.push(all.subarray(split))];
+ expect(frames.map((f) => (decodeJson(f.payload) as { sessionId: string }).sessionId)).toEqual(['a', 'b', 'c']);
+ });
+
+ it('refuses a frame over a lowered ceiling, and accepts it once the ceiling is raised', () => {
+ // Before a peer is let in, nothing it may say is bigger than a hello.
+ const frame = encodePtyFrame('s', Buffer.alloc(PRE_AUTH_MAX_FRAME_BYTES + 10));
+ expect(() => makeFrameDecoder({ maxFrameBytes: PRE_AUTH_MAX_FRAME_BYTES }).push(frame)).toThrow(/refusing a frame/);
+ const decoder = makeFrameDecoder({ maxFrameBytes: PRE_AUTH_MAX_FRAME_BYTES });
+ decoder.maxFrameBytes = MAX_FRAME_BYTES;
+ expect(decoder.push(frame)).toHaveLength(1);
+ });
+
+ it('never lets a ceiling be raised past the protocol maximum', () => {
+ const decoder = makeFrameDecoder({ maxFrameBytes: MAX_FRAME_BYTES * 4 });
+ expect(decoder.maxFrameBytes).toBe(MAX_FRAME_BYTES);
+ decoder.maxFrameBytes = Number.NaN;
+ expect(decoder.maxFrameBytes).toBe(MAX_FRAME_BYTES);
+ });
+});
diff --git a/src/main/link/protocol.ts b/src/main/link/protocol.ts
index 2fdc094..6c71aad 100644
--- a/src/main/link/protocol.ts
+++ b/src/main/link/protocol.ts
@@ -174,6 +174,13 @@ export interface DecodedFrame {
payload: Buffer;
}
+/**
+ * The ceiling for a connection that has not been let in yet. Before `ready`, a peer may only say
+ * `hello` and `pair` — a few hundred bytes — and anyone who can reach the port can open one, so the
+ * full ceiling would let each of them park megabytes in this process for the grace period.
+ */
+export const PRE_AUTH_MAX_FRAME_BYTES = 64 * 1024;
+
export interface FrameDecoder {
/**
* Feed one socket chunk; returns whatever complete frames it completed.
@@ -183,45 +190,94 @@ export interface FrameDecoder {
push(chunk: Buffer): DecodedFrame[];
/** Bytes held back waiting for the rest of a frame — a stall diagnostic, not a protocol feature. */
readonly pending: number;
+ /** The largest frame this decoder accepts; never above MAX_FRAME_BYTES. Checked as each header is read. */
+ maxFrameBytes: number;
}
/**
* TCP gives a byte stream, not messages: a frame arrives split across chunks, several frames arrive in
* one chunk, and a header itself can straddle the boundary. That is where framing bugs live, so this
- * is a plain buffer-and-slice loop with no fast paths.
+ * stays a plain loop with no fast paths.
+ *
+ * Chunks are held as a LIST and only joined once a whole frame is there. Gluing every chunk onto
+ * the buffer as it arrived (and re-copying the tail after each frame) copied a large frame once per
+ * chunk — an 8 MB replay arriving in 64 KB reads was a quadratic number of bytes moved on the
+ * thread that serves every live terminal.
*/
-export function makeFrameDecoder(): FrameDecoder {
- let buffer = Buffer.alloc(0);
+export function makeFrameDecoder(options: { maxFrameBytes?: number } = {}): FrameDecoder {
+ const chunks: Buffer[] = [];
+ let length = 0;
+ let limit = clampLimit(options.maxFrameBytes);
+
+ /** The first `n` bytes, without consuming them. Only ever asked for a header's worth. */
+ const peek = (n: number): Buffer => {
+ if (chunks[0].length >= n) return chunks[0];
+ const out = Buffer.allocUnsafe(n);
+ let filled = 0;
+ for (const c of chunks) {
+ filled += c.copy(out, filled, 0, Math.min(c.length, n - filled));
+ if (filled >= n) break;
+ }
+ return out;
+ };
+
+ /** Remove exactly `n` bytes from the front, into a buffer of their own. */
+ const take = (n: number): Buffer => {
+ // Its own allocation, never a slice of a chunk: a slice would pin that chunk, and through it the
+ // bytes of whatever it was read alongside, for as long as the payload lives.
+ const out = Buffer.allocUnsafe(n);
+ let filled = 0;
+ while (filled < n) {
+ const head = chunks[0];
+ const want = n - filled;
+ if (head.length <= want) {
+ head.copy(out, filled);
+ filled += head.length;
+ chunks.shift();
+ } else {
+ head.copy(out, filled, 0, want);
+ filled += want;
+ chunks[0] = head.subarray(want);
+ }
+ }
+ length -= n;
+ return out;
+ };
+
return {
push(chunk) {
// Copy the incoming chunk rather than adopting it: the socket's buffer is reused, and holding a
// reference to it across ticks is how a decoder starts reading bytes from a later read.
- buffer = buffer.length === 0 ? Buffer.from(chunk) : Buffer.concat([buffer, chunk]);
+ if (chunk.length > 0) { chunks.push(Buffer.from(chunk)); length += chunk.length; }
const frames: DecodedFrame[] = [];
for (;;) {
- if (buffer.length < FRAME_HEADER_BYTES) break;
- const declared = buffer.readUInt32BE(0);
+ if (length < FRAME_HEADER_BYTES) break;
+ const header = peek(FRAME_HEADER_BYTES);
+ const declared = header.readUInt32BE(0);
// A zero-length frame carries not even a kind byte; a huge one is a peer we cannot follow.
- if (declared < 1 || declared > MAX_FRAME_BYTES) {
+ if (declared < 1 || declared > limit) {
throw new Error(`link: refusing a frame of ${declared} bytes`);
}
const total = 4 + declared;
- if (buffer.length < total) break;
- const kind = buffer.readUInt8(4);
+ if (length < total) break;
+ const kind = header.readUInt8(4);
if (kind !== FrameKind.Json && kind !== FrameKind.Pty) {
throw new Error(`link: unknown frame kind ${kind}`);
}
- // Copy rather than subarray: the slice would pin the whole concatenated buffer alive, which for
- // a long-lived terminal stream means holding every chunk it was ever glued to.
- frames.push({ kind, payload: Buffer.from(buffer.subarray(FRAME_HEADER_BYTES, total)) });
- buffer = Buffer.from(buffer.subarray(total));
+ frames.push({ kind, payload: take(total).subarray(FRAME_HEADER_BYTES) });
}
return frames;
},
- get pending() { return buffer.length; },
+ get pending() { return length; },
+ get maxFrameBytes() { return limit; },
+ set maxFrameBytes(value: number) { limit = clampLimit(value); },
};
}
+function clampLimit(value: number | undefined): number {
+ return typeof value === 'number' && Number.isFinite(value) && value >= 1 ? Math.min(Math.floor(value), MAX_FRAME_BYTES) : MAX_FRAME_BYTES;
+}
+
/**
* Parse a JSON frame's payload into a message.
*
diff --git a/src/main/main.ts b/src/main/main.ts
index 9e16372..212896c 100644
--- a/src/main/main.ts
+++ b/src/main/main.ts
@@ -4,7 +4,7 @@ import { spawn } from 'node:child_process';
import { uptime, homedir, tmpdir } from 'node:os';
import { Store } from './store';
import { registerIpc } from './ipc';
-import { PtyHost, type PtySpawn } from './ptyHost';
+import { PtyHost, taskkillTree, withTreeKill, type PtyProcess, type PtySpawn } from './ptyHost';
import { setupTray } from './tray';
import { registerUpdater } from './updater';
import { applyOpenAtLogin } from './autostart';
@@ -35,7 +35,7 @@ try { nodePty = require('@homebridge/node-pty-prebuilt-multiarch') as NodePty; }
const realSpawn: PtySpawn = (file, args, opts) => {
if (!nodePty) throw new Error('node-pty native binding unavailable — embedded terminals need Windows (or a matching prebuilt)');
const p = nodePty.spawn(file, args, { name: 'xterm-256color', cwd: opts.cwd, cols: opts.cols, rows: opts.rows });
- return {
+ const proc: PtyProcess = {
pid: p.pid,
onData: (cb) => { p.onData(cb); },
onExit: (cb) => { p.onExit((e) => cb({ exitCode: e.exitCode })); },
@@ -43,6 +43,9 @@ const realSpawn: PtySpawn = (file, args, opts) => {
resize: (c, r) => p.resize(c, r),
kill: () => p.kill(),
};
+ // Closing a tile has to end what the agent started too (MCP servers, hidden dev servers), which
+ // node-pty's console-only kill leaves running on Windows — see withTreeKill.
+ return process.platform === 'win32' ? withTreeKill(proc, (pid) => taskkillTree(pid)) : proc;
};
const ptyHost = new PtyHost(realSpawn);
@@ -130,11 +133,11 @@ if (!gotLock) {
// render-process-gone / child-process-gone fire on `app`, not `process` — a renderer or GPU
// crash previously left zero trace anywhere (no log entry, no Windows crash event either,
// since Crashpad intercepts it before the OS's own crash reporting sees it).
- // On a renderer crash the recovery also reaps the now-ownerless ptys and reloads the window,
- // which restores the sessions from the persisted list (fresh ptys) instead of leaking the old ones.
+ // On a renderer crash the recovery reloads the window, which re-adopts the project sessions that
+ // are still running; only the internal login shells, which nothing re-adopts, are reaped.
installAppCrashHandlers(app, makeCrashRecovery({
log: (kind, detail) => logLine(`[${kind}] ${JSON.stringify(detail)}`),
- reapPtys: () => ptyHost.killAll(),
+ reapInternalPtys: () => ptyHost.killInternal(),
reloadWindow: () => { if (win && !win.isDestroyed()) win.webContents.reload(); },
}));
// Match the installer shortcut's AppUserModelID (electron-builder sets it to
@@ -171,18 +174,20 @@ if (!gotLock) {
logLine(`[shutdown] ${msg}`);
try { if (!w.isDestroyed()) w.webContents.send('devdeck:error', msg); } catch { /* renderer gone */ }
};
- const spawnShutdown = (args: string[]): void => {
+ const spawnShutdown = (args: string[], onFailed: (msg: string) => void = reportShutdownError): void => {
// args array + windowsHide; an exec error must surface, not silently strand an "issued" record.
const p = spawn('shutdown', args, { windowsHide: true, stdio: 'ignore' });
- p.on('error', (e) => reportShutdownError(`shutdown.exe failed to spawn: ${e.message}`));
+ p.on('error', (e) => onFailed(`shutdown.exe failed to spawn: ${e.message}`));
// error never fires for a clean spawn that FAILS (e.g. a shutdown already pending, policy denial)
// — that's a non-zero exit, and it means the countdown the user is watching will never fire.
- p.on('exit', (code) => { if (code !== null && code !== 0) reportShutdownError(`shutdown ${args[0]} exited with code ${code}`); });
+ p.on('exit', (code) => { if (code !== null && code !== 0) onFailed(`shutdown ${args[0]} exited with code ${code}`); });
};
shutdown = new ShutdownScheduler({
log: shutdownLog,
now: Date.now,
- execShutdown: (sec) => spawnShutdown(['/s', '/f', '/t', String(sec), '/c', 'DevDeck idle auto-shutdown']),
+ // A failed issue goes back to the scheduler (which reports it and leaves the countdown), not
+ // straight to the user: otherwise the UI keeps counting down to a shutdown the OS never took.
+ execShutdown: (sec, onFailed) => spawnShutdown(['/s', '/f', '/t', String(sec), '/c', 'DevDeck idle auto-shutdown'], onFailed),
execAbort: () => spawnShutdown(['/a']),
transcriptMtime: () => latestTranscriptMtime(path.join(homedir(), '.claude', 'projects')),
idleHoldMs: () => store.getShutdownIdleMinutes() * 60_000,
diff --git a/src/main/projects.ts b/src/main/projects.ts
index 6411805..7fc3ea7 100644
--- a/src/main/projects.ts
+++ b/src/main/projects.ts
@@ -12,6 +12,8 @@ export interface BuildDeps {
sessions: (projectPath: string) => Promise;
/** Reads the cue through the provider that OWNS the session — never the globally selected one. */
resumeCue: (projectPath: string, session: ProjectSession) => Promise;
+ /** How many sessions the project has in all, when it differs from the (capped) list above. */
+ sessionCount?: (projectPath: string) => Promise;
getEntry: (path: string) => StoreEntry;
}
@@ -41,16 +43,28 @@ async function mapLimit(items: T[], limit: number, fn: (item: T) => Promis
return out;
}
-export async function buildProjectList(deps: BuildDeps): Promise {
+/** The fields of a deck row that come from the user's own store entry rather than from disk and git. */
+type EntryFields = 'note' | 'pinned' | 'hidden' | 'lastOpened' | 'todos' | 'todosRevision' | 'noteRevision';
+/** A deck row before the store entry is laid over it: everything git and the session stores said. */
+export type GatheredProject = Omit;
+
+/**
+ * The expensive half of the deck list: git and the session stores, for every scanned project.
+ *
+ * Split from the store entries (below) so it can be SHARED: one refresh, a window-focus refresh on top
+ * of it and a paired machine polling the same deck all used to run every project's git commands and
+ * session reads side by side. What they share has to be the same for all of them, and the store entry
+ * is not — a pin toggled while a gather is running must show in the list that answers after it.
+ */
+export async function gatherProjects(deps: Omit): Promise {
const raw = await deps.scan();
- const models = await mapLimit(raw, ENRICH_CONCURRENCY,
- async (r): Promise => {
+ return mapLimit(raw, ENRICH_CONCURRENCY,
+ async (r): Promise => {
const git = await deps.git(r.path);
const sessions = await deps.sessions(r.path);
const cueText = sessions[0] ? await deps.resumeCue(r.path, sessions[0]) : null;
const lastSessionMs = sessions[0]?.mtimeMs ?? null;
const activityMs = maxMs(git.lastCommitMs, lastSessionMs);
- const entry = deps.getEntry(r.path);
return {
path: r.path,
name: r.name,
@@ -61,24 +75,58 @@ export async function buildProjectList(deps: BuildDeps): Promise sessions.length)) : sessions.length,
agentIds: providerOrderFromSessions(sessions),
activityMs,
stale: classifyStaleness(activityMs, deps.nowMs, deps.thresholds),
+ resumeCue: cueText ? ({ kind: 'lastMessage', text: cueText } satisfies ResumeCue) : null,
+ repoUrl: git.repoUrl,
+ };
+ });
+}
+
+/**
+ * Lay each project's store entry over what was gathered, as it is NOW, and sort (pins first).
+ * Builds new rows — the gathered ones may be shared with another caller — with the keys in the order
+ * the deck has always received them.
+ */
+export function withEntries(gathered: GatheredProject[], getEntry: BuildDeps['getEntry']): ProjectViewModel[] {
+ return gathered
+ .map((g): ProjectViewModel => {
+ const entry = getEntry(g.path);
+ return {
+ path: g.path,
+ name: g.name,
+ branch: g.branch,
+ uncommitted: g.uncommitted,
+ ahead: g.ahead,
+ lastCommitMs: g.lastCommitMs,
+ lastSubject: g.lastSubject,
+ lastSessionMs: g.lastSessionMs,
+ sessions: g.sessions,
+ sessionCount: g.sessionCount,
+ agentIds: g.agentIds,
+ activityMs: g.activityMs,
+ stale: g.stale,
note: entry.note,
pinned: entry.pinned,
hidden: entry.hidden,
lastOpened: entry.lastOpened,
- resumeCue: cueText ? ({ kind: 'lastMessage', text: cueText } satisfies ResumeCue) : null,
- repoUrl: git.repoUrl,
+ resumeCue: g.resumeCue,
+ repoUrl: g.repoUrl,
todos: entry.todos,
todosRevision: entry.todosRevision ?? 0,
+ noteRevision: entry.noteRevision ?? 0,
};
- });
-
- return models
+ })
.sort((a, b) => {
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
return (b.activityMs ?? -Infinity) - (a.activityMs ?? -Infinity);
});
}
+
+export async function buildProjectList(deps: BuildDeps): Promise {
+ return withEntries(await gatherProjects(deps), deps.getEntry);
+}
diff --git a/src/main/ptyHost.test.ts b/src/main/ptyHost.test.ts
index 19e15ae..5c3e4ea 100644
--- a/src/main/ptyHost.test.ts
+++ b/src/main/ptyHost.test.ts
@@ -1,5 +1,6 @@
import { describe, it, expect, vi } from 'vitest';
-import { PtyHost, type PtyProcess, type PtySpawn } from './ptyHost';
+import { EventEmitter } from 'node:events';
+import { PtyHost, MAX_SESSIONS, taskkillTree, withTreeKill, type PtyProcess, type PtySpawn } from './ptyHost';
function fakeProc() {
let dataCb: (d: string) => void = () => {};
@@ -252,3 +253,94 @@ describe('what a machine can say about its own sessions', () => {
expect(host.note('gone', { label: 'triage' })).toBe(false);
});
});
+
+describe('limits and crash recovery', () => {
+ it(`refuses project terminal number ${MAX_SESSIONS + 1} with a readable error — internal ones do not count`, () => {
+ const host = new PtyHost(() => fakeProc());
+ for (let i = 0; i < MAX_SESSIONS; i++) host.create(`p${i}`, 'pwsh', [], '.', 80, 24, () => {}, () => {});
+ expect(() => host.create('one-too-many', 'pwsh', [], '.', 80, 24, () => {}, () => {}))
+ .toThrow(`Too many terminals open (${MAX_SESSIONS}). Close some sessions first.`);
+ expect(() => host.create('login', 'pwsh', [], '.', 80, 24, () => {}, () => {}, { internal: true })).not.toThrow();
+ host.kill('p0');
+ expect(() => host.create('room-again', 'pwsh', [], '.', 80, 24, () => {}, () => {})).not.toThrow();
+ });
+
+ it("killInternal() reaps only DevDeck's own login shells and leaves project sessions running", () => {
+ const login = fakeProc(), project = fakeProc();
+ const host = new PtyHost(vi.fn().mockReturnValueOnce(login).mockReturnValueOnce(project));
+ host.create('usage-login:claude:1', 'pwsh', [], '.', 80, 24, () => {}, () => {}, { internal: true });
+ host.create('C:\repo#1', 'pwsh', [], '.', 80, 24, () => {}, () => {});
+ host.killInternal();
+ expect(login.kill).toHaveBeenCalledTimes(1);
+ expect(project.kill).not.toHaveBeenCalled();
+ expect(host.list().map((s) => s.id)).toEqual(['C:\repo#1']);
+ expect(host.pid('usage-login:claude:1')).toBeNull();
+ });
+});
+
+describe('Windows process-tree kill', () => {
+ it('runs the tree kill FIRST (while the shell still exists) and the pty kill only after it settles', async () => {
+ const order: string[] = [];
+ let release!: () => void;
+ const inner = fakeProc();
+ (inner.kill as ReturnType).mockImplementation(() => order.push('pty.kill'));
+ const wrapped = withTreeKill(inner, (pid) => { order.push(`taskkill ${pid}`); return new Promise((r) => { release = r; }); });
+ wrapped.kill();
+ wrapped.kill(); // a second close while the first is in flight does not start another
+ expect(order).toEqual(['taskkill 1234']);
+ release();
+ await new Promise((r) => setTimeout(r, 0));
+ expect(order).toEqual(['taskkill 1234', 'pty.kill']);
+ });
+
+ it('still closes the pty when the tree kill fails', async () => {
+ const inner = fakeProc();
+ const wrapped = withTreeKill(inner, () => Promise.reject(new Error('taskkill missing')));
+ wrapped.kill();
+ await new Promise((r) => setTimeout(r, 0));
+ expect(inner.kill).toHaveBeenCalledTimes(1);
+ });
+
+ it('forwards everything else to the real pty', () => {
+ const inner = fakeProc();
+ const wrapped = withTreeKill(inner, async () => {});
+ wrapped.write('x'); wrapped.resize(100, 30);
+ expect(wrapped.pid).toBe(1234);
+ expect(inner.write).toHaveBeenCalledWith('x');
+ expect(inner.resize).toHaveBeenCalledWith(100, 30);
+ });
+
+ it('shutdown still drains: exit callbacks fire once the deferred pty kill has run', async () => {
+ const inner = fakeProc();
+ (inner.kill as ReturnType).mockImplementation(() => inner.emitExit(1));
+ const host = new PtyHost(() => withTreeKill(inner, async () => {}));
+ const onExit = vi.fn();
+ host.create('a', 'pwsh', [], '.', 80, 24, () => {}, onExit);
+ await host.shutdown(1_000);
+ expect(onExit).toHaveBeenCalledWith({ exitCode: 1 });
+ });
+
+ it('taskkillTree spawns `taskkill /PID /T /F` hidden and resolves when it exits', async () => {
+ const child = new EventEmitter();
+ const spawnFn = vi.fn(() => child);
+ const done = taskkillTree(4321, spawnFn as never, 1_000);
+ expect(spawnFn).toHaveBeenCalledWith('taskkill', ['/PID', '4321', '/T', '/F'], { windowsHide: true, stdio: 'ignore' });
+ child.emit('exit', 0);
+ await expect(done).resolves.toBeUndefined();
+ });
+
+ it('taskkillTree gives up after its timeout instead of holding the close forever', async () => {
+ vi.useFakeTimers();
+ try {
+ const done = taskkillTree(4321, (() => new EventEmitter()) as never, 3_000);
+ await vi.advanceTimersByTimeAsync(3_000);
+ await expect(done).resolves.toBeUndefined();
+ } finally { vi.useRealTimers(); }
+ });
+
+ it('taskkillTree does nothing for a pid it cannot trust', async () => {
+ const spawnFn = vi.fn();
+ await taskkillTree(0, spawnFn as never);
+ expect(spawnFn).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/main/ptyHost.ts b/src/main/ptyHost.ts
index 8c3d28f..bfa29f3 100644
--- a/src/main/ptyHost.ts
+++ b/src/main/ptyHost.ts
@@ -1,4 +1,5 @@
import { randomUUID } from 'node:crypto';
+import { spawn as spawnChild } from 'node:child_process';
import { withTimeout } from '../shared/withTimeout';
export interface PtyProcess {
@@ -63,6 +64,66 @@ export interface PtySessionInfo {
*/
const SCROLLBACK_BYTES = 256 * 1024;
+/**
+ * The most project terminals this machine will run at once. Each one is a real agent process
+ * holding memory and quota, and the restore path has run away before — one bug opened 50 claude
+ * processes in a loop and froze the host. Far past any real working set; a refusal there is a
+ * readable error instead of a machine that stops responding. Internal terminals do not count.
+ */
+export const MAX_SESSIONS = 32;
+
+/** How long `taskkill` may take before the plain kill goes ahead without it. */
+const TREE_KILL_TIMEOUT_MS = 3_000;
+
+export type TreeKiller = (pid: number) => Promise;
+
+/**
+ * `taskkill /PID /T /F`: end a Windows process AND everything it started. Never rejects —
+ * however it ends (killed, nothing to kill, taskkill missing, too slow), the caller carries on.
+ */
+export function taskkillTree(pid: number, spawnFn: typeof spawnChild = spawnChild, timeoutMs = TREE_KILL_TIMEOUT_MS): Promise {
+ return new Promise((resolve) => {
+ let done = false;
+ const finish = (): void => { if (!done) { done = true; clearTimeout(timer); resolve(); } };
+ const timer = setTimeout(finish, timeoutMs);
+ if (!Number.isInteger(pid) || pid <= 0) { finish(); return; }
+ try {
+ const p = spawnFn('taskkill', ['/PID', String(pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' });
+ p.once('error', finish);
+ p.once('exit', finish);
+ } catch { finish(); }
+ });
+}
+
+/**
+ * Make a pty's kill() take the shell's whole process tree, not only what is attached to its console.
+ *
+ * node-pty's Windows kill() ends the processes in the pseudoconsole's process list and closes it.
+ * An agent's own children that were started hidden or detached — MCP servers, a dev server it ran
+ * with Start-Process, anything spawned with windowsHide — are not in that list and outlive the tile,
+ * holding ports and files. `taskkill /T` walks the parent chain instead, so it has to run FIRST,
+ * while the shell still exists to walk from; the original kill() follows once it is done (or has
+ * timed out), so the pseudoconsole is always closed and node-pty's exit still fires.
+ */
+export function withTreeKill(proc: PtyProcess, killTree: TreeKiller): PtyProcess {
+ let killing = false;
+ return {
+ get pid() { return proc.pid; },
+ onData: (cb) => proc.onData(cb),
+ onExit: (cb) => proc.onExit(cb),
+ write: (d) => proc.write(d),
+ resize: (c, r) => proc.resize(c, r),
+ kill: () => {
+ if (killing) return;
+ killing = true;
+ const pid = proc.pid;
+ void killTree(pid).catch(() => { /* the plain kill below still runs */ }).then(() => {
+ try { proc.kill(); } catch (error) { console.error('DevDeck: terminal close failed', error); }
+ });
+ },
+ };
+}
+
interface Session {
/**
* The size the pty is at right now — and therefore the size every byte in `chunks` was drawn for.
@@ -105,6 +166,9 @@ export class PtyHost {
): void {
if (this.stopping) throw new Error('Terminals are shutting down');
if (this.sessions.has(id)) throw new Error(`Terminal already exists: ${id}`);
+ if (info?.internal !== true && this.list().length >= MAX_SESSIONS) {
+ throw new Error(`Too many terminals open (${MAX_SESSIONS}). Close some sessions first.`);
+ }
const proc = this.spawn(file, args, { cwd, cols, rows });
let exited!: () => void;
const exit = new Promise((resolve) => { exited = resolve; });
@@ -235,6 +299,13 @@ export class PtyHost {
try { this.kill(id); } catch (error) { console.error('DevDeck: terminal close failed', error); }
}
}
+ /** Kill only the terminals DevDeck opened for itself (provider logins), leaving project sessions running. */
+ killInternal(): void {
+ for (const [id, s] of [...this.sessions]) {
+ if (!s.internal) continue;
+ try { this.kill(id); } catch (error) { console.error('DevDeck: terminal close failed', error); }
+ }
+ }
/** Drain native exit callbacks while Electron's Node environment is still alive. */
shutdown(timeoutMs = 5_000): Promise {
diff --git a/src/main/sessionMeta.test.ts b/src/main/sessionMeta.test.ts
index 636576a..84be4a2 100644
--- a/src/main/sessionMeta.test.ts
+++ b/src/main/sessionMeta.test.ts
@@ -2,8 +2,9 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { appendFileSync, mkdirSync, mkdtempSync, rmSync, statSync, utimesSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
-import { readClaudeSessionMeta, emptySessionMeta } from './sessionMeta';
+import { readClaudeSessionMeta, emptySessionMeta, _cachedTimestampCount } from './sessionMeta';
import { encodeProjectPath } from '../shared/paths';
+import { activeMsFromTimestamps } from '../shared/usage';
describe('readClaudeSessionMeta', () => {
it('rejects a traversal sessionId — no path escape (returns an empty meta)', async () => {
@@ -64,6 +65,22 @@ describe('readClaudeSessionMeta over an append-only log', () => {
expect(second.activeMs).toBe(4 * 60_000);
});
+ it('two reads of the same grown log at once fold the appended lines once, not twice', async () => {
+ // The cockpit tick and a paired machine's poll ask about the same session concurrently; both
+ // used to resume from the one cached state and append the same lines to it.
+ writeFileSync(file, `${userLine('start', 0)}
+`);
+ await read();
+ let tail = '';
+ for (let i = 1; i <= 500; i++) tail += `${asstLine(`step ${i}`, i % 50, 'claude-opus-5', i)}
+`;
+ append(tail);
+ const [a, b] = await Promise.all([read(), read()]);
+ expect(a).toEqual(b);
+ expect(a).toMatchObject({ contextTokens: 500, assistantText: 'step 500' });
+ expect(_cachedTimestampCount(file)).toBe(501);
+ });
+
it('never consumes a half-written last line, and reads it once the rest lands', async () => {
const whole = asstLine('Whole line', 3, 'claude-fable-5', 999);
const cut = Math.floor(whole.length / 2);
@@ -97,6 +114,29 @@ describe('readClaudeSessionMeta over an append-only log', () => {
expect(await read()).toMatchObject({ model: 'claude-opus-5', contextTokens: 42 });
});
+ // Active time is tallied as the log is read forward instead of re-sorting every timestamp each tick.
+ // Whatever order lines land in — including one far back, before most of the log —
+ // it must equal the batch answer over the whole log.
+ it('keeps active time exact across resumed reads, even for a line far out of order', async () => {
+ const t0 = Date.UTC(2026, 7, 4, 9);
+ const stamps: number[] = [];
+ const line = (ms: number) => { stamps.push(ms); return JSON.stringify({ type: 'user', timestamp: new Date(ms).toISOString(), message: { content: 'x' } }); };
+ const lines: string[] = [];
+ for (let i = 0; i < 1500; i++) lines.push(line(t0 + i * 170_000 - (i % 13 === 0 ? 200_000 : 0)));
+ writeFileSync(file, lines.join('\n') + '\n');
+ let tick = Date.now();
+ // Each append gets its own mtime: two landing in the same millisecond are, rightly, a cache hit.
+ const grow = (extra: string) => { appendFileSync(file, extra); tick += 5000; utimesSync(file, new Date(tick), new Date(tick)); };
+ expect((await read()).activeMs).toBe(activeMsFromTimestamps(stamps));
+ grow(line(t0 + 1500 * 170_000) + '\n');
+ expect((await read()).activeMs).toBe(activeMsFromTimestamps(stamps));
+ grow(line(t0 + 1500 * 170_000 - 90_000) + '\n'); // slightly late
+ expect((await read()).activeMs).toBe(activeMsFromTimestamps(stamps));
+ grow(line(t0 + 10_000) + '\n'); // days before most of the log (a clock set back)
+ expect((await read()).activeMs).toBe(activeMsFromTimestamps(stamps));
+ expect(_cachedTimestampCount(file)).toBe(stamps.length);
+ });
+
it('handles a multi-byte character split across a read boundary', async () => {
// 4 MiB read chunk: pad the first line past it so the next line's CJK text straddles the boundary.
const pad = 'x'.repeat(4 * 1024 * 1024);
diff --git a/src/main/sessionMeta.ts b/src/main/sessionMeta.ts
index 30c3bcc..6d220fe 100644
--- a/src/main/sessionMeta.ts
+++ b/src/main/sessionMeta.ts
@@ -7,6 +7,7 @@ import {
type ParsedSessionMeta, type SessionMetaState,
} from '../shared/sessionMeta';
import { isValidSessionId } from './sessions';
+import { cloneActiveTally, emptyActiveTally, tallyActiveSince, tallyStamp, type ActiveTally } from '../shared/usage';
/** Parsed meta plus the log's mtime — the cache key for the AI summary and the task-staleness reference. */
export type ClaudeSessionMeta = ParsedSessionMeta & { mtimeMs: number };
@@ -24,8 +25,18 @@ const NEWLINE = 0x0a;
* Per-session parse state, resumed across reads. `offset` is the byte just past the last COMPLETE line
* consumed so far, so an append-only log is only ever read forward — the cockpit's 30s tick on an
* active session costs the few KB that turn wrote, not the whole log again.
+ *
+ * Active time rides in its own `tally` rather than in `state.timestamps`. The shared parser collects
+ * every line's timestamp and its finalize copies and sorts them all, so a tick on a live 550 MB session
+ * (~50,000 lines) did that — plus a copy of the whole array to resume into — for the sake of the few
+ * lines the turn added, every tile, every tick. Each read now moves the timestamps it parsed into the
+ * tally (kept in order, the sum kept as it goes) and empties the array, so a tick costs the lines it
+ * added plus one copy of the tally to fold into — no sort.
*/
-const _metaCache = new Map();
+interface MetaCacheEntry { mtimeMs: number; offset: number; state: SessionMetaState; tally: ActiveTally; meta: ClaudeSessionMeta }
+const _metaCache = new Map();
+/** Test-only: how many line timestamps the cached state for this log has counted (each line once). */
+export function _cachedTimestampCount(file: string): number { return _metaCache.get(file)?.tally.sorted.length ?? 0; }
/**
* Fold a log's bytes from `start` into `state`, stopping at the last complete line.
@@ -52,7 +63,7 @@ const _metaCache = new Map {
+async function foldFrom(handle: FileHandle, start: number, size: number, state: SessionMetaState, tally: ActiveTally): Promise {
let pos = start;
let carry = Buffer.alloc(0); // bytes after the last newline seen — an incomplete line
while (pos < size) {
@@ -65,18 +76,36 @@ async function foldFrom(handle: FileHandle, start: number, size: number, state:
const nl = data.lastIndexOf(NEWLINE);
if (nl < 0) { carry = Buffer.from(data); continue; } // one line longer than a chunk — keep reading
advanceSessionMeta(state, data.subarray(0, nl).toString('utf8'));
+ // Every line timestamp this chunk produced goes into the tally; the parser's own array stays empty.
+ for (const ts of state.timestamps) tallyStamp(tally, ts);
+ state.timestamps.length = 0;
carry = Buffer.from(data.subarray(nl + 1));
- // Parsing a 4 MB chunk is itself long enough to drop frames; hand the loop back between them.
+ // Parsing a chunk is itself long enough to drop frames; hand the loop back between them.
await yieldToLoop();
}
return pos - carry.length; // the trailing partial line is NOT consumed — it may still be being written
}
+/**
+ * Reads in progress, by file. The same session is routinely asked about twice at once — this
+ * machine's cockpit tick and a paired machine polling it — and both used to resume from the one
+ * cached state, folding the same appended lines into it twice. A second caller now waits for the
+ * first and is answered from what it cached.
+ */
+const _reading = new Map>();
+
/** Read a Claude session's meta from its on-disk .jsonl (best-effort; an empty meta if missing). */
export async function readClaudeSessionMeta(projectPath: string, sessionId: string, claudeProjectsDir: string): Promise {
// Guard the id before it touches a path — a crafted sessionId must not escape ~/.claude/projects (path traversal).
if (!isValidSessionId(sessionId)) return emptySessionMeta();
const file = join(claudeProjectsDir, encodeProjectPath(projectPath), sessionId + '.jsonl');
+ for (let inFlight = _reading.get(file); inFlight; inFlight = _reading.get(file)) await inFlight.catch(() => undefined);
+ const work = readMetaFile(file);
+ _reading.set(file, work);
+ try { return await work; } finally { if (_reading.get(file) === work) _reading.delete(file); }
+}
+
+async function readMetaFile(file: string): Promise {
let mtimeMs: number;
let size: number;
try { ({ mtimeMs, size } = await stat(file)); } catch { return emptySessionMeta(); }
@@ -87,18 +116,24 @@ export async function readClaudeSessionMeta(projectPath: string, sessionId: stri
// shrank was rewritten, not appended to, and the state carried over would describe a file that is
// no longer there — reparse from zero.
const resume = cached && size >= cached.offset;
- const state = resume ? cached.state : emptySessionMetaState();
+ // Copies, so a read that fails partway leaves the cached state exactly as it was committed.
+ const state = resume ? copyState(cached.state) : emptySessionMetaState();
+ const tally = resume ? cloneActiveTally(cached.tally) : emptyActiveTally();
let handle: FileHandle;
try { handle = await open(file, 'r'); } catch { return emptySessionMeta(); }
let offset: number;
try {
- offset = await foldFrom(handle, resume ? cached.offset : 0, size, state);
+ offset = await foldFrom(handle, resume ? cached.offset : 0, size, state, tally);
} catch {
return emptySessionMeta(); // leave the cache alone: a later read can still resume from it
} finally {
await handle.close().catch(() => { /* already gone */ });
}
- const meta = { ...finalizeSessionMeta(state), mtimeMs };
- _metaCache.set(file, { mtimeMs, offset, state, meta });
+ const meta = { ...finalizeSessionMeta(state), activeMs: tallyActiveSince(tally, -Infinity), mtimeMs };
+ _metaCache.set(file, { mtimeMs, offset, state, tally, meta });
return meta;
}
+
+function copyState(s: SessionMetaState): SessionMetaState {
+ return { ...s, timestamps: [...s.timestamps], edited: [...s.edited] };
+}
diff --git a/src/main/sessionScan.ts b/src/main/sessionScan.ts
index 1f2b669..18c518b 100644
--- a/src/main/sessionScan.ts
+++ b/src/main/sessionScan.ts
@@ -31,11 +31,15 @@ export interface SessionScanDeps {
perProject: Partial Promise>>;
/** Flat stores (Codex, Antigravity): indexed once, then looked up by canonical cwd key. */
indexed: Partial Map | Promise