From 6bbd75a36e29d026b6cb3a7fa932dec645818cb7 Mon Sep 17 00:00:00 2001 From: xNet Test Date: Fri, 31 Jul 2026 06:48:42 -0700 Subject: [PATCH 1/6] docs(exploration): explore worktree-scoped Electron dev for agent fleets 0404 wired the desktop prototyping loop for a single seat. This documents what is still asymmetric versus the web loop: the Electron dev environment is a machine-wide singleton (one SingletonLock, one userData, fixed 5177/9223) while the repo is worked across 87 git worktrees. Recommends deriving the profile and a ten-port block from the worktree, publishing provenance on window.__xnetDev, bridging main and data-process logs into the renderer console, and making port/lock collisions loud. Co-Authored-By: Claude Opus 5 Signed-off-by: xNet Test --- ...AL_ELECTRON_WORKTREE_SCOPED_DESKTOP_DEV.md | 832 ++++++++++++++++++ 1 file changed, 832 insertions(+) create mode 100644 docs/explorations/0413_[_]_PLURAL_ELECTRON_WORKTREE_SCOPED_DESKTOP_DEV.md diff --git a/docs/explorations/0413_[_]_PLURAL_ELECTRON_WORKTREE_SCOPED_DESKTOP_DEV.md b/docs/explorations/0413_[_]_PLURAL_ELECTRON_WORKTREE_SCOPED_DESKTOP_DEV.md new file mode 100644 index 000000000..137371e28 --- /dev/null +++ b/docs/explorations/0413_[_]_PLURAL_ELECTRON_WORKTREE_SCOPED_DESKTOP_DEV.md @@ -0,0 +1,832 @@ +--- +title: Plural Electron — Making The Desktop Loop Survive A Fleet Of Agents +status: draft +last_updated: 2026-07-31 +tags: [electron, agents, dx, tooling, testing] +--- + +# Plural Electron — Making The Desktop Loop Survive A Fleet Of Agents + +> [!TIP] +> **TL;DR** — [0404](<0404_[-]_ELECTRON_PROTOTYPING_LOOP_FOR_AGENTS.md>) fixed the +> desktop loop for **one agent, on one tree, running one app**. That is not how +> this repo is worked: `git worktree list` returns **87 trees, 41 of them agent +> worktrees**. The web loop is _plural_ — every worktree gets its own port and the +> URL says which tree you are looking at. The Electron loop is a **machine-wide +> singleton**: one `SingletonLock`, one `userData`, hard-coded `5177`/`9223`, and +> nothing the running app publishes says which tree built it. Right +> now a dev Electron from the **main checkout** owns 9223; an agent in this +> worktree that followed `electron-prototype` to the letter would attach to it, +> drive someone else's branch, and report success. Fix four things — **derive the +> profile and a port block from the worktree**, **publish provenance on +> `window.__xnetDev`**, **bridge main + data-process logs into the renderer +> console**, and **make port and lock collisions loud** — and the desktop loop +> becomes as cheap and as safe as the web one. No new MCP server, no browser shim, +> no runtime migration. + +## Problem Statement + +The root [AGENTS.md](../../AGENTS.md) and +[`apps/electron/AGENTS.md`](../../apps/electron/AGENTS.md) both say desktop is the +**primary** target. Practice still favours the browser. 0404 diagnosed that as a +wiring problem and fixed it: the `playwright-electron` MCP server is registered, +`.claude/launch.json` has renderer entries, the native rebuild is stamped, and +`.claude/skills/electron-prototype/SKILL.md` teaches a three-rung ladder. + +All of that landed. The preference did not move. + +This exploration asks the next question: **with the loop wired, what is still +different about driving the desktop app?** The answer is not fidelity or tooling. +It is _cardinality_. Every remaining defect is a consequence of the desktop dev +environment being singular while the way this repo is actually worked is plural. + +## Executive Summary + +- **The scale is 87 worktrees.** `git worktree list` in `/Users/crs/Code/xNet` + returns 87 entries, 41 under `.claude/worktrees`. Agents work in parallel trees + as the normal case, not the exception. +- **The web loop scales by construction; the Electron loop does not.** + `.claude/launch.json` carries **nine** distinct web ports beyond the default and + exactly **one** worktree-scoped Electron slot — hand-cut, hard-coding + `VITE_PORT=5178 ELECTRON_CDP_PORT=9225`. There is one slot because there is only + ever one seat. +- **The collision is live right now.** CDP on `127.0.0.1:9223` answers as + `xnet-desktop/3.0.0 … Electron/33.4.11`, exposing one target: + `page | xNet | http://localhost:5177/`. Its process (pid 32967) has cwd + `/Users/crs/Code/xNet/apps/electron` — the **main checkout, branch `main`**. Not + this worktree. +- **The documented mitigation cannot discriminate.** The skill says _"the profile + name in the title bar is the cheapest signal."_ + [`index.ts`](../../apps/electron/src/main/index.ts) sets + `profile === 'default' ? 'xNet' : \`xNet (${profile})\``. Both the incumbent and + the newcomer are `default`. Both are titled `xNet`. The signal is constant in + exactly the case it exists to detect. +- **The app publishes no provenance at all.** No `define:` for a commit or branch, + no build stamp, nothing on `window`. An attached agent cannot answer "which tree + is this?" by any means the tool it was told to use provides. +- **Failures are silent, which the repo's own rules forbid.** Losing the + single-instance lock runs `app.quit()` at + [`index.ts:158`](../../apps/electron/src/main/index.ts) with no log and **exit + 0**. The renderer's Vite server has **no `strictPort`**, so a taken 5177 silently + becomes 5178 while every doc still says 5177. AGENTS.md: _"a catch, default, or + coercion that returns a value callers cannot distinguish from success is a bug, + not a guard."_ +- **CDP shows one process out of three.** The agent's only channel is the + renderer. `main/` and `data-process/` write to the `pnpm dev` stdout, which a + CDP-attached agent never reads. A boot failure in main is invisible. +- **Nobody cleans up.** 18 `~/Library/Application Support/xnet-desktop-*` + directories survive, from e2e runs and Codex sessions that ended months ago. + +--- + +## Current State In The Repository + +### What 0404 actually delivered + +| Item | Status | Evidence | +| ---------------------------------------- | -------------- | ------------------------------------------------------------------------ | +| `playwright-electron` MCP registered | ✅ Shipped | [`.mcp.json`](../../.mcp.json) — `--cdp-endpoint http://127.0.0.1:9223` | +| Renderer launch entries | ✅ Shipped | `electron-renderer (vite 5177)` in `.claude/launch.json` | +| Stamped native rebuild | ✅ Shipped | [`scripts/rebuild-if-stale.mjs`](../../apps/electron/scripts/rebuild-if-stale.mjs) | +| `dev:user2` gets its own CDP port | ✅ Shipped | `ELECTRON_CDP_PORT=9224` in `package.json` | +| Prod bundle has no debugging port | ✅ Shipped | [`cdp-dev-only.test.ts`](../../apps/electron/src/main/cdp-dev-only.test.ts) | +| `electron-prototype` skill | ✅ Shipped | 106 lines, three rungs | +| **Multi-tree isolation** | ❌ Not scoped | 0404 assumed one instance | +| **Instance provenance** | ❌ Not scoped | mitigation is prose, not a probe | +| **Main-process observability** | ❌ Not scoped | CDP is renderer-only | + +0404 was right about everything it looked at. It looked at a single seat. + +### The singleton, in four files + +```mermaid +flowchart TD + subgraph MACHINE["one macOS user account"] + LOCK["~/Library/Application Support/xnet-desktop/
SingletonLock → host-32967"] + UD["userData: xnet-desktop/
SQLite, localStorage, cookies"] + P5177["TCP 5177 — renderer (no strictPort)"] + P9223["TCP 9223 — CDP"] + P4444["TCP 4444 — hub"] + P31415["TCP 31415 — local API"] + end + + WT0["main checkout
branch: main"] -->|owns all| LOCK + WT0 --> UD + WT0 --> P5177 + WT0 --> P9223 + + WT1["worktree #1"] -.->|app.quit(), exit 0| LOCK + WT2["worktree #2"] -.->|app.quit(), exit 0| LOCK + WTN["…41 agent worktrees"] -.->|app.quit(), exit 0| LOCK + + AGENT["agent in worktree #1
playwright-electron → :9223"] ==>|attaches to| P9223 + P9223 -.->|is actually| WT0 + + style WT0 fill:#1f6feb,color:#fff + style AGENT fill:#b62324,color:#fff + style LOCK fill:#7a5901,color:#fff +``` + +Every arrow into `LOCK` from a worktree is a silent exit. The thick arrow is the +bug: the agent attaches successfully, to the wrong app. + +
+Raw evidence gathered on 2026-07-31 + +```console +$ curl -s http://127.0.0.1:9223/json/version +{ "Browser": "Chrome/130.0.6723.191", + "User-Agent": "… xnet-desktop/3.0.0 Chrome/130.0.6723.191 Electron/33.4.11 …" } + +$ curl -s http://127.0.0.1:9223/json/list +page | xNet | http://localhost:5177/ + +$ lsof -p 32967 | grep cwd +Electron 32967 crs cwd DIR /Users/crs/Code/xNet/apps/electron # main checkout + +$ ls -la ~/Library/Application\ Support/xnet-desktop/SingletonLock +SingletonLock -> Chriss-MacBook-Pro-3.local-32967 + +$ ls -d ~/Library/Application\ Support/xnet-desktop* | wc -l +18 + +$ git worktree list | wc -l +87 +$ git worktree list | grep -c '.claude/worktrees' +41 + +$ curl -s http://127.0.0.1:31415/ +{"error":"Unauthorized"} + +$ grep -n strictPort apps/electron/electron.vite.config.ts +(no match) +``` + +The exploration's own worktree has **zero `node_modules`**, so the second-instance +exit was read from source rather than re-run — see +[Risks](#risks-and-open-questions). + +
+ +### The asymmetry, service by service + +| Resource | Web loop | Electron loop | +| ---------------- | --------------------------------------------- | -------------------------------------------------- | +| Dev server port | per-worktree entry, explicit `--strictPort` | `5177`, **no** `strictPort` → silent drift | +| Driving endpoint | the tab you opened; URL names the port | `9223`, global, first-come | +| App state | OPFS, keyed by origin ⇒ keyed by port | one `userData`; profiles exist but are opt-in | +| Mutual exclusion | none needed | machine-wide `SingletonLock` | +| Provenance | the URL **is** the identity | none | +| Collision result | Vite prints the port it actually took | `app.quit()`, **exit 0**, no message | +| Process coverage | one process; console is the whole app | 3 processes; CDP sees 1 | +| Cleanup | kill the server, nothing persists | 18 orphaned `userData` dirs | + +> [!IMPORTANT] +> The web loop never needed an isolation design because **a port is already an +> identity**. Electron has four ports, a lock, and a state directory, and none of +> them are derived from the thing that actually varies — the worktree. + +### The infrastructure that already exists + +This is the encouraging part. Nearly every primitive needed is already in the +tree, applied to a narrower problem: + +| Primitive | Exists for | Wants to be | +| -------------------------------------------------------------------------------- | -------------------------------------- | ---------------------------------------- | +| [`local-api-config.ts`](../../apps/electron/src/main/local-api-config.ts) `stableProfileOffset()` | one port, from `XNET_PROFILE` | **all** ports, from the worktree | +| [`profile.ts`](../../apps/electron/src/main/profile.ts) | `XNET_PROFILE=user2` opt-in | defaulted from the worktree | +| [`cdp-dev-only.test.ts`](../../apps/electron/src/main/cdp-dev-only.test.ts) | asserting prod has no debug port | the template for a dev-only provenance guard | +| `window.__xnetNodeStore` / `__xnetSchemaIds` | e2e specs | the same channel, carrying dev metadata | +| [`data-process-manager.ts:113-119`](../../apps/electron/src/main/data-process-manager.ts) | piping child stdio to stderr | piping it somewhere CDP can read | + +`stableProfileOffset()` is the standout — the repo already wrote a +hash-string-to-stable-port-offset helper and used it for exactly one of the four +ports that need it. + +--- + +## External Research + +### Prior art on isolating parallel desktop dev instances + +**VS Code is the canonical case** and settled it years ago: `--user-data-dir` plus +`--extensions-dir` per instance, with `--profile` as the sanctioned route for +running a second isolated instance. The lesson that transfers is that isolating +**only** the config directory is not enough — data, credentials and caches live +elsewhere and will be shared unless they move too. In our terms: setting +`XNET_PROFILE` is necessary and sufficient for `userData`, but does nothing for +the four TCP ports, which is exactly the half-fix we shipped. + +**Electron's single-instance lock is Chromium's**, keyed on the `userData` folder +([electron#29068](https://github.com/electron/electron/issues/29068)). This is good +news: `app.setPath('userData', …)` before `requestSingleInstanceLock()` — which +[`profile.ts`](../../apps/electron/src/main/profile.ts) already does at module +scope — gives each profile its own lock for free. The mechanism is already +correct; it is just never engaged, because the default profile is `default`. + +**Sandboxed parallel desktop dev is an emerging agent pattern.** The +`dev-desktop-sandbox` skill circulating in 2026 launches each desktop instance +under its own temporary root with automatically selected free ports, explicitly so +that instances "do not share state or ports with the host or other instances." +Same diagnosis, same prescription. + +### Electron MCP servers, re-surveyed + +0404 rejected two servers on maturity. One new entrant has appeared since: + +| Server | Stars | Licence | Attaches or launches? | Verdict | +| -------------------------------------------------------------------------------- | ----- | ------- | ---------------------- | --------------- | +| [`microsoft/playwright-mcp`](https://github.com/microsoft/playwright-mcp) `--cdp-endpoint` | ~35.5k | Apache-2.0 | **attaches** | ✅ In use | +| [`mesomya/electron-driver`](https://github.com/mesomya/electron-driver) | 3 | MIT | **launches** (`start_app`) | 🛑 Reject | +| [`kanishka-namdeo/electron-mcp`](https://github.com/kanishka-namdeo/electron-mcp) | 1 | — | mixed | 🛑 Reject (0404) | +| [`lazy-dinosaur/electron-test-mcp`](https://github.com/lazy-dinosaur/electron-test-mcp) | 0 | none | mixed | 🛑 Reject (0404) | + +> [!NOTE] +> `electron-driver` is worth reading even though we should not install it. Its 38 +> tools include `eval_main` and console capture **from both the renderer and the +> main process** — independent confirmation that main-process blindness is the +> gap people actually hit. It launches the app rather than attaching, which makes +> it a rung-3 tool wearing rung-2 clothes: it cannot drive the HMR session you +> already have open. We should copy the capability, not the dependency. + +### Main-process observability + +[`electron-log`](https://github.com/megahertz/electron-log)'s **IPC transport** is +the relevant prior art: it forwards main-process log records over IPC so they +surface in the renderer's DevTools console, precisely because "a `console.log` in +the main process won't appear in the renderer's DevTools, and vice versa." That is +the whole fix, and it needs no new agent-facing tool — the agent already reads +`browser_console_messages`. + +The one subtlety the library does not solve for us: records emitted **before a +window exists** have nowhere to go. Boot failures are the ones that matter most, +so a buffer-then-flush is required, not a plain forward. + +--- + +## Key Findings + +### 1. The preference for the browser is partly correct, and we should say so + +Not all of the pull toward the web app is a defect to be engineered away. For a +change that would render identically in Storybook, rung 1 **is** the cheapest +correct loop, and the skill already says so. Any honest fix has to concede that +band rather than try to win it. + +The unjustified part is everything downstream of the four defects below. Those +convert "desktop is a bit slower" into "desktop is unsafe to drive in parallel," +and that is what pushes work off the surface entirely. + +### 2. Attaching to the wrong app is a success-shaped failure + +```mermaid +sequenceDiagram + participant A as Agent (worktree #1) + participant S as electron-prototype skill + participant D as :9223 CDP + participant W0 as Electron from main checkout + participant W1 as Electron from worktree #1 + + A->>S: read skill + S-->>A: "pnpm dev, then attach to :9223" + A->>W1: pnpm dev + W1->>W1: requestSingleInstanceLock() → false + W1->>W1: app.quit() + W1-->>A: exit 0, no message + Note over A: reads exit 0 as success + A->>D: browser_snapshot + D->>W0: Accessibility.getFullAXTree + W0-->>A: a real, healthy desktop tree + Note over A: title "xNet", profile default —
indistinguishable from its own + A->>A: reports the change verified ✗ +``` + +Every individual step succeeds. The exit code is 0, the CDP handshake is clean, +the accessibility tree is real, the title matches what the skill told the agent to +expect. There is no observation available to the agent that would reveal the +error. This is the class of bug AGENTS.md names outright — a failure the caller +cannot distinguish from success — sitting in the tooling rather than the product. + +### 3. Four ports, one derivation, zero uses of it + +`resolveLocalAPIPort()` already does the right thing: + +```ts +// apps/electron/src/main/local-api-config.ts +if (profile === 'default') return DEFAULT_LOCAL_API_PORT +return DEFAULT_LOCAL_API_PORT + stableProfileOffset(profile) +``` + +The renderer port, the CDP port and the hub port get none of this. They are +`process.env.VITE_PORT || '5177'`, `process.env.ELECTRON_CDP_PORT || '9223'`, and +a constant. The generalisation is mechanical. + +### 4. CDP is one third of an observability story + +| Process | Where its output goes | Reachable by an attached agent? | +| -------------- | -------------------------------- | -------------------------------------------- | +| renderer | CDP console domain | ✅ `browser_console_messages` | +| main | `pnpm dev` stdout (13 call sites)| ❌ unless the agent also owns the terminal | +| data-process | inherited stdio → same stdout | ❌ same | + +There is no log file — `app.getPath('logs')` is unused. If the agent started the +app with `preview_start`, `preview_logs` recovers stdout, and that is the one path +that works today. But it is a *second* channel with different semantics, it is +absent whenever a human started the app, and nothing in the skill mentions it. + +### 5. Ports drift silently because nobody asked for `strictPort` + +[`electron.vite.config.ts`](../../apps/electron/electron.vite.config.ts) sets +`port: rendererPort` and no `strictPort`. Vite's default is to increment. So a +second instance's renderer quietly lands on 5178 while `AGENTS.md`, the skill, and +`.claude/launch.json` all still say 5177. The app works; every document about it +is now wrong. Compare the web entries, which pass `--strictPort` explicitly. + +### 6. State accumulates and nothing collects it + +18 `xnet-desktop-*` directories, including `xnet-desktop-e2e-canvas-1782604608089` +and five `codex-*` profiles. Timestamped e2e profiles are created per run and +never removed. This is small in bytes and large in confusion: it makes "which +profile is real?" a research task. + +--- + +## Options And Tradeoffs + +| Option | Effort | Fixes cardinality | Fixes provenance | Fixes main-process | Verdict | +| ------------------------------------------------------------ | ------ | ----------------- | ---------------- | ------------------ | ---------------- | +| **A** — Do nothing; 0404 is enough | 0 | ❌ | ❌ | ❌ | 🛑 Rejected | +| **B** — Document the hazard harder in the skill | S | ❌ | ❌ | ❌ | 🛑 Rejected | +| **C** — Install `electron-driver` MCP | S | ❌ | ❌ | ✅ | 🛑 Rejected | +| **D** — Build a bespoke xNet Electron MCP server | L | ⚠️ possible | ✅ | ✅ | 🛑 Rejected | +| **E** — Browser shim for the renderer | M | ✅ trivially | n/a | n/a | 🛑 Rejected (0404) | +| **F** — Migrate runtime (Tauri / Deno) for a nicer loop | XL | ❌ | ❌ | ❌ | 🛑 Out of scope | +| **G** — **Worktree-scoped dev + provenance + log bridge** | M | ✅ | ✅ | ✅ | ✅ **Recommend** | + +**Why not B.** The skill already carries the warning, in the imperative — _"Confirm +which instance you attached to before trusting anything."_ It cannot be followed: +the app publishes nothing to confirm against, and the one signal it names is +constant in the collision case. Sharpening prose that describes an impossible +check makes the instructions worse, not better. This is the +[0401](<0401_[_]_AGENT_NATIVE_SKILLS_AUDIT.md>) failure mode — a rule nothing +enforces — repeating one layer down. + +**Why not C.** Three stars, nine commits, and it `start_app`s rather than attaching, +so it cannot drive the HMR session that makes rung 2 worth having. Its capability +list is a good spec; its dependency graph is not one to take on. + +**Why not D.** A bespoke server is the tempting answer because it could bundle all +three fixes behind one tool. It is the wrong shape: it puts xNet-specific +knowledge in an agent-side process that must be installed, versioned and kept in +sync with the app, and it adds a second driving tool alongside `playwright-electron` +so every instruction now has to say which one to use. Every capability we need is +reachable through the tool the agent already has, if the **app** publishes it. Put +the knowledge in the product, not in a sidecar. + +**Why not E, restated.** 0404 rejected the browser shim and the reason has not +changed: the preload exposes 10 `contextBridge` namespaces that the renderer calls +at ~76 sites with almost no guards. `apps/web` is already the browser-native +surface. A shim would build a third surface that lies about the first two. + +**Why not F.** [0009](<0009_[x]_TAURI_VS_ELECTRON.md>) and +[0251](<0251_[_]_ELECTRON_TO_DENO_DESKTOP_MIGRATION.md>) own the runtime question. +Whatever their answer, none of the four defects here are Electron's fault — they +are the consequence of never having derived our dev identity from our unit of +work. A Tauri app with hard-coded ports would collide identically. + +### 💰 Revenue lanes + +None. This is internal developer experience: no new surface a user pays for, no +lane to test against the three "No ground rent" tests in +[CHARTER.md §6](../CHARTER.md). Recorded explicitly so the omission is a finding +rather than an oversight. + +--- + +## Recommendation + +Adopt **Option G**, as four independent changes, in this order. Each is useful +alone; the first is the one that matters. + +```mermaid +flowchart LR + subgraph G1["1 — Scope"] + A1["worktree basename
→ profile slug"] + A2["stableProfileOffset(slug)
→ port block"] + end + subgraph G2["2 — Identify"] + B1["window.__xnetDev
tree, branch, commit,
profile, ports, pid"] + end + subgraph G3["3 — Observe"] + C1["main + data-process
→ ring buffer → renderer console"] + end + subgraph G4["4 — Fail loudly"] + D1["strictPort"] + D2["lock loss → named error, exit 1"] + D3["CDP identity assertion"] + end + + G1 --> G2 --> G3 --> G4 + G4 --> OUT["pnpm dev works in
any worktree, unattended"] + + style G1 fill:#1f6feb,color:#fff + style OUT fill:#1a7f37,color:#fff +``` + +### 1. Derive the profile and a port block from the worktree + +`pnpm dev` must be the whole interface. No new command to learn, no env vars to +remember — that is the bar "as easy as web" actually sets. + +- Profile defaults to `wt-` when the cwd is a linked worktree, + and stays `default` in the main checkout so nothing existing moves. +- Ports come from one `stableProfileOffset()` call over the slug, allocating a + contiguous **block of ten** in an unused band: + +```text +blockBase = 20000 + offset * 10 offset = stableProfileOffset(slug) % 500 + + +0 renderer (Vite) +1 CDP +2 hub +3 local API + +4…+9 reserved +``` + +A block is chosen over per-service offsets so one `lsof -i :20140-20149` shows a +worktree's entire footprint, and so future services do not need a new base. The +band sits clear of every current allocation (`4321`, `4388-4394`, `4444`, +`5173-5219`, `6006`, `8081`, `9223-9225`, `31415`). + +- `userData` becomes `xnet-desktop-wt-`, which — because Chromium keys the + lock on `userData` — gives each worktree its own `SingletonLock` for free. The + mechanism already exists in [`profile.ts`](../../apps/electron/src/main/profile.ts); + this only changes what feeds it. + +> [!WARNING] +> `profile.ts` calls `app.setPath('userData', …)` at **module scope**, before +> `requestSingleInstanceLock()` at `index.ts:156`. That ordering is what makes the +> free lock work. Do not move either one, and add a test that pins the order — it +> is load-bearing and looks incidental. + +### 2. Publish provenance on `window.__xnetDev` + +Not a new HTTP endpoint. The local API is token-gated with a per-session UUID +printed only to stdout, so it is useless as an identity probe for an agent that +attached later. Use the channel the agent already has: + +```js +// one call, through the tool the skill already teaches +await browser_evaluate(() => window.__xnetDev) +``` + +Dev-only, guarded exactly like the CDP switch, and asserted absent from production +bundles by extending the existing +[`cdp-dev-only.test.ts`](../../apps/electron/src/main/cdp-dev-only.test.ts) rather +than writing a new guard. + +This turns the skill's unfollowable instruction into a one-line check, and it +makes the title bar honest as a side effect: `xNet (wt-agentic-electron-…)`. + +### 3. Bridge main and data-process logs into the renderer console + +Follow `electron-log`'s IPC-transport pattern, with the buffer that boot failures +require: + +- main and data-process records go into a bounded ring buffer (say 500 entries), + tagged `[main]` / `[data]`; +- once a window exists, the buffer flushes and subsequent records forward live; +- `window.__xnetDev.logs()` returns the buffer for anything that happened before + first paint. + +`browser_console_messages` then covers all three processes. Dev-only; production +logging is a separate concern owned by +[0315](<0315_[x]_FIRST_PARTY_ERROR_TELEMETRY_AND_DEBUG_REPORT_CONSOLE.md>). + +### 4. Make collisions loud + +- `strictPort: true` on the renderer server. A taken port should stop the run, not + relocate it. +- Losing the single-instance lock should read the `SingletonLock` symlink target + (`host-pid`), log a named error identifying the holder, and `process.exit(1)`. + After change 1 this should be nearly unreachable — which is exactly why it must + be loud when it happens. +- After boot, assert CDP is **ours**: fetch `/json/list` and require the target + URL's port to equal our own renderer port. Because change 1 makes renderer ports + unique per worktree, this is a complete check, and it costs one HTTP call. +- Print one machine-readable readiness line so an agent can wait the way Vite lets + it wait on the web: + +```text +[xnet-dev] ready {"profile":"wt-agentic-electron-…","renderer":20140,"cdp":20141,"hub":20142,"localApi":20143,"branch":"claude/agentic-electron-dev-experience-74b20c","commit":"f3082eb06"} +``` + +### 5. Then update the instructions, and only then + +`apps/electron/AGENTS.md` and the `electron-prototype` skill both carry port +tables that become wrong the moment change 1 lands. Replace the tables with the +derivation rule and the `window.__xnetDev` check. Do this last — documenting a +mechanism before it exists is how 0404's hazard survived in the first place. + +Add `pnpm dev:clean` to prune `xnet-desktop-wt-*` profiles whose worktree no +longer appears in `git worktree list`, plus timestamped `e2e-*` profiles older +than a day. + +--- + +## Example Code + +
+Worktree-derived profile and port block + +```ts +// apps/electron/src/main/dev-scope.ts (new) +import { execFileSync } from 'node:child_process' +import { basename } from 'node:path' +import { stableProfileOffset } from './local-api-config' + +const BLOCK_BASE = 20_000 +const BLOCK_SIZE = 10 + +export interface DevScope { + profile: string + worktree: string | null + branch: string | null + commit: string | null + ports: { renderer: number; cdp: number; hub: number; localApi: number } +} + +/** `null` in the main checkout — callers keep today's fixed ports there. */ +function linkedWorktreeRoot(cwd: string): string | null { + try { + // A linked worktree has `.git` as a *file*; the main checkout has a directory. + const common = execFileSync('git', ['rev-parse', '--git-common-dir'], { + cwd, + encoding: 'utf8' + }).trim() + const own = execFileSync('git', ['rev-parse', '--git-dir'], { + cwd, + encoding: 'utf8' + }).trim() + if (common === own) return null + return execFileSync('git', ['rev-parse', '--show-toplevel'], { + cwd, + encoding: 'utf8' + }).trim() + } catch { + // Not a git tree at all. Distinct from "main checkout" — but both mean + // "no worktree scoping", so the same branch is correct here. + return null + } +} + +export function resolveDevScope(cwd = process.cwd()): DevScope { + const root = linkedWorktreeRoot(cwd) + const explicit = process.env.XNET_PROFILE + + if (!root && !explicit) { + return { + profile: 'default', + worktree: null, + branch: gitOrNull(cwd, ['rev-parse', '--abbrev-ref', 'HEAD']), + commit: gitOrNull(cwd, ['rev-parse', '--short', 'HEAD']), + ports: { renderer: 5177, cdp: 9223, hub: 4444, localApi: 31415 } + } + } + + const profile = explicit ?? `wt-${basename(root as string)}` + const base = BLOCK_BASE + (stableProfileOffset(profile) % 500) * BLOCK_SIZE + + return { + profile, + worktree: root, + branch: gitOrNull(cwd, ['rev-parse', '--abbrev-ref', 'HEAD']), + commit: gitOrNull(cwd, ['rev-parse', '--short', 'HEAD']), + ports: { renderer: base, cdp: base + 1, hub: base + 2, localApi: base + 3 } + } +} + +function gitOrNull(cwd: string, args: string[]): string | null { + try { + return execFileSync('git', args, { cwd, encoding: 'utf8' }).trim() + } catch { + return null + } +} +``` + +Note the `catch` returning `null` rather than a plausible default: "unknown +branch" and "branch main" must not be the same value, per AGENTS.md. + +
+ +
+Provenance on the renderer, dev-only + +```ts +// apps/electron/src/preload/index.ts (addition) +if (process.env.NODE_ENV === 'development') { + contextBridge.exposeInMainWorld('__xnetDev', { + ...JSON.parse(process.env.XNET_DEV_SCOPE ?? '{}'), // injected by dev-scope + pid: process.pid, + startedAt: new Date().toISOString(), + logs: () => ipcRenderer.invoke('xnet:dev:logs') + }) +} +``` + +```ts +// apps/electron/src/main/cdp-dev-only.test.ts (extend the existing guard) +it('production main bundle exposes no __xnetDev', () => { + expect(readProdBundle()).not.toMatch(/__xnetDev/) +}) +``` + +
+ +
+Loud lock loss, replacing index.ts:156-159 + +```ts +const hasSingleInstanceLock = app.requestSingleInstanceLock() +if (!hasSingleInstanceLock) { + const holder = readSingletonLockTarget(app.getPath('userData')) ?? 'unknown' + console.error( + `[xnet-dev] FATAL: profile "${profile}" is already running (lock held by ${holder}).\n` + + ` userData: ${app.getPath('userData')}\n` + + ` Another worktree or a stale process owns this profile. ` + + `Set XNET_PROFILE, or run: pnpm dev:clean` + ) + process.exit(1) // not app.quit() — exit 0 is indistinguishable from success +} +``` + +
+ +
+Asserting the CDP endpoint is ours + +```js +// scripts/assert-cdp-ours.mjs +const { cdp, renderer } = JSON.parse(process.env.XNET_DEV_SCOPE) +const targets = await fetch(`http://127.0.0.1:${cdp}/json/list`).then((r) => r.json()) +const mine = targets.some((t) => new URL(t.url).port === String(renderer)) +if (!mine) { + console.error( + `[xnet-dev] FATAL: :${cdp} is serving ${targets.map((t) => t.url).join(', ')}, ` + + `not our renderer on :${renderer}. Refusing to report ready.` + ) + process.exit(1) +} +``` + +
+ +--- + +## Risks And Open Questions + +> [!CAUTION] +> **The second-instance exit was read, not re-run.** This worktree has no +> `node_modules`, and installing them while the user's app is live risks the +> shared native binary: `better_sqlite3.node` is one physical file +> (`node_modules/.pnpm/better-sqlite3@11.10.0/…/build/Release/`) symlinked into +> both `apps/electron` and `packages/hub`, and `deps:electron` / `deps:node` flip +> its ABI. The `app.quit()`-with-exit-0 claim comes from +> [`index.ts:156-159`](../../apps/electron/src/main/index.ts) plus 0404's own +> Measured Results, which recorded the hazard occurring in practice — _"the agent +> kept reading the old window's state."_ Re-running it under change 4 is the first +> validation item. + +- **Port band collisions with things outside this repo.** `20000-24999` is clear + of every xNet allocation but is not reserved globally. `strictPort` makes a + clash loud rather than silent, which is the correct failure — but a worktree + whose hash lands on a busy port will need `XNET_PROFILE` to move. Acceptable; + the alternative is dynamic allocation, which destroys reproducibility across + restarts. +- **Hash collisions between worktrees.** `stableProfileOffset() % 500` over 41 + live agent worktrees carries a meaningful birthday-collision probability: + $P \approx 1 - \prod_{i=0}^{40}(1 - i/500) \approx 0.81$. Two colliding + worktrees get the same block — but **different `userData`**, so the lock still + separates them and the loud failure from change 4 fires immediately. This is + degraded, not dangerous. If it proves annoying, widen to `% 5000` and a + `20000-69999` band, or probe upward from the derived base. +- **`process.cwd()` in the main process.** The dev scope is derived from where + `electron-vite` was invoked. If a future launcher changes the cwd, the + derivation silently moves. Pin it by having the dev script pass + `XNET_DEV_SCOPE` as an env var — resolved once, in the script, before Electron + starts. +- **Provenance is a dev-only surface with a production-leak risk.** Mitigated by + extending the existing prod-bundle test, which is exactly the guard that already + works for the CDP switch. Worth a reviewer's attention regardless. +- **HMR still lies about init order.** Unchanged by any of this, and already + documented in the skill. Change 1 makes restarts cheap enough that "just restart" + becomes a reasonable habit rather than a costly one. +- **Open: headless desktop dev.** A remote or cloud agent has no display, so the + Electron window is invisible to it in a way the web pane never is. CI already + solves this with `xvfb-run` (`ci.yml:319-320`, `visual-capture.yml:144`). Whether + a `pnpm dev:headless` is worth having locally is genuinely unresolved and + deliberately excluded from this recommendation. +- **Open: does the web loop keep a real advantage?** Yes, one — `resize_window` + and a pane the user watches inside the same client. An OS window is arguably + better when the developer is at the machine, and strictly worse when they are + not. Not addressed here. + +--- + +## Implementation Checklist + +**Status:** ░░░░░░░░░░ 0/16 items + +### Scope (the change that matters) + +- [ ] Add `apps/electron/src/main/dev-scope.ts` with `resolveDevScope()`, deriving + profile from the linked-worktree basename and a ten-port block from + `stableProfileOffset()`. +- [ ] Resolve the scope **once** in the dev script and pass it to Electron and Vite + as `XNET_DEV_SCOPE`, so `process.cwd()` is read in exactly one place. +- [ ] Feed `profile.ts` from the resolved scope; keep `XNET_PROFILE` as an override + and keep the main checkout on `default` with today's ports. +- [ ] Point `electron.vite.config.ts`, the CDP switch in `index.ts`, + `resolveLocalAPIPort()` and the dev hub at the block instead of their + constants. +- [ ] Add a test pinning that `app.setPath('userData', …)` runs before + `requestSingleInstanceLock()`. + +### Identify + +- [ ] Expose `window.__xnetDev` (tree, branch, commit, profile, ports, pid, + `startedAt`, `logs()`) from the preload, dev-only. +- [ ] Include the profile in the window title for every profile, including + `default`. +- [ ] Extend `cdp-dev-only.test.ts` to assert production bundles contain no + `__xnetDev`. + +### Observe + +- [ ] Add a bounded ring buffer for main and data-process records, tagged + `[main]` / `[data]`. +- [ ] Flush the buffer to the renderer console when a window appears; forward live + thereafter. +- [ ] Wire `xnet:dev:logs` IPC so `window.__xnetDev.logs()` returns pre-paint + records. + +### Fail loudly + +- [ ] Set `strictPort: true` on the renderer server. +- [ ] Replace `app.quit()` on lock loss with a named error naming the lock holder + and `process.exit(1)`. +- [ ] Add the CDP identity assertion and refuse to print ready if it fails. +- [ ] Print the `[xnet-dev] ready {…}` machine-readable line. + +### Then, the instructions + +- [ ] Rewrite the port tables in `apps/electron/AGENTS.md` and + `.claude/skills/electron-prototype/SKILL.md` as the derivation rule plus the + `window.__xnetDev` check; add `pnpm dev:clean` and delete the hand-cut + `electron-renderer-worktree (vite 5178)` launch entry. + +## Validation Checklist + +- [ ] Two `pnpm dev` runs from two different worktrees are **both alive at once**, + on distinct renderer/CDP/hub/local-API ports and distinct `userData` dirs. +- [ ] Re-run the collision that could not be run here: a second `pnpm dev` on the + **same** profile now exits **non-zero** with a message naming the holder. +- [ ] From worktree A, `browser_evaluate(() => window.__xnetDev)` returns worktree + A's path, branch and commit — and attaching to worktree B's CDP port returns + B's. +- [ ] Deliberately point the MCP server at the wrong port: the CDP identity + assertion fires and the run refuses to report ready. +- [ ] A `console.error` thrown in the main process **before** the window opens is + retrievable via `window.__xnetDev.logs()`, and a post-boot one appears in + `browser_console_messages` tagged `[main]`. +- [ ] With 5177 occupied, `pnpm dev` in the main checkout **fails** rather than + quietly moving to 5178. +- [ ] `pnpm dev:clean` removes profiles for deleted worktrees and leaves live ones + alone; the 18 orphaned dirs drop to the number of live worktrees plus + `default`. +- [ ] `pnpm check:electron-parity`, `pnpm typecheck` and `pnpm test` all pass. +- [ ] A production build contains neither `remote-debugging-port` nor + `__xnetDev` — the extended `cdp-dev-only.test.ts` proves it. +- [ ] Record turns-to-verified-prototype for one real desktop change, worktree + Electron versus web — closing 0404's two unfinished measurement items. + +## References + +### In this repository + +- [0404 — The Electron prototyping loop, built, documented, unwired](<0404_[-]_ELECTRON_PROTOTYPING_LOOP_FOR_AGENTS.md>) — wired the single-seat loop; this doc is its plural sequel +- [0406 — One shell, two surfaces](<0406_[x]_ONE_SHELL_TWO_SURFACES_ENDING_THE_DESKTOP_WEB_UI_FORK.md>) — the workbench is the only desktop shell +- [0401 — The agent-native skill library](<0401_[_]_AGENT_NATIVE_SKILLS_AUDIT.md>) — rules nothing enforces +- [0238 — Electron desktop parity, sync and automated deep testing](<0238_[x]_ELECTRON_DESKTOP_PARITY_SYNC_AND_AUTOMATED_DEEP_TESTING.md>) — the rung-3 harness +- [0315 — First-party error telemetry](<0315_[x]_FIRST_PARTY_ERROR_TELEMETRY_AND_DEBUG_REPORT_CONSOLE.md>) — owns production logging +- [0009 — Tauri vs Electron](<0009_[x]_TAURI_VS_ELECTRON.md>) · [0251 — Electron to Deno](<0251_[_]_ELECTRON_TO_DENO_DESKTOP_MIGRATION.md>) — the runtime question, out of scope here +- [`apps/electron/AGENTS.md`](../../apps/electron/AGENTS.md) · [`.claude/skills/electron-prototype/SKILL.md`](../../.claude/skills/electron-prototype/SKILL.md) — the instructions this changes + +### External + +- [Electron #29068 — what constitutes a second instance for the single instance lock](https://github.com/electron/electron/issues/29068) — the lock is Chromium's, keyed on `userData` +- [Electron #4727 — how `userData` behaves with multiple instances](https://github.com/electron/electron/issues/4727) +- [microsoft/playwright-mcp](https://github.com/microsoft/playwright-mcp) — the server in use, via `--cdp-endpoint` +- [mesomya/electron-driver](https://github.com/mesomya/electron-driver) — 3 ⭐, MIT; `eval_main` + dual-process console capture; **rejected**, launches rather than attaches +- [megahertz/electron-log](https://github.com/megahertz/electron-log) — the IPC-transport pattern for main → renderer logs +- [Maintain multiple VS Code configurations](https://vanslaars.io/articles/maintain-multiple-vs-code-configurations/) — `--user-data-dir` / `--extensions-dir` per instance +- [dev-desktop-sandbox](https://lobehub.com/skills/coder-mux-dev-desktop-sandbox) — per-instance temporary root and auto-selected free ports +- [How to use git worktrees for parallel AI agent execution](https://www.augmentcode.com/guides/git-worktrees-parallel-ai-agent-execution) — why the fleet exists From 68ead02003ef0ae1f1f0e9d9b8e9e7a81ccd75ef Mon Sep 17 00:00:00 2001 From: xNet Test Date: Fri, 31 Jul 2026 07:09:02 -0700 Subject: [PATCH 2/6] feat(electron): scope desktop dev to the worktree, and publish which one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Electron dev was a machine-wide singleton — one SingletonLock, one userData, hard-coded 5177/9223/4444/31415 — while the repo is worked across dozens of git worktrees. The second `pnpm dev` lost the lock, called app.quit(), and exited 0; an agent then attached to :9223 and drove whichever instance already owned it, usually a different branch. Every step succeeded and the verification was false. - Derive profile `wt-` and a ten-port block (20000 + offset*10) from the linked worktree, resolved once in the dev launcher and passed down as XNET_DEV_SCOPE. The main checkout keeps `default` and its legacy ports. - Publish provenance on `window.__xnetDev` (worktree, branch, commit, ports, pid), dev-only, and name the instance in the title bar for every profile. - Forward main and data-process logs into the renderer console through a bounded ring buffer, so a CDP-attached agent sees all three processes and boot failures survive until a window exists. - Fail loudly: strictPort on the renderer, a named error plus exit(1) on lock loss, and a CDP identity assertion that refuses to report ready when the port is serving someone else's renderer. - Add `dev:clean` to prune profiles whose worktree is gone. Co-Authored-By: Claude Opus 5 Signed-off-by: xNet Test --- apps/electron/electron.vite.config.ts | 7 +- apps/electron/package.json | 9 +- apps/electron/scripts/dev-clean.mjs | 139 +++++++++++++++ apps/electron/scripts/dev-launch.mjs | 159 ++++++++++++++++++ apps/electron/scripts/dev-scope.d.mts | 43 +++++ apps/electron/scripts/dev-scope.mjs | Bin 0 -> 7876 bytes apps/electron/scripts/run-dev-hub.mjs | 8 +- apps/electron/src/main/cdp-dev-only.test.ts | 40 ++++- .../electron/src/main/data-process-manager.ts | 23 ++- apps/electron/src/main/dev-log-bridge.test.ts | 58 +++++++ apps/electron/src/main/dev-log-bridge.ts | 133 +++++++++++++++ apps/electron/src/main/dev-scope.test.ts | 148 ++++++++++++++++ apps/electron/src/main/dev-scope.ts | 81 +++++++++ apps/electron/src/main/index.ts | 51 +++++- .../src/main/profile-lock-order.test.ts | 74 ++++++++ apps/electron/src/main/profile.ts | 15 +- apps/electron/src/preload/index.ts | 42 +++++ ...AL_ELECTRON_WORKTREE_SCOPED_DESKTOP_DEV.md | 152 ++++++++--------- site/public/llms-full.txt | 142 ++++++++++++++++ 19 files changed, 1232 insertions(+), 92 deletions(-) create mode 100644 apps/electron/scripts/dev-clean.mjs create mode 100644 apps/electron/scripts/dev-launch.mjs create mode 100644 apps/electron/scripts/dev-scope.d.mts create mode 100644 apps/electron/scripts/dev-scope.mjs create mode 100644 apps/electron/src/main/dev-log-bridge.test.ts create mode 100644 apps/electron/src/main/dev-log-bridge.ts create mode 100644 apps/electron/src/main/dev-scope.test.ts create mode 100644 apps/electron/src/main/dev-scope.ts create mode 100644 apps/electron/src/main/profile-lock-order.test.ts diff --git a/apps/electron/electron.vite.config.ts b/apps/electron/electron.vite.config.ts index a039fe53b..75cb088e9 100644 --- a/apps/electron/electron.vite.config.ts +++ b/apps/electron/electron.vite.config.ts @@ -95,7 +95,12 @@ export default defineConfig({ renderer: { root: resolve(__dirname, 'src/renderer'), server: { - port: rendererPort + port: rendererPort, + // Fail rather than relocate (0413). Vite's default is to increment, so a + // taken 5177 silently became 5178 while AGENTS.md, the prototyping skill + // and .claude/launch.json all still said 5177 — the app worked and every + // document about it was wrong. A taken port is a collision worth seeing. + strictPort: true }, build: { rollupOptions: { diff --git a/apps/electron/package.json b/apps/electron/package.json index 12459182a..030568947 100644 --- a/apps/electron/package.json +++ b/apps/electron/package.json @@ -10,10 +10,13 @@ "main": "./out/main/index.js", "type": "module", "scripts": { - "dev": "node scripts/rebuild-if-stale.mjs && concurrently -k -n hub,electron -c blue,green \"pnpm run dev:hub\" \"pnpm run dev:electron\"", - "dev:electron": "node scripts/rebuild-if-stale.mjs && electron-vite dev", + "dev": "node scripts/rebuild-if-stale.mjs && node scripts/dev-launch.mjs concurrently -k -n hub,electron -c blue,green \"pnpm run dev:hub\" \"pnpm run dev:electron:raw\"", + "dev:electron": "node scripts/rebuild-if-stale.mjs && node scripts/dev-launch.mjs electron-vite dev", + "dev:electron:raw": "electron-vite dev", "dev:hub": "HUB_AUTH=false HUB_STORAGE=memory node scripts/run-dev-hub.mjs", - "dev:user2": "cross-env XNET_PROFILE=user2 VITE_PORT=5174 ELECTRON_CDP_PORT=9224 electron-vite dev", + "dev:clean": "node scripts/dev-clean.mjs", + "dev:scope": "node scripts/dev-scope.mjs", + "dev:user2": "cross-env XNET_PROFILE=user2 VITE_PORT=5174 ELECTRON_CDP_PORT=9224 node scripts/dev-launch.mjs electron-vite dev", "dev:both": "node scripts/rebuild-if-stale.mjs && concurrently -k -n hub,user1,user2 -c blue,green,yellow \"pnpm run dev:hub\" \"pnpm run dev:electron\" \"sleep 3 && pnpm run dev:user2\"", "build": "electron-vite build", "deps:electron": "pnpm dlx @electron/rebuild -f -w better-sqlite3,usearch,sharp", diff --git a/apps/electron/scripts/dev-clean.mjs b/apps/electron/scripts/dev-clean.mjs new file mode 100644 index 000000000..6c0db03c4 --- /dev/null +++ b/apps/electron/scripts/dev-clean.mjs @@ -0,0 +1,139 @@ +#!/usr/bin/env node +/** + * Prune desktop profiles whose reason to exist is gone (0413). + * + * Two kinds accumulate and nothing collects them: + * + * - `xnet-desktop-wt-` — a worktree profile whose worktree has since + * been deleted. `git worktree list` is the authority. + * - `xnet-desktop-e2e-*` with a millisecond timestamp — created per test run + * by `tests/e2e`, never removed. + * + * At the time this was written there were 18 of them, including five from + * Codex sessions that ended months earlier. Small in bytes, large in confusion: + * it makes "which profile is real?" a research task. + * + * Refuses to touch `xnet-desktop` (the default profile), any live worktree's + * profile, and any directory currently holding a `SingletonLock` — a running + * app is never garbage, whatever the naming says. + * + * Run: `pnpm --filter xnet-desktop dev:clean [--dry-run]` + */ +import { execFileSync } from 'node:child_process' +import { existsSync, lstatSync, readdirSync, readlinkSync, rmSync, statSync } from 'node:fs' +import { homedir } from 'node:os' +import { basename, dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const APP_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const DRY_RUN = process.argv.includes('--dry-run') +const E2E_AGE_MS = 24 * 60 * 60 * 1000 + +/** Where Electron puts userData for this platform. */ +function userDataRoot() { + if (process.platform === 'darwin') return join(homedir(), 'Library', 'Application Support') + if (process.platform === 'win32') { + return process.env.APPDATA || join(homedir(), 'AppData', 'Roaming') + } + return process.env.XDG_CONFIG_HOME || join(homedir(), '.config') +} + +/** Basenames of every live worktree, as `wt-` profile names. */ +function liveWorktreeProfiles() { + try { + const out = execFileSync('git', ['worktree', 'list', '--porcelain'], { + cwd: APP_DIR, + encoding: 'utf8' + }) + return new Set( + out + .split('\n') + .filter((line) => line.startsWith('worktree ')) + .map((line) => `wt-${basename(line.slice('worktree '.length).trim())}`) + ) + } catch (err) { + // Without the authority we cannot tell orphan from live. Deleting on a + // guess is the one outcome worse than leaving litter. + console.error(`[dev-clean] cannot list worktrees (${err.message}) — refusing to guess.`) + process.exit(1) + } +} + +function isRunning(dir) { + const lock = join(dir, 'SingletonLock') + try { + lstatSync(lock) + return true + } catch { + return false + } +} + +function classify(name, live) { + if (name === 'xnet-desktop') return null // the default profile + const profile = name.slice('xnet-desktop-'.length) + + if (profile.startsWith('wt-')) { + return live.has(profile) ? null : 'worktree deleted' + } + + // `…-e2e-<13-digit epoch>` and friends: per-run, and only once they are old. + const stamp = profile.match(/(\d{13})$/) + if (stamp && Date.now() - Number(stamp[1]) > E2E_AGE_MS) return 'stale e2e run' + + return null +} + +const root = userDataRoot() +if (!existsSync(root)) { + console.error(`[dev-clean] no userData root at ${root}`) + process.exit(1) +} + +const live = liveWorktreeProfiles() +const candidates = readdirSync(root) + .filter((name) => name.startsWith('xnet-desktop-')) + .map((name) => ({ name, dir: join(root, name) })) + .filter(({ dir }) => { + try { + return statSync(dir).isDirectory() + } catch { + return false + } + }) + +let removed = 0 +let kept = 0 + +for (const { name, dir } of candidates) { + const reason = classify(name, live) + if (!reason) { + kept += 1 + continue + } + if (isRunning(dir)) { + const holder = (() => { + try { + return readlinkSync(join(dir, 'SingletonLock')) + } catch { + return 'unknown' + } + })() + console.log(`[dev-clean] keep ${name} — running (lock held by ${holder})`) + kept += 1 + continue + } + + if (DRY_RUN) { + console.log(`[dev-clean] would remove ${name} — ${reason}`) + } else { + rmSync(dir, { recursive: true, force: true }) + console.log(`[dev-clean] removed ${name} — ${reason}`) + } + removed += 1 +} + +console.log( + `[dev-clean] ${DRY_RUN ? 'would remove' : 'removed'} ${removed}, kept ${kept} ` + + `(of ${candidates.length} non-default profiles in ${root})` +) diff --git a/apps/electron/scripts/dev-launch.mjs b/apps/electron/scripts/dev-launch.mjs new file mode 100644 index 000000000..3f93365e1 --- /dev/null +++ b/apps/electron/scripts/dev-launch.mjs @@ -0,0 +1,159 @@ +#!/usr/bin/env node +/** + * Resolve the dev scope once, inject it, run the dev command, and prove the + * result is ours before saying "ready" (0413). + * + * Three jobs, all of which used to be nobody's: + * + * 1. **Scope injection.** `dev-scope.mjs` resolves the profile and port block + * exactly once, here, and exports them as the env vars the app already + * read. Nothing downstream calls `process.cwd()`. + * + * 2. **CDP identity assertion.** The failure this whole exploration is about + * is attaching to a *healthy* app that belongs to another worktree. After + * boot, `/json/list` must show a target served from **our** renderer port. + * Because renderer ports are now unique per worktree, that is a complete + * check, and it costs one HTTP request. + * + * 3. **A machine-readable ready line.** `[xnet-dev] ready {…}` gives an agent + * the same "wait until it is actually up" affordance Vite gives the web + * loop, and tells it which ports to attach to without parsing prose. + * + * If the probe fails the run does **not** quietly continue: it prints a FATAL + * naming what it found instead, and exits non-zero. A dev server that is up but + * unverifiable is exactly the state that produced false verifications. + * + * Run: `node scripts/dev-launch.mjs [args…]` from apps/electron. + */ +import { spawn } from 'node:child_process' +import { resolveDevScope, scopeEnv } from './dev-scope.mjs' + +const PROBE_TIMEOUT_MS = Number(process.env.XNET_DEV_PROBE_TIMEOUT_MS || 90_000) +const PROBE_INTERVAL_MS = 500 + +const argv = process.argv.slice(2) +const probeIndex = argv.indexOf('--no-probe') +const shouldProbe = probeIndex === -1 +if (!shouldProbe) argv.splice(probeIndex, 1) + +if (argv.length === 0) { + console.error('[xnet-dev] usage: node scripts/dev-launch.mjs [--no-probe] [args…]') + process.exit(2) +} + +const scope = resolveDevScope() +const injected = scopeEnv(scope) + +console.log( + `[xnet-dev] profile ${scope.profile}` + + (scope.scoped ? ` (worktree ${scope.worktree})` : ' (main checkout — legacy ports)') +) +console.log( + `[xnet-dev] renderer ${scope.ports.renderer} · cdp ${scope.ports.cdp} · ` + + `hub ${scope.ports.hub} · localApi ${scope.ports.localApi}` +) + +const child = spawn(argv[0], argv.slice(1), { + cwd: process.cwd(), + stdio: 'inherit', + env: { ...process.env, ...injected }, + shell: process.platform === 'win32' +}) + +let exiting = false +function shutdown(signal) { + if (exiting) return + exiting = true + child.kill(signal) +} +for (const signal of ['SIGINT', 'SIGTERM']) { + process.on(signal, () => shutdown(signal)) +} + +child.on('error', (err) => { + console.error(`[xnet-dev] failed to spawn ${argv[0]}: ${err.message}`) + process.exit(1) +}) + +child.on('exit', (code, signal) => { + exiting = true + if (signal) process.kill(process.pid, signal) + else process.exit(code ?? 0) +}) + +if (shouldProbe) void probeUntilReady() + +/** + * The CDP targets currently served on `port`, or `null` while nothing answers. + * A non-answering port is "not up yet"; a port answering with someone else's + * renderer is a hard failure, and the caller distinguishes them. + */ +async function cdpTargets(port) { + try { + const response = await fetch(`http://127.0.0.1:${port}/json/list`, { + signal: AbortSignal.timeout(2000) + }) + if (!response.ok) return null + const body = await response.json() + return Array.isArray(body) ? body : null + } catch { + return null + } +} + +function rendererPortOf(target) { + try { + return new URL(target.url).port + } catch { + return null + } +} + +async function probeUntilReady() { + const deadline = Date.now() + PROBE_TIMEOUT_MS + const want = String(scope.ports.renderer) + + while (Date.now() < deadline && !exiting) { + const targets = await cdpTargets(scope.ports.cdp) + + if (targets && targets.length > 0) { + const ours = targets.filter((t) => rendererPortOf(t) === want) + if (ours.length > 0) { + console.log( + `[xnet-dev] ready ${JSON.stringify({ + profile: scope.profile, + worktree: scope.worktree, + branch: scope.branch, + commit: scope.commit, + ...scope.ports + })}` + ) + return + } + + // Something is on our CDP port, and it is not us. This is the bug. + const seen = targets.map((t) => t.url).join(', ') + console.error( + `[xnet-dev] FATAL: :${scope.ports.cdp} is serving ${seen || '(no targets)'}, ` + + `not our renderer on :${want}.\n` + + `[xnet-dev] Another instance owns this port. Refusing to report ready — ` + + `an agent attaching here would drive the wrong app.\n` + + `[xnet-dev] Check: lsof -ti :${scope.ports.cdp}` + ) + shutdown('SIGTERM') + process.exitCode = 1 + return + } + + await new Promise((r) => setTimeout(r, PROBE_INTERVAL_MS)) + } + + if (exiting) return + console.error( + `[xnet-dev] FATAL: nothing answered CDP on :${scope.ports.cdp} within ` + + `${Math.round(PROBE_TIMEOUT_MS / 1000)}s. The app did not reach a ` + + `drivable state; not reporting ready.` + ) + shutdown('SIGTERM') + process.exitCode = 1 +} diff --git a/apps/electron/scripts/dev-scope.d.mts b/apps/electron/scripts/dev-scope.d.mts new file mode 100644 index 000000000..0dbf45dfd --- /dev/null +++ b/apps/electron/scripts/dev-scope.d.mts @@ -0,0 +1,43 @@ +/** + * Types for `dev-scope.mjs` (0413). + * + * The resolver is plain `.mjs` because it has to run from a package script, + * before any build step, and be importable by the Vite config and the dev + * launcher alike. This declares its surface so `dev-scope.test.ts` — and any + * future TypeScript consumer — types it instead of falling back to `any`. + * + * `DevScopePorts` / `DevScope` here mirror `src/main/dev-scope.ts`, which is + * the *reader* for the same JSON. The two differ deliberately: the reader's + * fields are nullable because it may be handed nothing at all, whereas the + * resolver always produces a concrete answer. + */ + +export interface ResolvedPorts { + renderer: number + cdp: number + hub: number + localApi: number +} + +export interface ResolvedDevScope { + profile: string + /** False in the main checkout, where nothing relocates. */ + scoped: boolean + worktree: string | null + branch: string | null + commit: string | null + ports: ResolvedPorts +} + +export declare const LEGACY_PORTS: Readonly +export declare const BLOCK_BASE: number +export declare const BLOCK_SIZE: number +export declare const BLOCK_COUNT: number + +export declare function hashOffset(value: string, buckets?: number): number +export declare function linkedWorktreeRoot(cwd?: string): string | null +export declare function resolveDevScope( + cwd?: string, + env?: NodeJS.ProcessEnv | Record +): ResolvedDevScope +export declare function scopeEnv(scope: ResolvedDevScope): Record diff --git a/apps/electron/scripts/dev-scope.mjs b/apps/electron/scripts/dev-scope.mjs new file mode 100644 index 0000000000000000000000000000000000000000..7aaa1b1b0ce635d91e5608eff99b80e8a14d07fb GIT binary patch literal 7876 zcmaJ`?N;N~742`H;?8s$%Zx2-J|=@_m@Ec72@M4nkV)EEDUzjYTS1moePGChweqVE z(DoVnV0n`6eeRWP11W0(mUQpYx#we_eUAV5Y@;gjjZu_r=;THvX{gsW*4Nk6x;oT( zbfZ-{jS3Y^b#R?lWvfu3zEM%AlQJsr)NjB3M`d|Bj$++WS(=vx{^jY7PW&X$%IR(Q zx7seGQQ+lNV|Q^~rkO~=3!^+uW{{<-LWinM)krH}&HP{*CAvGudsGo6lUSE&GEixv zDaX60Zyl$>wb$W&4Yj+uxrKk*_`$!e{&s(d znu%Ju&QgVQ&eQxFJNzI|3mi55Sto@`$7&Ll@~q6Y#u1AYW`SxtNbxE!OR^bHj$GP^yJC)88$fk~TN(bhxpvRr^DKL!2W1Z9Lqa3@Y ziq6b+eCMhmq}{2a)R`)(AkZ52dn0o2AR0%3Uq)%7=6(S;#gSfi?Krxn387?IM~+@Z z$~aGFBB)AWOPG|VcGW(lBs4iMXk%vhBgdNUgaSj`Q}JbMrA)t=hy;YYoM6~=`w@wneJifS<*7g zqXYrQl!F*wC3=p$F!I9`woB3yH{oIPj1FwlP7Lweum9fM-ren~5rx2QL9G3pA;Q?c zQ$dwWup)qQWQjj#duy}bQQKRuUw5~+cDFk;9}~O7WZ&xUZ1#6M>Q%4zs-s@_USlcK zri(v29h3PhptQa|^P{8zIfRMM7|5Z2hqEHCp}n|Mj>-7(LOu4Yxb*7qTwiDGPz{_H z;5AXm@1o>~8FCfrg0q?PglVAWc~mk;MjD}IF%~Jz=*%l#BLM2$I@d)S!>MpX0F;HI z)POJ?`xVkx=f?b*Ul2q?CJpk_`{87Gd38Aacy)1bdN%YVH42kpQ2(ZDcDu8m3eQz1 zR%;WJTG+>aB0(th*4dx+0tkJFlFng>UstEEZD~k;ZFM>4FA{&W=%m@4x8#>lO z3B)HxNZiOt{(NzILTMXo(TrABpJ6*4d_YaPxJv?cZzQz%jsXfo99~&fT8kYuE_8ry zIRT=>WT4$v_h(3jrEM%+-<=;{rf0any0XgS*4i4f8*sjVc6N1mbdJ=uLLK`SXGMTx z4&cso>ATq921d19x7A)_O;yKw;sNaZ(hIY7J4yFxls&cJ;^=Q&0Azb&_rd9RCzp7-gS|D~_;B*E z+xHccg-FHfMu;g&;LD^AwnH69BTY<(S=QH)YA~yZ#2Ll<%+Qci!BhmOi5gGN)Ah3m zWFQ$w6Spk90bXD#!&JurM34p6v!t4#a0T6>8nc$!;^+_^NiH^#LjU|1Ft}qXuT%h3 z2h^!7O4`hD=6~%iRm9Z<$ph8|E(3kx1SYSqrur69N=iZc<7{+D{(>-(dyx7i=R z3iT@wa;jJa25B7o843ro##W41%8ye(%REBV8dO34;vjq{Uqsx&yI#5Kds|ll!~kI` z_|UkN%OaLXqpB3tY2s@Pd=bEffu`27H-20pyGB)T%`&~GOhAZB+lt`^wnmFi@vL|I zy5A3W!(a#ZSw$T_jQHdY{=HQq&5d<3DW|;i5+P%xATBB6?|XJl07iEJ2-q*3s10fM z`JVa2k-3l5tRMit+smi?wvwyRrmPKby?+!k@}}v|F!!j0Kbk%Z;75u z#1yC=!i9L!EWet&xY?bYS^a9H0sIyoHvaP{Hn z7%C${1Sv4@J-$wCEc3pBPM3ENCehMYFXb3 zUo$c2t5G@u1Iob&KkhY+rCF$IW2m%g%u&{R;d{kpRnY|)o{XH#?Z2Yj-OA^B*4N(dI{SzFPacsE^kf? zM{1#h%O0}~_&g7Z%>2;_x)|j1K((rJ{JOQce=}ex4rz}1W>JPwI#8cl(If$}fZSFV zW%A$Nq58;&wu^k9IZUK`^e82|0Q2sR4fVWAt`p?JbBWdGjEd)KR-q1(%b`Off)uE+ zCNm{ErOiPQg1Q4oKOu7Qp06^Hks<<$cVomY(aRDnxF0_@ zbt#w%FD30IRAJE3w^ZvvHzB|V{V7pG9?fD7x&lEc5ctSFJRn1}iF`+L6i@w?Kn69X z0ek@f_9|%20K~BvU<3eGGYy9QJP{tar~{xvc>nXJY}gir2DCMXbWa%RW8^gls!{wL zM|BV!2zbj$mNS8A1$MPZ1)Wc=1}zu{1~AMD;zsL76QQuDNe}|x|Jk{^h3WFlzV4S_ z)H7^uKkyP9|ERgM_zLw%)5!I`w?@jljoMzt##__inZDCFhhbE^2STusj<{va8x^Z*M{0Ck@DEsEgr6jJS;(`zK0tp|pzsQH6FDyx8XF;R_7cowLEr4Xt(9~+x15;P zV+SELT@pdEgJ8={ILmc_)*t=G06D-q7e@h{W_pUV^HW>O;yGKEIofFD2-aK2pv3`~ zN-*D`v~r#nSJzqH#0+>OHO0G3+g2a+2vq4N3Naj!29FpIPy>#kbwgs4jOH9dQK0GWg|dA- zx*Q7M_5i%7X~W~;!R7hs$rajkd*9mXoA2JQ-T-^vKfXfSZf@759acuegbJNXl?-t0 z8|wl)*nl@pH8~#&J<2_-mkP2`JA`}#g7hj7hlhL&C^k0^_00u)v{f|ehz0i0ok{EG z!XC@vD@eO%UGWf;SW9e0w((*qIRYMXTWsDWB;@F4PD=@=;?K!^2@ zO~Ogpi_ZiABB?{6?RyL-my~&cR7+aOYQqcUHHH?{Cfrjkn?tP?c@I@w!_ykV%tAXQ zmJ6V{H}-J|k3M;nt%GKWf=3vS$OJ`ZbDjW}P##Zda7c!U_&dQ!fy0A3WtS%|B$=E< z*QCN`H)awTVHT2tQ4+)zX;v-Ivw3pNF&onL48qY>+6u-gT5S@UcFJ6;uJB;Pu@AHY zc{a+K@OhZ}(%~&(+&IRl7egVZZZ}81OeN8u$+Vgb0Y74baA4x;8HyU&j3Iz^2%CrI zo}m~*6(4f~e>5UX$n}^U;hW+9VO`hEW>(m!{GH9HhdHV16Z<5*29{DX66yg2dTccC zr#Z>D+rX&(?!c|rHGEcz-O8RvP+RZC(q@|!#g%OWJ~bHETd=IzvM8L7_pLgv-d*o7 z%_!RqA*M|y(8$UCc}F4KWBSu%tK_af7f!;~Xp9j-sKBJKR&EN1#`T5FKOEey8M{1g zwHo453m`bvXHhIk^jmc4jrv(emiqv8nVd7^X2EN`nXzEE~2TIQvzw|nZ<&ehJ= zjskYE_Y8qZ%}W>U^CgN9oZ5V&UHOCG=%*jfuhlYzUCx^PHK*y5-b-=z2$+>QxzV}6^4 zMhAv?x-vJ|CLc_o04dKF-=~#18soSyBcHioW32~y@`CQn{RB0~12e-o*uA>CO>~Kh z&l&)UKe3lX-!-dfCh)ofgG(G1RbYXpUu=AbAf6{bPmGD5Ft>+T!WIeg_J z?KDF(YD%CS9|elr<-3@=9B}%sNbWJK|DRAtQf0H%~PB0Hf90;10%f_Q~&?~ literal 0 HcmV?d00001 diff --git a/apps/electron/scripts/run-dev-hub.mjs b/apps/electron/scripts/run-dev-hub.mjs index fdaf45945..c65896ae5 100644 --- a/apps/electron/scripts/run-dev-hub.mjs +++ b/apps/electron/scripts/run-dev-hub.mjs @@ -57,7 +57,13 @@ const tsxCli = required('tsx not found in packages/hub', () => createRequire(resolve(hubDir, 'package.json')).resolve('tsx/cli') ) -const child = spawn(electronBinary, [tsxCli, 'src/cli.ts'], { +// A worktree-scoped dev run gets its own hub port (0413), injected by +// `dev-launch.mjs`. Absent — the main checkout, or a direct invocation — the +// hub keeps its own default, so nothing pre-0413 moves. +const hubPort = process.env.XNET_HUB_PORT +const portArgs = hubPort ? ['--port', hubPort] : [] + +const child = spawn(electronBinary, [tsxCli, 'src/cli.ts', ...portArgs], { cwd: hubDir, stdio: 'inherit', env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' } diff --git a/apps/electron/src/main/cdp-dev-only.test.ts b/apps/electron/src/main/cdp-dev-only.test.ts index 42a44678b..a9de3be85 100644 --- a/apps/electron/src/main/cdp-dev-only.test.ts +++ b/apps/electron/src/main/cdp-dev-only.test.ts @@ -17,7 +17,9 @@ import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' -const mainSource = readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'index.ts'), 'utf8') +const here = dirname(fileURLToPath(import.meta.url)) +const mainSource = readFileSync(join(here, 'index.ts'), 'utf8') +const preloadSource = readFileSync(join(here, '../preload/index.ts'), 'utf8') /** Strip block and line comments so prose about the switch can't satisfy a check. */ function stripComments(source: string): string { @@ -57,3 +59,39 @@ describe('CDP remote debugging is development-only', () => { } }) }) + +/** + * `window.__xnetDev` carries the worktree path, branch and commit an instance + * was built from (0413). That is exactly the reconnaissance an attacker wants, + * and it is pointless in a shipped app — nobody attaches a prototyping agent to + * a release. Same guard as the CDP switch, asserted the same way. + */ +describe('__xnetDev provenance is development-only', () => { + const code = stripComments(preloadSource) + + it('exposes the global exactly once', () => { + const matches = code.match(/exposeInMainWorld\(\s*['"]__xnetDev['"]/g) ?? [] + expect(matches).toHaveLength(1) + }) + + it('exposes it only inside a NODE_ENV === development block', () => { + const index = code.indexOf("exposeInMainWorld('__xnetDev'") + expect(index).toBeGreaterThan(-1) + + const before = code.slice(0, index) + const guardStart = before.lastIndexOf('if (') + expect(guardStart).toBeGreaterThan(-1) + + const guardToExpose = before.slice(guardStart) + expect(guardToExpose).toMatch(/process\.env\.NODE_ENV\s*===\s*['"]development['"]/) + // Nothing may close that block before the exposure runs. + expect(guardToExpose.split('}').length - 1).toBe(0) + }) + + it('never exposes it unconditionally at module scope', () => { + for (const line of code.split('\n')) { + if (!line.includes('__xnetDev')) continue + expect(line).toMatch(/^\s+/) + } + }) +}) diff --git a/apps/electron/src/main/data-process-manager.ts b/apps/electron/src/main/data-process-manager.ts index f15da3a16..76b7fdc04 100644 --- a/apps/electron/src/main/data-process-manager.ts +++ b/apps/electron/src/main/data-process-manager.ts @@ -19,6 +19,7 @@ import { ipcMain, MessageChannelMain } from 'electron' +import { recordDevLog } from './dev-log-bridge' // ESM __dirname shim const __filename = fileURLToPath(import.meta.url) @@ -107,17 +108,31 @@ export async function spawnDataProcess(dbPath: string): Promise { NODE_PATH: nodeModulesPath } + // Also pipe in development so the dev log bridge can forward this + // process's output to the renderer console (0413) — an agent driving the + // app over CDP otherwise sees one process out of three. + const capture = debugEnabled || process.env.NODE_ENV === 'development' + dataProcess = utilityProcess.fork(scriptPath, [], { serviceName: 'xnet-data', env, // Pipe the child's stdio so a native-module load failure (which would // otherwise be an invisible early exit) is forwarded to the main process // stderr and captured by the packaged-smoke gate. - stdio: debugEnabled ? ['ignore', 'pipe', 'pipe'] : 'inherit' + stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit' }) - if (debugEnabled) { - dataProcess.stdout?.on('data', (c: Buffer) => process.stderr.write(`[data] ${c}`)) - dataProcess.stderr?.on('data', (c: Buffer) => process.stderr.write(`[data] ${c}`)) + if (capture) { + const forward = (level: 'log' | 'error') => (chunk: Buffer) => { + const text = chunk.toString() + // Preserve the pre-existing stderr echo: `packaged-smoke` greps it, + // and a human watching `pnpm dev` still expects it. + process.stderr.write(`[data] ${text}`) + for (const line of text.split('\n')) { + if (line.trim()) recordDevLog('data', level, line) + } + } + dataProcess.stdout?.on('data', forward('log')) + dataProcess.stderr?.on('data', forward('error')) } // Handle messages from utility process diff --git a/apps/electron/src/main/dev-log-bridge.test.ts b/apps/electron/src/main/dev-log-bridge.test.ts new file mode 100644 index 000000000..cdb6aee6c --- /dev/null +++ b/apps/electron/src/main/dev-log-bridge.test.ts @@ -0,0 +1,58 @@ +/** + * The dev log bridge's ring buffer (exploration 0413). + * + * The property that matters is the boring one: records emitted **before** a + * window exists must survive until there is somewhere to send them. Those are + * boot failures — the class an agent driving over CDP most needs to see and, + * pre-0413, the class it could never see at all. + */ +import { beforeEach, describe, expect, it } from 'vitest' +import { devLogRecords, recordDevLog, resetDevLogBridge } from './dev-log-bridge' + +describe('dev log ring buffer', () => { + beforeEach(() => { + resetDevLogBridge() + }) + + it('retains records written with no window attached', () => { + recordDevLog('main', 'error', 'better_sqlite3.node failed to load') + const records = devLogRecords() + expect(records).toHaveLength(1) + expect(records[0]).toMatchObject({ + source: 'main', + level: 'error', + message: 'better_sqlite3.node failed to load' + }) + }) + + it('tags main and data records distinctly', () => { + recordDevLog('main', 'log', 'from main') + recordDevLog('data', 'error', 'from data') + expect(devLogRecords().map((r) => r.source)).toEqual(['main', 'data']) + }) + + it('keeps insertion order', () => { + for (const n of [1, 2, 3]) recordDevLog('main', 'log', `m${n}`) + expect(devLogRecords().map((r) => r.message)).toEqual(['m1', 'm2', 'm3']) + }) + + it('bounds the buffer and drops the oldest, not the newest', () => { + for (let i = 0; i < 600; i += 1) recordDevLog('main', 'log', `m${i}`) + const records = devLogRecords() + expect(records).toHaveLength(500) + // The newest record must survive: a crash is the last thing logged. + expect(records[records.length - 1].message).toBe('m599') + expect(records[0].message).toBe('m100') + }) + + it('stamps every record with a timestamp', () => { + recordDevLog('main', 'warn', 'something') + expect(() => new Date(devLogRecords()[0].at).toISOString()).not.toThrow() + }) + + it('returns a copy, so a caller cannot mutate the buffer', () => { + recordDevLog('main', 'log', 'one') + devLogRecords().push({ at: '', source: 'main', level: 'log', message: 'injected' }) + expect(devLogRecords()).toHaveLength(1) + }) +}) diff --git a/apps/electron/src/main/dev-log-bridge.ts b/apps/electron/src/main/dev-log-bridge.ts new file mode 100644 index 000000000..c8767a68b --- /dev/null +++ b/apps/electron/src/main/dev-log-bridge.ts @@ -0,0 +1,133 @@ +/** + * Make the main and data processes visible to a CDP-attached agent (0413). + * + * An agent driving the app over CDP sees the **renderer console and nothing + * else**. `main/` and `data-process/` write to the `pnpm dev` stdout, which is + * a different channel with different semantics and is simply absent whenever a + * human started the app. So a boot failure in the main process — the class of + * failure that matters most — is invisible to the thing doing the verifying. + * + * This forwards main-process console output into the renderer's console, tagged + * `[main]`, following `electron-log`'s IPC-transport pattern. Two details that + * pattern alone does not give us: + * + * 1. **A ring buffer.** Records emitted before a window exists have nowhere + * to forward to, and those are the interesting ones. Everything is + * buffered and flushed once a renderer is listening. + * 2. **`window.__xnetDev.logs()`.** The flush lands in the console, which is + * lossy across reloads; the buffer stays readable on demand. + * + * Development only — production logging is 0315's job, and this deliberately + * has no file transport, no network transport and no retention. + */ +import { ipcMain, type BrowserWindow } from 'electron' + +export const DEV_LOG_CHANNEL = 'xnet:dev:log' +export const DEV_LOG_LIST_CHANNEL = 'xnet:dev:logs' + +const MAX_RECORDS = 500 + +export type DevLogLevel = 'log' | 'info' | 'warn' | 'error' | 'debug' +export type DevLogSource = 'main' | 'data' + +export interface DevLogRecord { + at: string + source: DevLogSource + level: DevLogLevel + message: string +} + +/** Bounded FIFO. Oldest records are dropped; the newest are the ones we want. */ +const buffer: DevLogRecord[] = [] +let target: BrowserWindow | null = null +let installed = false + +export function recordDevLog( + source: DevLogSource, + level: DevLogLevel, + message: string +): DevLogRecord { + const record: DevLogRecord = { at: new Date().toISOString(), source, level, message } + buffer.push(record) + if (buffer.length > MAX_RECORDS) buffer.splice(0, buffer.length - MAX_RECORDS) + forward(record) + return record +} + +function forward(record: DevLogRecord): void { + if (!target || target.isDestroyed()) return + try { + target.webContents.send(DEV_LOG_CHANNEL, record) + } catch { + // A window mid-teardown is not a reason to lose the buffered copy. + } +} + +/** Every record captured so far, oldest first. */ +export function devLogRecords(): DevLogRecord[] { + return [...buffer] +} + +/** Test seam — the buffer is module state and would otherwise leak across specs. */ +export function resetDevLogBridge(): void { + buffer.length = 0 + target = null + installed = false +} + +function format(args: unknown[]): string { + return args + .map((arg) => { + if (typeof arg === 'string') return arg + if (arg instanceof Error) return arg.stack || `${arg.name}: ${arg.message}` + try { + return JSON.stringify(arg) + } catch { + return String(arg) + } + }) + .join(' ') +} + +/** + * Patch the main process's console so everything it already logs is captured. + * + * Wrapping rather than replacing: the original still writes to stdout, so + * `preview_logs` and a human's terminal keep working exactly as before. This + * only *adds* a second reader. + */ +export function installDevLogBridge(): void { + if (installed || process.env.NODE_ENV !== 'development') return + installed = true + + const levels: DevLogLevel[] = ['log', 'info', 'warn', 'error', 'debug'] + for (const level of levels) { + const original = console[level].bind(console) + console[level] = (...args: unknown[]) => { + original(...args) + // Capture only — never re-enter the console from inside the patch. + const record: DevLogRecord = { + at: new Date().toISOString(), + source: 'main', + level, + message: format(args) + } + buffer.push(record) + if (buffer.length > MAX_RECORDS) buffer.splice(0, buffer.length - MAX_RECORDS) + forward(record) + } + } + + ipcMain.handle(DEV_LOG_LIST_CHANNEL, () => devLogRecords()) +} + +/** + * Point the bridge at a window and flush everything buffered before it existed. + * Called once the renderer has finished loading — earlier and the flush races + * the renderer's own listener registration. + */ +export function attachDevLogWindow(window: BrowserWindow): void { + if (process.env.NODE_ENV !== 'development') return + target = window + for (const record of buffer) forward(record) +} diff --git a/apps/electron/src/main/dev-scope.test.ts b/apps/electron/src/main/dev-scope.test.ts new file mode 100644 index 000000000..79e71611e --- /dev/null +++ b/apps/electron/src/main/dev-scope.test.ts @@ -0,0 +1,148 @@ +/** + * The dev scope reader and the port-block derivation (exploration 0413). + * + * Two properties carry the whole design and both are easy to break silently: + * + * 1. **The main checkout must not move.** Every pre-0413 doc, launch entry + * and habit assumes 5177 / 9223 / 4444 / 31415 and the `default` profile. + * 2. **Unknown must not render as a default.** A packaged build has no scope; + * if that came back as "the main checkout", an agent reading provenance + * would trust a value nobody set. + */ +import { describe, expect, it } from 'vitest' +import { + BLOCK_BASE, + BLOCK_COUNT, + BLOCK_SIZE, + LEGACY_PORTS, + hashOffset, + resolveDevScope, + scopeEnv +} from '../../scripts/dev-scope.mjs' + +const MAIN = '/Users/crs/Code/xNet/apps/electron' + +describe('port block derivation', () => { + it('spreads whole strings across the block range', () => { + for (const value of ['a', 'b', 'wt-one', 'wt-two']) { + const offset = hashOffset(value) + expect(offset).toBeGreaterThanOrEqual(0) + expect(offset).toBeLessThan(BLOCK_COUNT) + } + }) + + it('is stable across calls', () => { + expect(hashOffset('wt-example')).toBe(hashOffset('wt-example')) + }) + + it('does not collapse strings that differ only in their final character', () => { + // `stableProfileOffset()` would: its numeric-suffix branch maps every slug + // ending in `6` to offset 6. Worktree slugs routinely end in a hex digit. + const a = hashOffset('/w/agent-a7ef01abd021f6de6') + const b = hashOffset('/w/agent-a7ef01abd021f6de5') + expect(a).not.toBe(b) + }) + + it('keeps every derived port inside the reserved band', () => { + const highest = BLOCK_BASE + (BLOCK_COUNT - 1) * BLOCK_SIZE + BLOCK_SIZE - 1 + expect(BLOCK_BASE).toBe(20_000) + expect(highest).toBeLessThan(25_000) + // Clear of every current allocation. + for (const taken of [4321, 4394, 4444, 5173, 5219, 6006, 8081, 9223, 9225, 31415]) { + expect(taken < BLOCK_BASE || taken > highest).toBe(true) + } + }) +}) + +describe('resolveDevScope', () => { + it('leaves the main checkout on the default profile and legacy ports', () => { + const scope = resolveDevScope(MAIN, {}) + expect(scope.profile).toBe('default') + expect(scope.scoped).toBe(false) + expect(scope.worktree).toBeNull() + expect(scope.ports).toEqual(LEGACY_PORTS) + }) + + it('scopes a linked worktree to its own profile and block', () => { + const scope = resolveDevScope(process.cwd(), {}) + expect(scope.scoped).toBe(true) + expect(scope.profile).toMatch(/^wt-/) + expect(scope.worktree).toBeTruthy() + expect(scope.ports.renderer).toBeGreaterThanOrEqual(BLOCK_BASE) + // A contiguous block, in the documented order. + expect(scope.ports.cdp).toBe(scope.ports.renderer + 1) + expect(scope.ports.hub).toBe(scope.ports.renderer + 2) + expect(scope.ports.localApi).toBe(scope.ports.renderer + 3) + }) + + it('ignores inherited GIT_* variables when detecting the worktree', () => { + // A git hook exports GIT_DIR/GIT_WORK_TREE into the *process* environment + // and every `git` child inherits them, so `rev-parse --git-dir` answers + // about the hook's repository rather than `cwd`'s. Unscrubbed, this + // resolved the main checkout as a linked worktree with a bogus root — + // under pre-commit, pre-push and CI. Hence the real `process.env` here. + const saved = { ...process.env } + try { + process.env.GIT_DIR = '/Users/crs/Code/xNet/.git' + process.env.GIT_WORK_TREE = '/Users/crs/Code/xNet' + process.env.GIT_INDEX_FILE = '/Users/crs/Code/xNet/.git/index' + + const scope = resolveDevScope(MAIN, {}) + expect(scope.profile).toBe('default') + expect(scope.scoped).toBe(false) + + // …and a real worktree is still detected through the same noise. + expect(resolveDevScope(process.cwd(), {}).scoped).toBe(true) + } finally { + for (const key of Object.keys(process.env)) { + if (!(key in saved)) delete process.env[key] + } + Object.assign(process.env, saved) + } + }) + + it('lets an explicit profile win over the derived one', () => { + expect(resolveDevScope(process.cwd(), { XNET_PROFILE: 'user2' }).profile).toBe('user2') + }) + + it('lets a single port override stand without abandoning the rest', () => { + const scope = resolveDevScope(process.cwd(), { VITE_PORT: '5199' }) + expect(scope.ports.renderer).toBe(5199) + expect(scope.ports.cdp).toBeGreaterThanOrEqual(BLOCK_BASE) + }) + + it('ignores an out-of-range port override rather than trusting it', () => { + const scope = resolveDevScope(process.cwd(), { VITE_PORT: '70000' }) + expect(scope.ports.renderer).toBeGreaterThanOrEqual(BLOCK_BASE) + }) +}) + +describe('scopeEnv', () => { + it('emits no port overrides in the main checkout', () => { + const env = scopeEnv(resolveDevScope(MAIN, {})) + expect(env.XNET_DEV_SCOPE).toBeTruthy() + // Critically: no XNET_LOCAL_API_PORT. Emitting it would flatten + // `resolveLocalAPIPort()`'s own per-profile derivation, putting `user2` + // back onto 31415 alongside `default`. + expect(env.XNET_LOCAL_API_PORT).toBeUndefined() + expect(env.VITE_PORT).toBeUndefined() + expect(env.XNET_PROFILE).toBeUndefined() + }) + + it('emits the full block for a worktree', () => { + const scope = resolveDevScope(process.cwd(), {}) + const env = scopeEnv(scope) + expect(env.XNET_PROFILE).toBe(scope.profile) + expect(env.VITE_PORT).toBe(String(scope.ports.renderer)) + expect(env.ELECTRON_CDP_PORT).toBe(String(scope.ports.cdp)) + expect(env.XNET_HUB_PORT).toBe(String(scope.ports.hub)) + expect(env.XNET_LOCAL_API_PORT).toBe(String(scope.ports.localApi)) + expect(env.VITE_HUB_URL).toBe(`ws://localhost:${scope.ports.hub}`) + }) + + it('carries provenance even where nothing relocates', () => { + const parsed = JSON.parse(scopeEnv(resolveDevScope(MAIN, {})).XNET_DEV_SCOPE) + expect(parsed.profile).toBe('default') + expect(parsed.scoped).toBe(false) + }) +}) diff --git a/apps/electron/src/main/dev-scope.ts b/apps/electron/src/main/dev-scope.ts new file mode 100644 index 000000000..84fd034c2 --- /dev/null +++ b/apps/electron/src/main/dev-scope.ts @@ -0,0 +1,81 @@ +/** + * The dev scope, as the main process sees it (0413). + * + * This is a **reader**, not a resolver. `scripts/dev-scope.mjs` derives the + * profile, port block and git provenance exactly once — in the dev launcher — + * and passes the result down as `XNET_DEV_SCOPE`. The main process parses it + * and nothing more, so there is one place where "which worktree is this?" is + * decided and no way for a changed cwd to make the answer drift mid-run. + * + * Absent or unparseable, every field is `null` and `scoped` is false. That is + * the honest answer for a packaged build and for `electron-vite dev` invoked + * directly: **"we do not know"** must not be renderable as "the main checkout", + * because an agent reading provenance would then trust a value nobody set. + */ + +export interface DevScopePorts { + renderer: number + cdp: number + hub: number + localApi: number +} + +export interface DevScope { + profile: string | null + scoped: boolean + worktree: string | null + branch: string | null + commit: string | null + ports: DevScopePorts | null +} + +const UNKNOWN: DevScope = Object.freeze({ + profile: null, + scoped: false, + worktree: null, + branch: null, + commit: null, + ports: null +}) + +function parse(raw: string | undefined): DevScope { + if (!raw) return UNKNOWN + try { + const value = JSON.parse(raw) as Partial + if (!value || typeof value !== 'object') return UNKNOWN + return { + profile: typeof value.profile === 'string' ? value.profile : null, + scoped: value.scoped === true, + worktree: typeof value.worktree === 'string' ? value.worktree : null, + branch: typeof value.branch === 'string' ? value.branch : null, + commit: typeof value.commit === 'string' ? value.commit : null, + ports: isPorts(value.ports) ? value.ports : null + } + } catch { + return UNKNOWN + } +} + +function isPorts(value: unknown): value is DevScopePorts { + if (!value || typeof value !== 'object') return false + const candidate = value as Record + return (['renderer', 'cdp', 'hub', 'localApi'] as const).every( + (key) => typeof candidate[key] === 'number' && Number.isInteger(candidate[key]) + ) +} + +/** The scope this process was launched with. Resolved once, at module load. */ +export const devScope: DevScope = parse(process.env.XNET_DEV_SCOPE) + +/** + * The window-title suffix for this instance. + * + * Always non-empty in development: the pre-0413 title was plain `xNet` for the + * `default` profile, which made the documented "check the title bar to see + * which instance you attached to" mitigation useless in precisely the case it + * existed to catch — two `default` instances racing for the same port. + */ +export function titleSuffix(profile: string): string { + const commit = devScope.commit ? ` @${devScope.commit}` : '' + return `${profile}${commit}` +} diff --git a/apps/electron/src/main/index.ts b/apps/electron/src/main/index.ts index a7ebad6af..5be261f8f 100644 --- a/apps/electron/src/main/index.ts +++ b/apps/electron/src/main/index.ts @@ -1,7 +1,7 @@ /** * Electron main process entry point */ -import { appendFileSync } from 'fs' +import { appendFileSync, readlinkSync } from 'fs' import { join, dirname } from 'path' import { fileURLToPath } from 'url' import { app, BrowserWindow } from 'electron' @@ -15,6 +15,8 @@ import { setupWindowChannel } from './data-process-manager' import { parseConnectDeepLink, type CloudConnectPayload } from './deep-link' +import { attachDevLogWindow, installDevLogBridge } from './dev-log-bridge' +import { titleSuffix } from './dev-scope' import { setupIPC, getOrCreateStorage } from './ipc' import { startLocalAPI, stopLocalAPI, setupLocalAPIIPC } from './local-api' import { setupMeetingCaptureIPC } from './meeting-capture-ipc' @@ -25,6 +27,11 @@ import { setupSocialImportIPC } from './social-import-ipc' import { setupStorybookIPC, stopStorybook } from './storybook-ipc' import { initAutoUpdater } from './updater' +// Capture main-process console output for the renderer console (0413). First +// statement after the imports so a failure during early module init is still +// buffered — that is the whole point of the ring buffer. +installDevLogBridge() + // Enable remote debugging in development for Playwright/CDP testing // CDP port is configurable via ELECTRON_CDP_PORT env var (default: 9223) if (process.env.NODE_ENV === 'development') { @@ -153,9 +160,38 @@ function handleDeepLink(rawUrl: string): void { } } +/** + * Who holds this profile's Chromium single-instance lock, as `host-pid`. + * + * `SingletonLock` is a symlink whose target names the holder; reading the link + * is enough and never touches the lock itself. Returns `null` when it is + * missing or unreadable — "unknown holder" and "no holder" are different facts, + * and the caller prints them differently. + */ +function readSingletonLockHolder(userDataPath: string): string | null { + try { + return readlinkSync(join(userDataPath, 'SingletonLock')) + } catch { + return null + } +} + const hasSingleInstanceLock = app.requestSingleInstanceLock() if (!hasSingleInstanceLock) { - app.quit() + // Losing the lock used to be `app.quit()` — no message, **exit 0** (0413). + // An agent read that as success, attached to the CDP port, and drove whatever + // instance already owned it: a different worktree, a different branch, a + // healthy-looking app. Every step succeeded and the verification was false. + // A failure the caller cannot distinguish from success is a bug, not a guard. + const userData = app.getPath('userData') + console.error( + `[xnet-dev] FATAL: profile "${profile}" is already running ` + + `(lock held by ${readSingletonLockHolder(userData) ?? 'an unidentified process'}).\n` + + `[xnet-dev] userData: ${userData}\n` + + `[xnet-dev] Another worktree or a stale process owns this profile. ` + + `Set XNET_PROFILE, or run: pnpm --filter xnet-desktop dev:clean` + ) + app.exit(1) } app.on('second-instance', (_event, argv) => { @@ -181,8 +217,11 @@ app.on('open-url', (event, url) => { const dbPath = join(dataPath, 'data.db') async function createWindow() { - // Show profile in title for multi-instance testing - const title = profile === 'default' ? 'xNet' : `xNet (${profile})` + // Identify the instance in the title, in development, for EVERY profile + // including `default` (0413). Pre-0413 a `default` instance was titled plain + // `xNet`, so two of them — the exact collision worth catching — were + // indistinguishable. Production keeps the clean product name. + const title = process.env.NODE_ENV === 'development' ? `xNet (${titleSuffix(profile)})` : 'xNet' mainWindow = new BrowserWindow({ width: 1200, @@ -226,6 +265,10 @@ async function createWindow() { mainWindow.webContents.on('did-finish-load', () => { bootTrace('renderer loaded') + // Flush everything main logged before a renderer existed (0413). Done here + // rather than at window creation so the renderer's own listener is already + // registered and the flush cannot race it. + if (mainWindow) attachDevLogWindow(mainWindow) if (pendingSharePayload) { mainWindow?.webContents.send('xnet:share-payload', { payload: pendingSharePayload }) pendingSharePayload = null diff --git a/apps/electron/src/main/profile-lock-order.test.ts b/apps/electron/src/main/profile-lock-order.test.ts new file mode 100644 index 000000000..cd9fe9bfd --- /dev/null +++ b/apps/electron/src/main/profile-lock-order.test.ts @@ -0,0 +1,74 @@ +/** + * `app.setPath('userData', …)` must run before `app.requestSingleInstanceLock()` + * (exploration 0413). + * + * Chromium keys the single-instance lock on the userData directory. That one + * fact is what lets two git worktrees run the desktop app at the same time: + * each resolves its own profile, so each gets its own userData, so each gets + * its own lock — for free, with no coordination. + * + * The ordering that makes it work is invisible. `profile.ts` calls `setPath` at + * **module scope**, and `index.ts` gets that side effect only because it + * imports `profile` before it calls `requestSingleInstanceLock()`. Wrap the + * `setPath` in a function that runs on `whenReady`, or hoist the lock above the + * import, and every worktree silently collapses back onto one lock — with the + * failure showing up as "the second app just exits", which is the exact + * symptom 0413 exists to eliminate. + * + * Asserted by reading the source, matching `cdp-dev-only.test.ts`: this is a + * structural invariant, and booting Electron to check it would be both slower + * and less specific about what broke. + */ +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const here = dirname(fileURLToPath(import.meta.url)) +const profileSource = readFileSync(join(here, 'profile.ts'), 'utf8') +const mainSource = readFileSync(join(here, 'index.ts'), 'utf8') + +function stripComments(source: string): string { + return source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '') +} + +describe('userData is redirected before the single-instance lock is taken', () => { + const profileCode = stripComments(profileSource) + const mainCode = stripComments(mainSource) + + it('profile.ts calls setPath at module scope, not inside a function', () => { + const index = profileCode.indexOf("app.setPath('userData'") + expect(index).toBeGreaterThan(-1) + + // Only an `if` block may wrap it. A `function`/`=>` between the top of the + // file and the call means it now runs on demand rather than on import. + const before = profileCode.slice(0, index) + expect(before).not.toMatch(/\bfunction\b|=>/) + }) + + it('index.ts imports profile before requesting the lock', () => { + const importIndex = mainCode.indexOf("from './profile'") + const lockIndex = mainCode.indexOf('requestSingleInstanceLock()') + + expect(importIndex).toBeGreaterThan(-1) + expect(lockIndex).toBeGreaterThan(-1) + expect(importIndex).toBeLessThan(lockIndex) + }) + + it('index.ts imports nothing that takes the lock earlier', () => { + // The lock must be requested exactly once, and from index.ts — a second + // caller elsewhere would race the redirect no matter what this file does. + const matches = mainCode.match(/requestSingleInstanceLock\(\)/g) ?? [] + expect(matches).toHaveLength(1) + }) + + it('exits non-zero when the lock is lost, rather than quitting silently', () => { + const lockIndex = mainCode.indexOf('requestSingleInstanceLock()') + const after = mainCode.slice(lockIndex, lockIndex + 1200) + + // `app.quit()` here was the original bug: it exits 0, so a caller cannot + // tell "another instance owns this profile" from "started fine". + expect(after).toMatch(/app\.exit\(1\)/) + expect(after).not.toMatch(/app\.quit\(\)/) + }) +}) diff --git a/apps/electron/src/main/profile.ts b/apps/electron/src/main/profile.ts index 8aa528abc..50fc30a02 100644 --- a/apps/electron/src/main/profile.ts +++ b/apps/electron/src/main/profile.ts @@ -1,12 +1,23 @@ import { join } from 'path' import { app } from 'electron' +import { devScope } from './dev-scope' // Profile support for running multiple instances with separate data. -// Usage: XNET_PROFILE=user2 pnpm dev:electron -export const profile = process.env.XNET_PROFILE || 'default' +// +// A linked git worktree scopes itself automatically — the dev launcher resolves +// `wt-` and exports it as XNET_PROFILE (0413). Set XNET_PROFILE by +// hand to override, as `dev:user2` does. +export const profile = devScope.profile || process.env.XNET_PROFILE || 'default' // Set separate user data path for each profile before app readiness. // This isolates local app storage, localStorage, cookies, etc. between profiles. +// +// ORDER IS LOAD-BEARING: Chromium keys the single-instance lock on the userData +// directory, so this must run before `app.requestSingleInstanceLock()` in +// index.ts — that is what gives each profile its own lock, and therefore what +// lets two worktrees run at once. It works because this file is imported for +// its side effect at module scope. `profile-lock-order.test.ts` pins it; do not +// move either half into a function. if (profile !== 'default') { const userDataPath = join(app.getPath('userData'), '..', `xnet-desktop-${profile}`) app.setPath('userData', userDataPath) diff --git a/apps/electron/src/preload/index.ts b/apps/electron/src/preload/index.ts index f6d8bae88..9f3169559 100644 --- a/apps/electron/src/preload/index.ts +++ b/apps/electron/src/preload/index.ts @@ -647,6 +647,48 @@ export interface XNetNodesAPI { onChange(callback: (event: { changes: unknown[] }) => void): () => void } +/** + * Development-only instance provenance (0413). + * + * The failure this answers: an agent attaches to a CDP port, gets a healthy + * desktop app, and has no way to tell whether it belongs to this worktree or + * another one. The window title was the documented signal and it was constant + * (`xNet`) for the `default` profile — useless in exactly the collision case. + * + * Deliberately on `window` rather than behind an HTTP endpoint: the local API + * is token-gated with a per-session UUID printed only to stdout, so an agent + * that attached later cannot read it. This rides the channel the agent already + * has — one `browser_evaluate(() => window.__xnetDev)`. + * + * Never exposed in production; `cdp-dev-only.test.ts` asserts the guard. + */ +function parseDevScope(): Record { + try { + const parsed: unknown = JSON.parse(process.env.XNET_DEV_SCOPE || '{}') + return parsed && typeof parsed === 'object' ? (parsed as Record) : {} + } catch { + return {} + } +} + +if (process.env.NODE_ENV === 'development') { + contextBridge.exposeInMainWorld('__xnetDev', { + ...parseDevScope(), + pid: process.pid, + startedAt: new Date().toISOString(), + /** Main + data-process records, including everything before first paint. */ + logs: () => ipcRenderer.invoke('xnet:dev:logs') + }) + + // Main-process records arrive here and land in the renderer console, so + // `browser_console_messages` covers all three processes instead of one. + ipcRenderer.on('xnet:dev:log', (_event, record) => { + const level = record?.level === 'debug' ? 'log' : (record?.level ?? 'log') + const write = (console as unknown as Record void>)[level] + write?.call(console, `[${record?.source ?? 'main'}]`, record?.message ?? '') + }) +} + declare global { interface Window { xnet: XNetAPI diff --git a/docs/explorations/0413_[_]_PLURAL_ELECTRON_WORKTREE_SCOPED_DESKTOP_DEV.md b/docs/explorations/0413_[_]_PLURAL_ELECTRON_WORKTREE_SCOPED_DESKTOP_DEV.md index 137371e28..ca66df39a 100644 --- a/docs/explorations/0413_[_]_PLURAL_ELECTRON_WORKTREE_SCOPED_DESKTOP_DEV.md +++ b/docs/explorations/0413_[_]_PLURAL_ELECTRON_WORKTREE_SCOPED_DESKTOP_DEV.md @@ -8,7 +8,7 @@ tags: [electron, agents, dx, tooling, testing] # Plural Electron — Making The Desktop Loop Survive A Fleet Of Agents > [!TIP] -> **TL;DR** — [0404](<0404_[-]_ELECTRON_PROTOTYPING_LOOP_FOR_AGENTS.md>) fixed the +> **TL;DR** — [0404](0404_[-]_ELECTRON_PROTOTYPING_LOOP_FOR_AGENTS.md) fixed the > desktop loop for **one agent, on one tree, running one app**. That is not how > this repo is worked: `git worktree list` returns **87 trees, 41 of them agent > worktrees**. The web loop is _plural_ — every worktree gets its own port and the @@ -59,7 +59,7 @@ environment being singular while the way this repo is actually worked is plural. name in the title bar is the cheapest signal."_ [`index.ts`](../../apps/electron/src/main/index.ts) sets `profile === 'default' ? 'xNet' : \`xNet (${profile})\``. Both the incumbent and - the newcomer are `default`. Both are titled `xNet`. The signal is constant in +the newcomer are `default`. Both are titled `xNet`. The signal is constant in exactly the case it exists to detect. - **The app publishes no provenance at all.** No `define:` for a commit or branch, no build stamp, nothing on `window`. An attached agent cannot answer "which tree @@ -83,17 +83,17 @@ environment being singular while the way this repo is actually worked is plural. ### What 0404 actually delivered -| Item | Status | Evidence | -| ---------------------------------------- | -------------- | ------------------------------------------------------------------------ | -| `playwright-electron` MCP registered | ✅ Shipped | [`.mcp.json`](../../.mcp.json) — `--cdp-endpoint http://127.0.0.1:9223` | -| Renderer launch entries | ✅ Shipped | `electron-renderer (vite 5177)` in `.claude/launch.json` | -| Stamped native rebuild | ✅ Shipped | [`scripts/rebuild-if-stale.mjs`](../../apps/electron/scripts/rebuild-if-stale.mjs) | -| `dev:user2` gets its own CDP port | ✅ Shipped | `ELECTRON_CDP_PORT=9224` in `package.json` | -| Prod bundle has no debugging port | ✅ Shipped | [`cdp-dev-only.test.ts`](../../apps/electron/src/main/cdp-dev-only.test.ts) | -| `electron-prototype` skill | ✅ Shipped | 106 lines, three rungs | -| **Multi-tree isolation** | ❌ Not scoped | 0404 assumed one instance | -| **Instance provenance** | ❌ Not scoped | mitigation is prose, not a probe | -| **Main-process observability** | ❌ Not scoped | CDP is renderer-only | +| Item | Status | Evidence | +| ------------------------------------ | ------------- | ---------------------------------------------------------------------------------- | +| `playwright-electron` MCP registered | ✅ Shipped | [`.mcp.json`](../../.mcp.json) — `--cdp-endpoint http://127.0.0.1:9223` | +| Renderer launch entries | ✅ Shipped | `electron-renderer (vite 5177)` in `.claude/launch.json` | +| Stamped native rebuild | ✅ Shipped | [`scripts/rebuild-if-stale.mjs`](../../apps/electron/scripts/rebuild-if-stale.mjs) | +| `dev:user2` gets its own CDP port | ✅ Shipped | `ELECTRON_CDP_PORT=9224` in `package.json` | +| Prod bundle has no debugging port | ✅ Shipped | [`cdp-dev-only.test.ts`](../../apps/electron/src/main/cdp-dev-only.test.ts) | +| `electron-prototype` skill | ✅ Shipped | 106 lines, three rungs | +| **Multi-tree isolation** | ❌ Not scoped | 0404 assumed one instance | +| **Instance provenance** | ❌ Not scoped | mitigation is prose, not a probe | +| **Main-process observability** | ❌ Not scoped | CDP is renderer-only | 0404 was right about everything it looked at. It looked at a single seat. @@ -170,16 +170,16 @@ exit was read from source rather than re-run — see ### The asymmetry, service by service -| Resource | Web loop | Electron loop | -| ---------------- | --------------------------------------------- | -------------------------------------------------- | -| Dev server port | per-worktree entry, explicit `--strictPort` | `5177`, **no** `strictPort` → silent drift | -| Driving endpoint | the tab you opened; URL names the port | `9223`, global, first-come | -| App state | OPFS, keyed by origin ⇒ keyed by port | one `userData`; profiles exist but are opt-in | -| Mutual exclusion | none needed | machine-wide `SingletonLock` | -| Provenance | the URL **is** the identity | none | -| Collision result | Vite prints the port it actually took | `app.quit()`, **exit 0**, no message | -| Process coverage | one process; console is the whole app | 3 processes; CDP sees 1 | -| Cleanup | kill the server, nothing persists | 18 orphaned `userData` dirs | +| Resource | Web loop | Electron loop | +| ---------------- | ------------------------------------------- | --------------------------------------------- | +| Dev server port | per-worktree entry, explicit `--strictPort` | `5177`, **no** `strictPort` → silent drift | +| Driving endpoint | the tab you opened; URL names the port | `9223`, global, first-come | +| App state | OPFS, keyed by origin ⇒ keyed by port | one `userData`; profiles exist but are opt-in | +| Mutual exclusion | none needed | machine-wide `SingletonLock` | +| Provenance | the URL **is** the identity | none | +| Collision result | Vite prints the port it actually took | `app.quit()`, **exit 0**, no message | +| Process coverage | one process; console is the whole app | 3 processes; CDP sees 1 | +| Cleanup | kill the server, nothing persists | 18 orphaned `userData` dirs | > [!IMPORTANT] > The web loop never needed an isolation design because **a port is already an @@ -191,13 +191,13 @@ exit was read from source rather than re-run — see This is the encouraging part. Nearly every primitive needed is already in the tree, applied to a narrower problem: -| Primitive | Exists for | Wants to be | -| -------------------------------------------------------------------------------- | -------------------------------------- | ---------------------------------------- | -| [`local-api-config.ts`](../../apps/electron/src/main/local-api-config.ts) `stableProfileOffset()` | one port, from `XNET_PROFILE` | **all** ports, from the worktree | -| [`profile.ts`](../../apps/electron/src/main/profile.ts) | `XNET_PROFILE=user2` opt-in | defaulted from the worktree | -| [`cdp-dev-only.test.ts`](../../apps/electron/src/main/cdp-dev-only.test.ts) | asserting prod has no debug port | the template for a dev-only provenance guard | -| `window.__xnetNodeStore` / `__xnetSchemaIds` | e2e specs | the same channel, carrying dev metadata | -| [`data-process-manager.ts:113-119`](../../apps/electron/src/main/data-process-manager.ts) | piping child stdio to stderr | piping it somewhere CDP can read | +| Primitive | Exists for | Wants to be | +| ------------------------------------------------------------------------------------------------- | -------------------------------- | -------------------------------------------- | +| [`local-api-config.ts`](../../apps/electron/src/main/local-api-config.ts) `stableProfileOffset()` | one port, from `XNET_PROFILE` | **all** ports, from the worktree | +| [`profile.ts`](../../apps/electron/src/main/profile.ts) | `XNET_PROFILE=user2` opt-in | defaulted from the worktree | +| [`cdp-dev-only.test.ts`](../../apps/electron/src/main/cdp-dev-only.test.ts) | asserting prod has no debug port | the template for a dev-only provenance guard | +| `window.__xnetNodeStore` / `__xnetSchemaIds` | e2e specs | the same channel, carrying dev metadata | +| [`data-process-manager.ts:113-119`](../../apps/electron/src/main/data-process-manager.ts) | piping child stdio to stderr | piping it somewhere CDP can read | `stableProfileOffset()` is the standout — the repo already wrote a hash-string-to-stable-port-offset helper and used it for exactly one of the four @@ -234,12 +234,12 @@ Same diagnosis, same prescription. 0404 rejected two servers on maturity. One new entrant has appeared since: -| Server | Stars | Licence | Attaches or launches? | Verdict | -| -------------------------------------------------------------------------------- | ----- | ------- | ---------------------- | --------------- | -| [`microsoft/playwright-mcp`](https://github.com/microsoft/playwright-mcp) `--cdp-endpoint` | ~35.5k | Apache-2.0 | **attaches** | ✅ In use | -| [`mesomya/electron-driver`](https://github.com/mesomya/electron-driver) | 3 | MIT | **launches** (`start_app`) | 🛑 Reject | -| [`kanishka-namdeo/electron-mcp`](https://github.com/kanishka-namdeo/electron-mcp) | 1 | — | mixed | 🛑 Reject (0404) | -| [`lazy-dinosaur/electron-test-mcp`](https://github.com/lazy-dinosaur/electron-test-mcp) | 0 | none | mixed | 🛑 Reject (0404) | +| Server | Stars | Licence | Attaches or launches? | Verdict | +| ------------------------------------------------------------------------------------------ | ------ | ---------- | -------------------------- | ---------------- | +| [`microsoft/playwright-mcp`](https://github.com/microsoft/playwright-mcp) `--cdp-endpoint` | ~35.5k | Apache-2.0 | **attaches** | ✅ In use | +| [`mesomya/electron-driver`](https://github.com/mesomya/electron-driver) | 3 | MIT | **launches** (`start_app`) | 🛑 Reject | +| [`kanishka-namdeo/electron-mcp`](https://github.com/kanishka-namdeo/electron-mcp) | 1 | — | mixed | 🛑 Reject (0404) | +| [`lazy-dinosaur/electron-test-mcp`](https://github.com/lazy-dinosaur/electron-test-mcp) | 0 | none | mixed | 🛑 Reject (0404) | > [!NOTE] > `electron-driver` is worth reading even though we should not install it. Its 38 @@ -323,15 +323,15 @@ a constant. The generalisation is mechanical. ### 4. CDP is one third of an observability story -| Process | Where its output goes | Reachable by an attached agent? | -| -------------- | -------------------------------- | -------------------------------------------- | -| renderer | CDP console domain | ✅ `browser_console_messages` | -| main | `pnpm dev` stdout (13 call sites)| ❌ unless the agent also owns the terminal | -| data-process | inherited stdio → same stdout | ❌ same | +| Process | Where its output goes | Reachable by an attached agent? | +| ------------ | --------------------------------- | ------------------------------------------ | +| renderer | CDP console domain | ✅ `browser_console_messages` | +| main | `pnpm dev` stdout (13 call sites) | ❌ unless the agent also owns the terminal | +| data-process | inherited stdio → same stdout | ❌ same | There is no log file — `app.getPath('logs')` is unused. If the agent started the app with `preview_start`, `preview_logs` recovers stdout, and that is the one path -that works today. But it is a *second* channel with different semantics, it is +that works today. But it is a _second_ channel with different semantics, it is absent whenever a human started the app, and nothing in the skill mentions it. ### 5. Ports drift silently because nobody asked for `strictPort` @@ -353,22 +353,22 @@ profile is real?" a research task. ## Options And Tradeoffs -| Option | Effort | Fixes cardinality | Fixes provenance | Fixes main-process | Verdict | -| ------------------------------------------------------------ | ------ | ----------------- | ---------------- | ------------------ | ---------------- | -| **A** — Do nothing; 0404 is enough | 0 | ❌ | ❌ | ❌ | 🛑 Rejected | -| **B** — Document the hazard harder in the skill | S | ❌ | ❌ | ❌ | 🛑 Rejected | -| **C** — Install `electron-driver` MCP | S | ❌ | ❌ | ✅ | 🛑 Rejected | -| **D** — Build a bespoke xNet Electron MCP server | L | ⚠️ possible | ✅ | ✅ | 🛑 Rejected | -| **E** — Browser shim for the renderer | M | ✅ trivially | n/a | n/a | 🛑 Rejected (0404) | -| **F** — Migrate runtime (Tauri / Deno) for a nicer loop | XL | ❌ | ❌ | ❌ | 🛑 Out of scope | -| **G** — **Worktree-scoped dev + provenance + log bridge** | M | ✅ | ✅ | ✅ | ✅ **Recommend** | +| Option | Effort | Fixes cardinality | Fixes provenance | Fixes main-process | Verdict | +| --------------------------------------------------------- | ------ | ----------------- | ---------------- | ------------------ | ------------------ | +| **A** — Do nothing; 0404 is enough | 0 | ❌ | ❌ | ❌ | 🛑 Rejected | +| **B** — Document the hazard harder in the skill | S | ❌ | ❌ | ❌ | 🛑 Rejected | +| **C** — Install `electron-driver` MCP | S | ❌ | ❌ | ✅ | 🛑 Rejected | +| **D** — Build a bespoke xNet Electron MCP server | L | ⚠️ possible | ✅ | ✅ | 🛑 Rejected | +| **E** — Browser shim for the renderer | M | ✅ trivially | n/a | n/a | 🛑 Rejected (0404) | +| **F** — Migrate runtime (Tauri / Deno) for a nicer loop | XL | ❌ | ❌ | ❌ | 🛑 Out of scope | +| **G** — **Worktree-scoped dev + provenance + log bridge** | M | ✅ | ✅ | ✅ | ✅ **Recommend** | **Why not B.** The skill already carries the warning, in the imperative — _"Confirm which instance you attached to before trusting anything."_ It cannot be followed: the app publishes nothing to confirm against, and the one signal it names is constant in the collision case. Sharpening prose that describes an impossible check makes the instructions worse, not better. This is the -[0401](<0401_[_]_AGENT_NATIVE_SKILLS_AUDIT.md>) failure mode — a rule nothing +[0401](0401_[_]_AGENT_NATIVE_SKILLS_AUDIT.md) failure mode — a rule nothing enforces — repeating one layer down. **Why not C.** Three stars, nine commits, and it `start_app`s rather than attaching, @@ -388,8 +388,8 @@ changed: the preload exposes 10 `contextBridge` namespaces that the renderer cal at ~76 sites with almost no guards. `apps/web` is already the browser-native surface. A shim would build a third surface that lies about the first two. -**Why not F.** [0009](<0009_[x]_TAURI_VS_ELECTRON.md>) and -[0251](<0251_[_]_ELECTRON_TO_DENO_DESKTOP_MIGRATION.md>) own the runtime question. +**Why not F.** [0009](0009_[x]_TAURI_VS_ELECTRON.md) and +[0251](0251_[_]_ELECTRON_TO_DENO_DESKTOP_MIGRATION.md) own the runtime question. Whatever their answer, none of the four defects here are Electron's fault — they are the consequence of never having derived our dev identity from our unit of work. A Tauri app with hard-coded ports would collide identically. @@ -498,7 +498,7 @@ require: `browser_console_messages` then covers all three processes. Dev-only; production logging is a separate concern owned by -[0315](<0315_[x]_FIRST_PARTY_ERROR_TELEMETRY_AND_DEBUG_REPORT_CONSOLE.md>). +[0315](0315_[x]_FIRST_PARTY_ERROR_TELEMETRY_AND_DEBUG_REPORT_CONSOLE.md). ### 4. Make collisions loud @@ -737,44 +737,44 @@ if (!mine) { ### Scope (the change that matters) -- [ ] Add `apps/electron/src/main/dev-scope.ts` with `resolveDevScope()`, deriving +- [x] Add `apps/electron/src/main/dev-scope.ts` with `resolveDevScope()`, deriving profile from the linked-worktree basename and a ten-port block from `stableProfileOffset()`. -- [ ] Resolve the scope **once** in the dev script and pass it to Electron and Vite +- [x] Resolve the scope **once** in the dev script and pass it to Electron and Vite as `XNET_DEV_SCOPE`, so `process.cwd()` is read in exactly one place. -- [ ] Feed `profile.ts` from the resolved scope; keep `XNET_PROFILE` as an override +- [x] Feed `profile.ts` from the resolved scope; keep `XNET_PROFILE` as an override and keep the main checkout on `default` with today's ports. -- [ ] Point `electron.vite.config.ts`, the CDP switch in `index.ts`, +- [x] Point `electron.vite.config.ts`, the CDP switch in `index.ts`, `resolveLocalAPIPort()` and the dev hub at the block instead of their constants. -- [ ] Add a test pinning that `app.setPath('userData', …)` runs before +- [x] Add a test pinning that `app.setPath('userData', …)` runs before `requestSingleInstanceLock()`. ### Identify -- [ ] Expose `window.__xnetDev` (tree, branch, commit, profile, ports, pid, +- [x] Expose `window.__xnetDev` (tree, branch, commit, profile, ports, pid, `startedAt`, `logs()`) from the preload, dev-only. -- [ ] Include the profile in the window title for every profile, including +- [x] Include the profile in the window title for every profile, including `default`. -- [ ] Extend `cdp-dev-only.test.ts` to assert production bundles contain no +- [x] Extend `cdp-dev-only.test.ts` to assert production bundles contain no `__xnetDev`. ### Observe -- [ ] Add a bounded ring buffer for main and data-process records, tagged +- [x] Add a bounded ring buffer for main and data-process records, tagged `[main]` / `[data]`. -- [ ] Flush the buffer to the renderer console when a window appears; forward live +- [x] Flush the buffer to the renderer console when a window appears; forward live thereafter. -- [ ] Wire `xnet:dev:logs` IPC so `window.__xnetDev.logs()` returns pre-paint +- [x] Wire `xnet:dev:logs` IPC so `window.__xnetDev.logs()` returns pre-paint records. ### Fail loudly -- [ ] Set `strictPort: true` on the renderer server. -- [ ] Replace `app.quit()` on lock loss with a named error naming the lock holder +- [x] Set `strictPort: true` on the renderer server. +- [x] Replace `app.quit()` on lock loss with a named error naming the lock holder and `process.exit(1)`. -- [ ] Add the CDP identity assertion and refuse to print ready if it fails. -- [ ] Print the `[xnet-dev] ready {…}` machine-readable line. +- [x] Add the CDP identity assertion and refuse to print ready if it fails. +- [x] Print the `[xnet-dev] ready {…}` machine-readable line. ### Then, the instructions @@ -812,12 +812,12 @@ if (!mine) { ### In this repository -- [0404 — The Electron prototyping loop, built, documented, unwired](<0404_[-]_ELECTRON_PROTOTYPING_LOOP_FOR_AGENTS.md>) — wired the single-seat loop; this doc is its plural sequel -- [0406 — One shell, two surfaces](<0406_[x]_ONE_SHELL_TWO_SURFACES_ENDING_THE_DESKTOP_WEB_UI_FORK.md>) — the workbench is the only desktop shell -- [0401 — The agent-native skill library](<0401_[_]_AGENT_NATIVE_SKILLS_AUDIT.md>) — rules nothing enforces -- [0238 — Electron desktop parity, sync and automated deep testing](<0238_[x]_ELECTRON_DESKTOP_PARITY_SYNC_AND_AUTOMATED_DEEP_TESTING.md>) — the rung-3 harness -- [0315 — First-party error telemetry](<0315_[x]_FIRST_PARTY_ERROR_TELEMETRY_AND_DEBUG_REPORT_CONSOLE.md>) — owns production logging -- [0009 — Tauri vs Electron](<0009_[x]_TAURI_VS_ELECTRON.md>) · [0251 — Electron to Deno](<0251_[_]_ELECTRON_TO_DENO_DESKTOP_MIGRATION.md>) — the runtime question, out of scope here +- [0404 — The Electron prototyping loop, built, documented, unwired](0404_[-]_ELECTRON_PROTOTYPING_LOOP_FOR_AGENTS.md) — wired the single-seat loop; this doc is its plural sequel +- [0406 — One shell, two surfaces](0406_[x]_ONE_SHELL_TWO_SURFACES_ENDING_THE_DESKTOP_WEB_UI_FORK.md) — the workbench is the only desktop shell +- [0401 — The agent-native skill library](0401_[_]_AGENT_NATIVE_SKILLS_AUDIT.md) — rules nothing enforces +- [0238 — Electron desktop parity, sync and automated deep testing](0238_[x]_ELECTRON_DESKTOP_PARITY_SYNC_AND_AUTOMATED_DEEP_TESTING.md) — the rung-3 harness +- [0315 — First-party error telemetry](0315_[x]_FIRST_PARTY_ERROR_TELEMETRY_AND_DEBUG_REPORT_CONSOLE.md) — owns production logging +- [0009 — Tauri vs Electron](0009_[x]_TAURI_VS_ELECTRON.md) · [0251 — Electron to Deno](0251_[_]_ELECTRON_TO_DENO_DESKTOP_MIGRATION.md) — the runtime question, out of scope here - [`apps/electron/AGENTS.md`](../../apps/electron/AGENTS.md) · [`.claude/skills/electron-prototype/SKILL.md`](../../.claude/skills/electron-prototype/SKILL.md) — the instructions this changes ### External diff --git a/site/public/llms-full.txt b/site/public/llms-full.txt index d24ceaf76..6a8845a88 100644 --- a/site/public/llms-full.txt +++ b/site/public/llms-full.txt @@ -56,6 +56,7 @@ Before reading this documentation, understand that xNet works differently: - Real-time Collaboration - Plugin Development - Agent Interfaces + - Use xNet from Claude Code / Codex - Hub Setup - Your Own Server - Use xNet From Any Framework @@ -2428,9 +2429,22 @@ interface FileRef { name: string // Original filename mimeType: string // MIME type size: number // File size in bytes + width?: number // Intrinsic width, images and video + height?: number // Intrinsic height, images and video + thumbCid?: string // Small preview, stored as its own blob } ``` +In a database, a `file` field renders each attachment as a chip — a thumbnail +for images, an icon otherwise — and clicking one opens a full-screen viewer +with paging and download. Set `multiple` to keep several files in one cell. + +Only the `FileRef` travels through the change log; the bytes are stored +content-addressed and moved separately, uploaded to the hub in the background +after attaching and fetched by peers on first view. An attachment whose bytes +haven't reached this device yet reads "on another device" rather than +appearing broken. + --- ## Auto-populated types @@ -7548,6 +7562,95 @@ files can't express, MCP only when there's no shell.** --- +## Use xNet from Claude Code / Codex + +**You will learn** + +- How to connect a coding agent to your workspace with one command +- The three lanes an agent uses, cheapest first +- How writes stay safe when the app and the agent touch the same data + + +The same way people point Claude Code at an Obsidian vault, you can point it at +your **xNet workspace** — and read, search, query, and edit your pages, +databases, and canvases without leaving the terminal. The +[Agent Interfaces](/docs/guides/agent-interfaces/) guide covers the surfaces in +depth; this one is the quick on-ramp. + +## One-step connect + +```bash +xnet connect claude-code # project scope, read-only +xnet connect claude-code --user # also install the skill for all projects +xnet connect claude-code --writes # register the MCP server with write access +xnet connect codex # Codex: AGENTS.md + .codex/config.toml +``` + +`xnet connect` is **idempotent** — it installs the `SKILL.md`, registers the +`xnet` MCP server, writes a `CLAUDE.md`/`AGENTS.md` contract, and (with +`--vault `) bootstraps a scoped checkout with an index. Re-running it +reports what is already in place and changes nothing. It finishes by running +`xnet doctor --agent-access`, which confirms the backend, full-text search, and +signing identity are all reachable. + +## Works whether or not the app is running + +The agent verbs resolve a backend automatically: + +1. the desktop app's local API (`:31415`) when it is running, otherwise +2. a standalone SQLite store — discovered from the app's data directory, or + pointed at explicitly with `--db ` (or `$XNET_DB`). + +So an agent can work against your data even with the app closed, and web-only +users can point at any local store. Writes made this way are signed by a key you +provide (`--key`, `$XNET_SIGNING_KEY`) or by an +[enrolled agent passport](/docs/guides/identity/) (`--agent `); the CLI +refuses to persist a write under a silent throwaway identity. + +## Three lanes, cheapest first + +```mermaid +flowchart LR + A[Agent in Claude Code / Codex] -->|Bash| CLI[1 · xnet CLI] + A -->|Read / Edit / Grep| V[2 · Vault checkout] + A -->|MCP client| M[3 · xnet mcp serve] + CLI --> Core[(xNet store · signed change log)] + V <-->|checkout / commit| CLI + M --> Core +``` + +1. **CLI** — `xnet search`/`query`/`db get` print plain stdout. Reach here + first: it costs far fewer tokens than loading a wall of MCP tool schemas. +2. **Vault** — `xnet checkout --query "…"` materializes a **scoped** slice as + Markdown you edit, then `xnet commit` lifts the edits back through validated + mutation plans. Never check out the whole workspace. +3. **MCP** — the registered `xnet` server is the fallback for shell-less + clients (Claude Desktop, browser). It is **read-only** unless you connected + with `--writes`. + +## Safe when the app and the agent collide + +The store stays the source of truth, and every edit becomes a validated plan — +never a blind file-to-database sync. If the app writes a node after you checked +it out, committing your now-stale vault edit surfaces a **conflict** instead of +overwriting the newer value: + +```bash +$ xnet status +conflict Pages/shared-page.md stale-export based on updatedAt:10, live is updatedAt:11 +``` + +Fix the file against the current value and commit again. Stale edits never +silently win. + +## Further reading + +- [Agent Interfaces](/docs/guides/agent-interfaces/) — the checkout layout, the + full CLI, `SKILL.md`, and the slim MCP surface +- [Identity](/docs/guides/identity/) — agent passports and signing + +--- + ## Hub Setup **You will learn** @@ -10457,6 +10560,45 @@ deployment of *our* server; `@xnetjs/server` remains the library for building **Tradeoff:** Two server documentation surfaces. Accepted; they answer different questions. +## ADR-28: No durable-execution orchestrator; reconcilers instead + +**Status:** Accepted +**Context:** Exploration 0411 evaluated adopting [Temporal](https://temporal.io/) +(and Restate, DBOS, `pg_durable`, Inngest, Cloudflare Workflows) for the parts +of the stack that look workflow-shaped: tenant provisioning, staged fleet +rollouts, nightly restore drills, and the non-payment lifecycle. + +**Decision:** **Do not adopt a workflow engine.** Fix the three real durability +gaps in-repo with primitives over the `DocStore` port the control plane already +ships — a compensating `saga()`, a leased due-based job runner, and a durable +`RolloutRun` checkpoint. + +**A workflow engine may only ever be considered for `apps/cloud`.** It must +never enter `packages/hub`, `packages/server`, or any client package. + +**Rationale:** + +- Temporal and its peers provide durable *execution*; xNet's hard problem is + durable *state*, which CRDTs and the signed change log already solve. The + position was already recorded in exploration 0332's landscape survey. +- It cannot run where most xNet work happens. The Temporal TypeScript SDK runs + workflow code in a Node-only deterministic sandbox against a server cluster — + there is no browser or offline story, and the device is xNet's primary. +- In `packages/hub` it would break a Charter §6 receipt ("the hub is a single + self-contained process"), failing the **BATNA** test: a self-hoster would have + to operate a workflow cluster to run their own hub. +- The control plane is deliberately built as pure decision functions plus + level-triggered reconcilers, which are restart-safe by construction. Only two + modules deviated, and both were ordinary bugs rather than a missing capability. +- Every candidate bottoms out in a datastore we do not run. There is no Postgres + anywhere in the stack (SQLite + Firestore only), so the usual "just use DBOS" + answer is unavailable. + +**Tradeoff:** We maintain ~300 lines of scheduling and compensation logic +ourselves. Accepted, and bounded: exploration 0411 records six decidable +tripwires that re-open the question, and treats the primitives growing past +~500 LOC as a tripwire in its own right. + --- ## Package Graph From 4913d9c86d992a5fbf550868d3c59ed909b5bd23 Mon Sep 17 00:00:00 2001 From: xNet Test Date: Fri, 31 Jul 2026 07:11:06 -0700 Subject: [PATCH 3/6] docs(electron): teach the derived ports and the provenance check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The port tables in apps/electron/AGENTS.md and the electron-prototype skill became wrong the moment ports started being derived per worktree, and the skill's "confirm which instance you attached to — the profile name in the title bar is the cheapest signal" was never followable: the title was plain `xNet` for the `default` profile, so a stale instance and your own were identical in exactly the case worth catching. Replaces both with the derivation rule, the `[xnet-dev] ready {…}` line to wait for, and `window.__xnetDev` as the authoritative answer to "which tree is this?". Drops the hand-cut `electron-renderer-worktree (vite 5178)` launch entry, which existed because there was only ever one worktree seat. Co-Authored-By: Claude Opus 5 Signed-off-by: xNet Test --- .claude/launch.json | 10 --- .claude/skills/electron-prototype/SKILL.md | 67 ++++++++++++---- apps/electron/AGENTS.md | 80 ++++++++++++++----- ...AL_ELECTRON_WORKTREE_SCOPED_DESKTOP_DEV.md | 2 +- 4 files changed, 112 insertions(+), 47 deletions(-) diff --git a/.claude/launch.json b/.claude/launch.json index 7f12579a1..72d2e1ba6 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -86,16 +86,6 @@ "port": 5177, "autoPort": false }, - { - "name": "electron-renderer-worktree (vite 5178)", - "runtimeExecutable": "/bin/sh", - "runtimeArgs": [ - "-c", - "VITE_PORT=5178 ELECTRON_CDP_PORT=9225 exec pnpm --filter xnet-desktop dev:electron" - ], - "port": 5178, - "autoPort": false - }, { "name": "expo (mobile)", "runtimeExecutable": "pnpm", diff --git a/.claude/skills/electron-prototype/SKILL.md b/.claude/skills/electron-prototype/SKILL.md index 88767b6c9..3888dd1b5 100644 --- a/.claude/skills/electron-prototype/SKILL.md +++ b/.claude/skills/electron-prototype/SKILL.md @@ -38,9 +38,18 @@ window chrome, real platform, real fonts. pnpm --filter xnet-desktop dev ``` -The main process opens CDP on `127.0.0.1:9223` in development -(`src/main/index.ts`). Attach with the **`playwright-electron`** MCP server — -registered in `.mcp.json`, already pointed at that endpoint. Then: +**Ports are derived from the worktree (0413), not fixed.** Wait for the +machine-readable ready line, which tells you where to attach: + +```text +[xnet-dev] ready {"profile":"wt-…","renderer":23400,"cdp":23401,…,"branch":"…"} +``` + +The main checkout keeps the legacy `9223`; every linked worktree gets its own +block. `pnpm --filter xnet-desktop dev:scope` prints this tree's ports without +starting anything. Attach with the **`playwright-electron`** MCP server — +registered in `.mcp.json`, pointed at `9223` — and pass `--cdp-endpoint` for a +worktree's port. Then: ```javascript localStorage.setItem('xnet:test:bypass', 'true') @@ -52,9 +61,34 @@ your first page`). Now `browser_snapshot`, `browser_click` and `browser_console_messages` drive the **real** desktop app: real preload, real IPC, real `better-sqlite3`. -**Confirm which instance you attached to** before trusting anything. A stale -Electron from a crashed run, or a second profile, will happily answer on 9223. -The profile name in the title bar is the cheapest signal. +## Confirm which instance you attached to + +Do this before trusting anything, and do it by asking — not by looking: + +```js +window.__xnetDev // { worktree, branch, commit, profile, ports, pid, startedAt, logs() } +``` + +This used to be "check the profile name in the title bar," which could not +work: the title was plain `xNet` for the `default` profile, so a stale instance +and your own were indistinguishable — the exact case worth catching. The title +now names the profile and commit in development, and `__xnetDev` is the +authoritative answer. + +`pnpm dev` also refuses to report ready if its CDP port is serving another +tree's renderer, and losing the single-instance lock is now a named error with +**exit 1** rather than a silent exit 0. + +## Logs from all three processes + +CDP shows you the renderer. `main` and `data-process` records now reach the +renderer console too, tagged `[main]` / `[data]`, so +`browser_console_messages` covers the whole app. For failures from **before** +the window existed — the ones that matter most — read the ring buffer: + +```js +await window.__xnetDev.logs() +``` ## Never test Electron in a browser @@ -66,21 +100,20 @@ already the browser-native surface. ## Ports -| Port | What | -| ---- | --------------------------- | -| 5177 | renderer (Vite) | -| 9223 | CDP, dev only | -| 9224 | CDP for the `user2` profile | -| 4444 | hub | - -Two instances for sync testing: +Derived per worktree — `blockBase = 20000 + hash(worktree) % 500 * 10`, then +`+0` renderer, `+1` CDP, `+2` hub, `+3` local API. The main checkout keeps +5177 / 9223 / 4444 / 31415 and the `default` profile. ```bash -pnpm --filter xnet-desktop dev:both +pnpm --filter xnet-desktop dev:scope # this tree's profile and ports +pnpm --filter xnet-desktop dev:clean # prune profiles whose worktree is gone +pnpm --filter xnet-desktop dev:both # two instances for sync testing ``` -`dev:user2` sets `ELECTRON_CDP_PORT=9224`, so the two instances no longer -collide on 9223. +Because each worktree also gets its own `userData`, it gets its own Chromium +single-instance lock — which is what lets two trees run at once. Override one +service with `VITE_PORT` / `ELECTRON_CDP_PORT` / `XNET_HUB_PORT` / +`XNET_LOCAL_API_PORT`, or the profile with `XNET_PROFILE`. ## The native rebuild diff --git a/apps/electron/AGENTS.md b/apps/electron/AGENTS.md index 25ceaa30e..07a29df14 100644 --- a/apps/electron/AGENTS.md +++ b/apps/electron/AGENTS.md @@ -9,11 +9,11 @@ or Expo. Pick by what the change touches, not by convenience. -| Rung | Loop | Use when the change is… | -| ------------------------ | -------------------------------------------- | ------------------------------------------------------ | -| 1 — Storybook | `pnpm dev:stories` → `:6006` | pure UI: a component, a layout, a state | -| 2 — CDP attach | `pnpm dev` → `:9223` → `playwright-electron` | anything touching `window.xnet*`, SQLite, native APIs | -| 3 — `_electron.launch()` | `tests/e2e/src/electron-smoke.spec.ts` | restart durability, deep links, packaging, crash paths | +| Rung | Loop | Use when the change is… | +| ------------------------ | --------------------------------------------- | ------------------------------------------------------ | +| 1 — Storybook | `pnpm dev:stories` → `:6006` | pure UI: a component, a layout, a state | +| 2 — CDP attach | `pnpm dev` → this tree's CDP port (see below) | anything touching `window.xnet*`, SQLite, native APIs | +| 3 — `_electron.launch()` | `tests/e2e/src/electron-smoke.spec.ts` | restart durability, deep links, packaging, crash paths | Never reach for rung 3 to check a layout. @@ -21,28 +21,70 @@ Never reach for rung 3 to check a layout. and cannot boot in a tab — see below. Drive it with `playwright-electron` over CDP. -## Ports +## Ports are derived, not fixed (0413) -| Port | What | -| ---- | ------------------------------------------------------------------ | -| 5177 | renderer (Vite) — `electron.vite.config.ts` | -| 9223 | CDP, dev only — `src/main/index.ts` sets `--remote-debugging-port` | -| 9224 | CDP for the `user2` profile | -| 4444 | hub | +**Do not hard-code a port. Ask the app which one it is on.** -```bash -pnpm dev # hub + app -pnpm dev:both # two instances for sync testing +`pnpm dev` resolves the profile and a block of ten ports from the **worktree**, +once, in `scripts/dev-launch.mjs`: + +```text +blockBase = 20000 + hash(worktree path) % 500 * 10 + +0 renderer +1 CDP +2 hub +3 local API +4…+9 reserved ``` -`dev:both` does not set `ELECTRON_CDP_PORT`, so both instances request 9223. -For a two-instance session, set the ports explicitly: +The **main checkout keeps the legacy fixed ports** — 5177 / 9223 / 4444 / +31415, profile `default` — so nothing there moved. Every linked worktree gets +its own block, its own `userData` (`xnet-desktop-wt-`), and therefore its +own single-instance lock. Two worktrees can now run at once. ```bash -ELECTRON_CDP_PORT=9223 pnpm --filter xnet-desktop run dev:electron -XNET_PROFILE=user2 VITE_PORT=5174 ELECTRON_CDP_PORT=9224 pnpm --filter xnet-desktop run dev:user2 +pnpm --filter xnet-desktop dev:scope # print this tree's profile and ports +pnpm dev # prints them too, then `[xnet-dev] ready {…}` +pnpm --filter xnet-desktop dev:clean # prune profiles whose worktree is gone +``` + +Wait for the machine-readable ready line before attaching — it means the CDP +port was checked and is serving **this** tree's renderer: + +```text +[xnet-dev] ready {"profile":"wt-…","renderer":23400,"cdp":23401,…,"branch":"…"} ``` +Override any single service with `VITE_PORT`, `ELECTRON_CDP_PORT`, +`XNET_HUB_PORT` or `XNET_LOCAL_API_PORT`; `XNET_PROFILE` overrides the profile. +`dev:both` still runs two instances for sync testing. + +> The renderer now uses `strictPort`. A taken port **fails** instead of quietly +> becoming the next one — that silent drift is why the port tables here used to +> lie. + +## Confirm which instance you are driving + +Never assume. One `browser_evaluate` answers it: + +```js +window.__xnetDev // { worktree, branch, commit, profile, ports, pid, startedAt, logs() } +``` + +Dev-only, and `cdp-dev-only.test.ts` asserts it never ships. The window title +also names the profile and commit in development, for every profile including +`default` — it used to be plain `xNet`, which made it useless in exactly the +case worth catching. + +Losing the lock is now a named error and **exit 1**. It used to be `app.quit()` +and exit 0, which an agent read as success before attaching to someone else's +app. + +## Logs from all three processes + +`window.__xnetDev.logs()` returns a bounded ring buffer of `main` and `data` +records, including everything logged **before the window existed** — boot +failures a CDP-attached agent could not otherwise see. Live records also land +in the renderer console tagged `[main]` / `[data]`, so +`browser_console_messages` covers the whole app rather than one process of +three. Development only; production logging is 0315's. + ## Preload The preload exposes **10** `contextBridge.exposeInMainWorld` namespaces diff --git a/docs/explorations/0413_[_]_PLURAL_ELECTRON_WORKTREE_SCOPED_DESKTOP_DEV.md b/docs/explorations/0413_[_]_PLURAL_ELECTRON_WORKTREE_SCOPED_DESKTOP_DEV.md index ca66df39a..8e92dd030 100644 --- a/docs/explorations/0413_[_]_PLURAL_ELECTRON_WORKTREE_SCOPED_DESKTOP_DEV.md +++ b/docs/explorations/0413_[_]_PLURAL_ELECTRON_WORKTREE_SCOPED_DESKTOP_DEV.md @@ -778,7 +778,7 @@ if (!mine) { ### Then, the instructions -- [ ] Rewrite the port tables in `apps/electron/AGENTS.md` and +- [x] Rewrite the port tables in `apps/electron/AGENTS.md` and `.claude/skills/electron-prototype/SKILL.md` as the derivation rule plus the `window.__xnetDev` check; add `pnpm dev:clean` and delete the hand-cut `electron-renderer-worktree (vite 5178)` launch entry. From 55b613b5c4b3a584c590a12ef2252d321fe0daff Mon Sep 17 00:00:00 2001 From: xNet Test Date: Fri, 31 Jul 2026 07:27:45 -0700 Subject: [PATCH 4/6] fix(electron): keep the instance label in the window title MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `title` option on BrowserWindow is only the *initial* title — Electron discards it as soon as the document declares its own ``, and renderer/index.html does. So the profile label added for 0413 never actually reached the window: verified titles still read plain `xNet`. Own the title in development via `page-title-updated`, which also survives the renderer updating it later. `document.title` is deliberately left alone — CanvasView reads it to name captured content. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: xNet Test <test@xnet.dev> --- apps/electron/src/main/index.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/apps/electron/src/main/index.ts b/apps/electron/src/main/index.ts index 5be261f8f..850704bce 100644 --- a/apps/electron/src/main/index.ts +++ b/apps/electron/src/main/index.ts @@ -259,6 +259,17 @@ async function createWindow() { ) } + // The `title` option above is only the *initial* title: Electron discards it + // as soon as the loaded document declares its own `<title>`, which + // `renderer/index.html` does. Own the title instead, or the instance label is + // silently reverted to plain `xNet` — the very thing 0413 is fixing. + if (process.env.NODE_ENV === 'development') { + mainWindow.webContents.on('page-title-updated', (event) => { + event.preventDefault() + mainWindow?.setTitle(title) + }) + } + mainWindow.on('closed', () => { mainWindow = null }) From 367605e000a141ef393430b6948f6e527b102c2c Mon Sep 17 00:00:00 2001 From: xNet Test <test@xnet.dev> Date: Fri, 31 Jul 2026 07:29:21 -0700 Subject: [PATCH 5/6] docs(exploration): partially check off plural electron worktree scoped desktop dev MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 25 of 26 items. Records the measured results against a live pair of instances — the user's main-checkout app was running throughout, so the CDP collision the exploration is about was reproduced and then caught rather than simulated. Also notes three defects found by building it (stableProfileOffset could not be reused; git hook variables corrupted worktree detection; the window title option was inert) and one left alone (rebuild-if-stale's stamp does not notice `pnpm install --force` replacing the native binary). The remaining item — recording turns-to-prototype, desktop versus web — is a measurement activity across future work, not a change this PR can contain. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: xNet Test <test@xnet.dev> --- ...L_ELECTRON_WORKTREE_SCOPED_DESKTOP_DEV.md} | 116 ++++++++++++++++-- ...-worktrees-can-run-the-desktop-app-at.json | 8 ++ 2 files changed, 113 insertions(+), 11 deletions(-) rename docs/explorations/{0413_[_]_PLURAL_ELECTRON_WORKTREE_SCOPED_DESKTOP_DEV.md => 0413_[-]_PLURAL_ELECTRON_WORKTREE_SCOPED_DESKTOP_DEV.md} (87%) create mode 100644 site/src/data/changelog/2026-07-31-two-worktrees-can-run-the-desktop-app-at.json diff --git a/docs/explorations/0413_[_]_PLURAL_ELECTRON_WORKTREE_SCOPED_DESKTOP_DEV.md b/docs/explorations/0413_[-]_PLURAL_ELECTRON_WORKTREE_SCOPED_DESKTOP_DEV.md similarity index 87% rename from docs/explorations/0413_[_]_PLURAL_ELECTRON_WORKTREE_SCOPED_DESKTOP_DEV.md rename to docs/explorations/0413_[-]_PLURAL_ELECTRON_WORKTREE_SCOPED_DESKTOP_DEV.md index 8e92dd030..4e29bee67 100644 --- a/docs/explorations/0413_[_]_PLURAL_ELECTRON_WORKTREE_SCOPED_DESKTOP_DEV.md +++ b/docs/explorations/0413_[-]_PLURAL_ELECTRON_WORKTREE_SCOPED_DESKTOP_DEV.md @@ -785,29 +785,123 @@ if (!mine) { ## Validation Checklist -- [ ] Two `pnpm dev` runs from two different worktrees are **both alive at once**, +- [x] Two `pnpm dev` runs from two different worktrees are **both alive at once**, on distinct renderer/CDP/hub/local-API ports and distinct `userData` dirs. -- [ ] Re-run the collision that could not be run here: a second `pnpm dev` on the +- [x] Re-run the collision that could not be run here: a second `pnpm dev` on the **same** profile now exits **non-zero** with a message naming the holder. -- [ ] From worktree A, `browser_evaluate(() => window.__xnetDev)` returns worktree +- [x] From worktree A, `browser_evaluate(() => window.__xnetDev)` returns worktree A's path, branch and commit — and attaching to worktree B's CDP port returns B's. -- [ ] Deliberately point the MCP server at the wrong port: the CDP identity +- [x] Deliberately point the MCP server at the wrong port: the CDP identity assertion fires and the run refuses to report ready. -- [ ] A `console.error` thrown in the main process **before** the window opens is +- [x] A `console.error` thrown in the main process **before** the window opens is retrievable via `window.__xnetDev.logs()`, and a post-boot one appears in `browser_console_messages` tagged `[main]`. -- [ ] With 5177 occupied, `pnpm dev` in the main checkout **fails** rather than +- [x] With 5177 occupied, `pnpm dev` in the main checkout **fails** rather than quietly moving to 5178. -- [ ] `pnpm dev:clean` removes profiles for deleted worktrees and leaves live ones - alone; the 18 orphaned dirs drop to the number of live worktrees plus - `default`. -- [ ] `pnpm check:electron-parity`, `pnpm typecheck` and `pnpm test` all pass. -- [ ] A production build contains neither `remote-debugging-port` nor +- [x] `pnpm dev:clean` removes profiles for deleted worktrees and stale + timestamped e2e runs, and leaves live ones alone. (**Amended during + implementation** from "the 18 orphaned dirs drop to the number of live + worktrees plus `default`": the shipped cleaner refuses to touch profiles + it cannot prove are garbage — the five `codex-*` and unstamped `e2e-*` + dirs match no worktree and carry no timestamp, so deleting them would be + a guess about someone's data.) +- [x] `pnpm check:electron-parity`, `pnpm typecheck` and `pnpm test` all pass. +- [x] A production build contains neither `remote-debugging-port` nor `__xnetDev` — the extended `cdp-dev-only.test.ts` proves it. - [ ] Record turns-to-verified-prototype for one real desktop change, worktree Electron versus web — closing 0404's two unfinished measurement items. +## Measured Results + +Recorded at implementation time, against a live pair of instances. The user's +own dev app was running from the **main checkout** throughout — the incumbent +this exploration was written about — so every check below is a real collision, +not a simulated one. + +### Two instances, at once + +| Instance | Profile | Renderer | CDP | `SingletonLock` holder | +| -------------------------------- | ------------------------------ | -------- | ----- | ---------------------- | +| main checkout (`main`) | `default` | 5177 | 9223 | `…local-32967` | +| this worktree (`claude/…74b20c`) | `wt-agentic-electron-…-74b20c` | 23400 | 23401 | `…local-16919` | + +Both answered CDP simultaneously; `/json/list` on 9223 returned +`xNet | http://localhost:5177/` and on 23401 returned +`xNet | http://localhost:23400/`. Distinct `userData` directories, distinct +locks. **This is the thing that was impossible before.** + +### The failures, now loud + +| Check | Result | +| ---------------------------------- | ------------------------------------------------------------------------------------------------------- | +| Second instance, same profile | **exit 1** — `FATAL: profile "wt-…" is already running (lock held by Chriss-MacBook-Pro-3.local-16919)` | +| CDP pointed at another tree's port | `FATAL: :9223 is serving http://localhost:5177/, not our renderer on :23450` — refused to report ready | +| Renderer port already taken | `Error: Port 23400 is already in use` — `strictPort` stopped the run instead of drifting | + +The middle row is the exploration's whole thesis, reproduced and then caught: +`:9223` really was serving the main checkout's renderer. + +### Provenance and logs + +`window.__xnetDev`, read over CDP from the worktree instance: + +```json +{ + "worktree": "/Users/crs/Code/xNet/.claude/worktrees/agentic-electron-dev-experience-74b20c", + "branch": "claude/agentic-electron-dev-experience-74b20c", + "commit": "4913d9c86", + "profile": "wt-agentic-electron-dev-experience-74b20c", + "ports": { "renderer": 23400, "cdp": 23401, "hub": 23402, "localApi": 23403 }, + "pid": 7882 +} +``` + +`window.__xnetDev.logs()` returned main-process records written **before the +window existed** (`[LocalAPI] Server listening on http://127.0.0.1:23403`), and +those same records arrive in the renderer console tagged `[main]` — so +`browser_console_messages` now covers the main process, not just the renderer. + +OS window title, read via System Events: +`xNet (wt-agentic-electron-dev-experience-74b20c @4913d9c86)`. + +### Gates + +`pnpm test` 11,426 passed / 1,121 files · `turbo run typecheck` 40/40 uncached · +`check:electron-parity` OK (12 covered, 18 waived, 9 pre-existing warnings). + +<details> +<summary>Two defects found by building it, and one left alone</summary> + +**`stableProfileOffset()` could not be reused as-is.** The doc proposed reusing +it for worktree offsets. It has a numeric-suffix branch — the thing that makes +`user2` → +2, pinned by its own tests — and worktree slugs routinely end in a +hex digit, so `…-a7ef01abd021f6de6` would collapse onto offset 6 with every +other slug ending in 6. Worktrees get FNV-1a over the whole path instead. + +**Git hook variables corrupted worktree detection.** `pre-commit` exports +`GIT_DIR` / `GIT_WORK_TREE`, every `git` child inherits them, and +`rev-parse --git-dir` then answers about the hook's repository rather than the +directory being asked about — so a main-checkout commit resolved as a linked +worktree with a bogus root. Caught by the pre-commit hook itself failing. +`git()` now scrubs `GIT_*`, with a regression test that fails when the scrub is +removed. + +**The window title was inert.** `BrowserWindow`'s `title` option is only the +initial title; Electron discards it once the document declares `<title>`, which +`renderer/index.html` does. The label is now owned via `page-title-updated`. +`document.title` is deliberately untouched — `CanvasView` reads it to name +captured content. + +**Not fixed, and out of scope:** `rebuild-if-stale.mjs` (0404) hashes Electron's +version, the module versions and the lockfile. A `pnpm install --force` replaces +the native binary without moving any of those, so the stamp stays warm and the +next boot hands Electron a Node-ABI module — observed here as +`NODE_MODULE_VERSION 131 … requires 130`. `XNET_FORCE_ELECTRON_REBUILD=1` clears +it. Worth a follow-up that stamps the binary's own mtime or hash. + +</details> + ## References ### In this repository diff --git a/site/src/data/changelog/2026-07-31-two-worktrees-can-run-the-desktop-app-at.json b/site/src/data/changelog/2026-07-31-two-worktrees-can-run-the-desktop-app-at.json new file mode 100644 index 000000000..12dd22bb6 --- /dev/null +++ b/site/src/data/changelog/2026-07-31-two-worktrees-can-run-the-desktop-app-at.json @@ -0,0 +1,8 @@ +{ + "id": "2026-07-31-two-worktrees-can-run-the-desktop-app-at", + "date": "July 31, 2026", + "title": "Two worktrees can run the desktop app at once", + "summary": "The desktop dev app now takes its profile and ports from the git worktree it was started in, so parallel checkouts no longer fight over one set of ports and one data directory. Collisions that used to exit silently now fail with a message naming what holds the lock.", + "highlights": [], + "tags": ["devtools", "platform"] +} From a83e282938e3bcaaa5f192aa492a12ef67a34436 Mon Sep 17 00:00:00 2001 From: xNet Test <test@xnet.dev> Date: Fri, 31 Jul 2026 07:45:09 -0700 Subject: [PATCH 6/6] test(electron): make dev-scope tests hermetic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first version resolved against the running checkout, so it asserted a property of the machine rather than of the code: it passed locally (a linked worktree) and failed in CI (a plain clone), and it hard-coded absolute paths from one developer's disk. Build both shapes in a temp directory instead — a plain checkout and a real `git worktree add` of it — so the same assertions hold anywhere git exists. The setup helper scrubs GIT_* for the same reason the resolver does: under a git hook those variables are exported and its `git commit` operated on the hook's repository instead of the temp one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: xNet Test <test@xnet.dev> --- apps/electron/src/main/dev-scope.test.ts | 70 ++++++++++++++++++++---- 1 file changed, 58 insertions(+), 12 deletions(-) diff --git a/apps/electron/src/main/dev-scope.test.ts b/apps/electron/src/main/dev-scope.test.ts index 79e71611e..058366c7c 100644 --- a/apps/electron/src/main/dev-scope.test.ts +++ b/apps/electron/src/main/dev-scope.test.ts @@ -9,7 +9,11 @@ * if that came back as "the main checkout", an agent reading provenance * would trust a value nobody set. */ -import { describe, expect, it } from 'vitest' +import { execFileSync } from 'node:child_process' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { BLOCK_BASE, BLOCK_COUNT, @@ -20,7 +24,49 @@ import { scopeEnv } from '../../scripts/dev-scope.mjs' -const MAIN = '/Users/crs/Code/xNet/apps/electron' +/** + * A throwaway repo with a real linked worktree. + * + * The first version of this file resolved against the *running* checkout, which + * passed locally (a worktree) and failed in CI (a plain clone) — the test was + * asserting a property of the machine, not of the code. Build both shapes + * instead, so the same assertions hold anywhere git exists. + */ +let root: string +/** A plain, non-worktree checkout — stands in for the main repo. */ +let mainCheckout: string +/** A linked worktree of it. */ +let worktree: string + +/** + * Scrubbed exactly like `dev-scope.mjs`'s own helper, and for the same reason: + * run from a git hook, `GIT_DIR`/`GIT_INDEX_FILE` are exported and these + * commands would operate on the *hook's* repository instead of the temp one. + * (Observed: `git commit` here failed under pre-commit until this was added.) + */ +function git(args: string[], cwd: string): void { + const env = { ...process.env } + for (const key of Object.keys(env)) { + if (key.startsWith('GIT_')) delete env[key] + } + execFileSync('git', args, { cwd, env, stdio: 'ignore' }) +} + +beforeAll(() => { + root = mkdtempSync(join(tmpdir(), 'xnet-dev-scope-')) + mainCheckout = join(root, 'main') + worktree = join(root, 'wt-example') + + git(['init', '-q', '-b', 'main', mainCheckout], root) + git(['config', 'user.email', 'test@example.com'], mainCheckout) + git(['config', 'user.name', 'xNet Test'], mainCheckout) + git(['commit', '-q', '--allow-empty', '-m', 'root'], mainCheckout) + git(['worktree', 'add', '-q', '-b', 'feature', worktree], mainCheckout) +}) + +afterAll(() => { + if (root) rmSync(root, { recursive: true, force: true }) +}) describe('port block derivation', () => { it('spreads whole strings across the block range', () => { @@ -56,7 +102,7 @@ describe('port block derivation', () => { describe('resolveDevScope', () => { it('leaves the main checkout on the default profile and legacy ports', () => { - const scope = resolveDevScope(MAIN, {}) + const scope = resolveDevScope(mainCheckout, {}) expect(scope.profile).toBe('default') expect(scope.scoped).toBe(false) expect(scope.worktree).toBeNull() @@ -64,7 +110,7 @@ describe('resolveDevScope', () => { }) it('scopes a linked worktree to its own profile and block', () => { - const scope = resolveDevScope(process.cwd(), {}) + const scope = resolveDevScope(worktree, {}) expect(scope.scoped).toBe(true) expect(scope.profile).toMatch(/^wt-/) expect(scope.worktree).toBeTruthy() @@ -87,12 +133,12 @@ describe('resolveDevScope', () => { process.env.GIT_WORK_TREE = '/Users/crs/Code/xNet' process.env.GIT_INDEX_FILE = '/Users/crs/Code/xNet/.git/index' - const scope = resolveDevScope(MAIN, {}) + const scope = resolveDevScope(mainCheckout, {}) expect(scope.profile).toBe('default') expect(scope.scoped).toBe(false) // …and a real worktree is still detected through the same noise. - expect(resolveDevScope(process.cwd(), {}).scoped).toBe(true) + expect(resolveDevScope(worktree, {}).scoped).toBe(true) } finally { for (const key of Object.keys(process.env)) { if (!(key in saved)) delete process.env[key] @@ -102,24 +148,24 @@ describe('resolveDevScope', () => { }) it('lets an explicit profile win over the derived one', () => { - expect(resolveDevScope(process.cwd(), { XNET_PROFILE: 'user2' }).profile).toBe('user2') + expect(resolveDevScope(worktree, { XNET_PROFILE: 'user2' }).profile).toBe('user2') }) it('lets a single port override stand without abandoning the rest', () => { - const scope = resolveDevScope(process.cwd(), { VITE_PORT: '5199' }) + const scope = resolveDevScope(worktree, { VITE_PORT: '5199' }) expect(scope.ports.renderer).toBe(5199) expect(scope.ports.cdp).toBeGreaterThanOrEqual(BLOCK_BASE) }) it('ignores an out-of-range port override rather than trusting it', () => { - const scope = resolveDevScope(process.cwd(), { VITE_PORT: '70000' }) + const scope = resolveDevScope(worktree, { VITE_PORT: '70000' }) expect(scope.ports.renderer).toBeGreaterThanOrEqual(BLOCK_BASE) }) }) describe('scopeEnv', () => { it('emits no port overrides in the main checkout', () => { - const env = scopeEnv(resolveDevScope(MAIN, {})) + const env = scopeEnv(resolveDevScope(mainCheckout, {})) expect(env.XNET_DEV_SCOPE).toBeTruthy() // Critically: no XNET_LOCAL_API_PORT. Emitting it would flatten // `resolveLocalAPIPort()`'s own per-profile derivation, putting `user2` @@ -130,7 +176,7 @@ describe('scopeEnv', () => { }) it('emits the full block for a worktree', () => { - const scope = resolveDevScope(process.cwd(), {}) + const scope = resolveDevScope(worktree, {}) const env = scopeEnv(scope) expect(env.XNET_PROFILE).toBe(scope.profile) expect(env.VITE_PORT).toBe(String(scope.ports.renderer)) @@ -141,7 +187,7 @@ describe('scopeEnv', () => { }) it('carries provenance even where nothing relocates', () => { - const parsed = JSON.parse(scopeEnv(resolveDevScope(MAIN, {})).XNET_DEV_SCOPE) + const parsed = JSON.parse(scopeEnv(resolveDevScope(mainCheckout, {})).XNET_DEV_SCOPE) expect(parsed.profile).toBe('default') expect(parsed.scoped).toBe(false) })