# code-browser — architecture A read-only code browser built for reviewing changes. It's a hybrid of a browser and a code editor. It never edits code, and it never runs code from the browsed repository outside a sandbox. It writes nothing to a repository: reviews are kept in a local store (see [Reviews](#reviews)). ## Principles 1. **Full files by default, not diffs.** A review opens the whole file at the *head* revision. Changed lines are marked in the gutter and tinted. Removed code can be shown inline on demand, and a changed-files filter is one click away. A classic side-by-side diff is not the default view. 2. **Forge-agnostic.** The unit of review is `(repository, base ref, head ref)`. Repositories are local paths or any git remote URL (ssh/https). GitHub, GitLab and similar can be added later as *sources* that resolve to that same tuple. 3. **The browsed code is untrusted.** Nothing from the repo runs on the host. That includes git hooks, filters, textconv drivers, fsmonitor, language servers, build scripts and tests. 4. **Execution happens only in sandboxes**, behind a provider interface. Local sandboxes use Linux VMs on macOS Virtualization.framework. Remote sandboxes implement the same interface over the network. 5. **Reviews are local.** They live in one SQLite store in the app's data directory, covering every repository, and nothing about them is written to git. A submitted review can be exported to a forge, and what happens there is fetched back, read-only, through adapters. The store is specified in [REVIEW-FORMAT.md](REVIEW-FORMAT.md), exporting and fetching in [integrations/README.md](integrations/README.md). ## Process layout ``` ┌──────────────────────────── Electron ─────────────────────────────┐ │ main (Node) │ │ ├─ git/ read-only git plumbing (hardened invocations) │ │ ├─ repos/ local + remote repo registry, bare clone cache │ │ ├─ sandbox/ SandboxProvider interface + providers │ │ │ ├─ vz → native/ (Rust, napi-rs, Virtualization.framework)│ │ │ └─ remote → (future) gRPC/WebSocket to a sandbox service │ │ └─ ipc.ts typed IPC surface (the only renderer entry point) │ │ │ │ preload contextBridge → window.api (typed, narrow) │ │ │ │ renderer React UI, CodeMirror 6 read-only viewer │ │ sandbox:true, contextIsolation, strict CSP │ │ │ │ (future) preview WebContentsView per sandbox, own session │ │ partition, only reaches the sandbox's forwarded port │ └───────────────────────────────────────────────────────────────────┘ ``` ## Windows and instances - **One process per profile.** Electron's single-instance lock keys on the data directory. - A second launch (Dock, `open -n`, `code-browser `) hands its arguments to the running instance, which opens a window, and exits in ~0.1 s. - Two processes would each keep a warm pool and race on the snapshot and registry caches and scratch disks. - `--profile=` runs a fully separate instance, with its own data directory, lock, VMs and caches. - Because only one instance owns a data directory, startup can safely delete scratch disks and half-built snapshots left behind by a crash. - **Each window is an independent review** (File → New Window, ⌘N). - Sandboxes belong to the window that created them, keyed by webContents id. - The main process checks ownership on every sandbox call, so one window can't exec in, kill or dispose another window's sandbox. - A window's sandboxes stop when it closes, crashes or reloads. - All windows share the warm pool and the snapshot and registry caches. - At most 6 sandboxes run at once across windows. ## Git hardening The app reads repositories only through git plumbing, and never through the working tree: - `ls-tree`, `cat-file`, `diff-tree`/`diff` between two commits, `merge-base`, `for-each-ref`, `rev-parse`. - Every call passes `--no-ext-diff --no-textconv` where it applies, and runs with `-c core.fsmonitor= -c core.hooksPath=/dev/null -c diff.external=` so that repo-local `.git/config` can't make git execute anything. - Remote repos are cloned **bare** (`--bare --filter=blob:none`) into the app's cache directory. Nothing is checked out, and submodules are never recursed. - `GIT_TERMINAL_PROMPT=0` stops git from hanging on credential prompts. Auth uses the user's existing ssh agent or credential helper (host config, which we trust). - A cache clone is of the URL the app was given (for a pull request, the `https://` URL it chose). A `url..insteadOf` rewrite in the user's config is honoured first; when the rewritten URL fails (say an ssh agent that refuses the app), the clone or fetch is tried once more with the URL as given (a `GIT_CONFIG_*` entry mapping it to itself, never an argument), and once that works the clone's own config pins its URL to itself, so later fetches and lazy blob fetches use it too. No token of the app's is used for git; the user's own credential helpers, or none, apply. - The main process writes to git only to clone and fetch (`src/main/repos.ts`): the cache clones, their remote-tracking refs and their own config (the fetch refspec, and the URL pin above). Never branches, tags, `HEAD`, the index, the working tree or a checkout's config, and never anything about reviews. The one thing the app writes about reviews is its own store (see [Reviews](#reviews)). - Opening a pull request URL fetches its head and base into remote-tracking refs: `refs/remotes//pull/` and `refs/remotes//`, in an open repository with a remote on that GitHub repository, else in a cache clone. Fetch fetches both again for a subject labelled `/pull/` (a local checkout too) and opens the new head, so a draft on the old one gets the head-moved choice. A clone's sync fetches the pull request refs it has along with its branches, since `--prune` would otherwise delete them. - Forge credentials (a GitHub token) never come from a repository: no git credential helpers and no repository-local config. The tools that supply them (`gh`, `security`, `secret-tool`) are found only outside every repository and run in a fixed directory with an allowlisted environment. A token is used only for its exact host, stays in the main process, and is scrubbed from everything stored, logged or sent to the renderer ([integrations/README.md](integrations/README.md) §8). ## Subject model A window looks through a `Subject`: the repository, base and head that a review is about. "Review" is kept for the stored record described in [Reviews](#reviews). Opening a repository opens a subject with base = head at the current branch (the default branch for a clone): the code, with nothing to compare. A draft recorded there is an audit ([REVIEW-FORMAT.md](REVIEW-FORMAT.md) §2). Compare (`Compare.tsx`) sets another base or head over the code and Stop comparing returns to base = head at the same commit; each subject keeps its own draft. The omnibar shows base = head as a `repo` address and anything else as a `review` address. Branch counts for Compare (commits ahead of the default branch, files changed since the merge-base) come from `branchStats` in `src/main/git/repo.ts`, tree-level so a blobless clone fetches nothing. The reviews list is only shown when asked for (⇧⌘R, or from the welcome screen). ``` Subject = { repo, base, head } base defaults to merge-base(defaultBranch, head) changedFiles = diff --name-status -M base head fileView(p) = blob(head:p) + hunks(base:p → head:p, -U0) oldView(p) = blob(base:p) (used for inline "show removed") ``` The renderer gets the head file content plus a compact list of hunks. Gutter markers (added, modified, deleted-here) are computed from the hunks, and the inline removed-code view uses `@codemirror/merge`'s unified view with editing controls disabled. ### Structure: tree-sitter (`src/renderer/src/syntax/`) The review's structure comes from tree-sitter, parsed on the host with no sandbox needed, for TS/TSX, JS/JSX, Python, Go and Rust. - **Where it runs:** a module Web Worker in the renderer, using `web-tree-sitter`. Grammars are the official `tree-sitter-*` packages' prebuilt WebAssembly (their native-build postinstalls stay blocked), loaded per language on first use (0.2–1.4 MB each). Repository content is untrusted, and this keeps parsing off the UI thread and inside the wasm heap, with a 2 MB size cap and a 2 s parse budget. The CSP gains `'wasm-unsafe-eval'`, which allows compiling WebAssembly, not `eval`. - **Symbols** come from the grammars' upstream `tags.scm` queries (TypeScript extends JavaScript's), plus a few additions (TS type aliases and enums, Rust impl blocks, enums and aliases). Nesting follows ranges; names are qualified by their containers (`Store.get`, Rust methods by the impl's type, Go methods by the receiver). - **Changes by symbol** (sidebar *Symbols* tab): each changed file is parsed at base and head, and every changed line (from the `-U0` hunks) belongs to its innermost enclosing definition, or to the top level (blank lines don't count). Symbols match across base/head by kind + qualified name: - unmatched → *added* / *removed* (only the outermost of a nested run), or *renamed* when the body is token-identical apart from the name; - matched and touched → *reformatted* when the tokens are identical (whitespace isn't a token), *comments* when they're identical without comments, else *modified*; - a file whose tokens are identical overall is flagged *formatting only*; new and deleted files are one row, expandable. - **Outline** (sidebar *Outline* tab) lists the open file's definitions and highlights those a change touches. The viewer's **scope header** shows the definitions enclosing the top visible line (`function Viewer › method jumpChange`); each part is clickable. - `analysis.ts` is pure over parsed trees, tested under `bun test` with the real grammars and real `git diff -U0` hunks. ## Settings Settings are TOML in two layers over built-in defaults (`src/main/settings/`): ``` default ← user: ~/.config/code-browser/config.toml (written by the app and by hand) ← repo: .code-browser/config.toml at the trusted branch's tip (read only) ``` - **One schema** (`schema.ts`) lists every key: type, default, section, where it may be set (`scopes`), and whether a repository may lock it. The settings page, the consumers and the checks all read it. - **The user's file** (`store.ts`). `--profile=` uses `config..toml`, so profiles stay as separate as their data directories. Writes touch only the lines of the keys they change, so comments and layout survive (`toml.ts`: every edit is parsed back and must equal the intended document, else the file is regenerated). The file is watched: an edit by hand applies at once (the egress policy, for one). A file that doesn't parse leaves defaults in effect and refuses writes. - **Per-clone settings** sit in the user's file under `[repos.""]`, where the location is a local repository's working-tree path or a remote's URL: the trusted remote and branch, primary remote (`repository.*`), toolchain versions. - **The repository's file** is read with `cat-file` from the tip of the trusted branch ([SETTINGS.md](SETTINGS.md), `repoLayer.ts`), never HEAD, the working tree or the head under review, so a change can't alter the settings it's reviewed under. The app never writes it: the page's Repository tab is read-only and says to change it with a commit. `[locks] keys = [...]` enforces a value, for keys the schema marks lockable (today `sandbox.auto_start`). - **User-only keys** (`scopes: ['user']`) are never taken from a repository, whatever its file says: egress mode and allow-list, GitHub auth, the trusted branch. The page shows such a key as ignored. - **Precedence** is the repository's over the user's, except where the schema says `precedence: 'user'` (no key does today). A lock always wins. - **The page** is the design system's `SettingsPage` (`Settings.tsx`), at the omnibar address `settings user/
` or `settings /
`, or ⌘, from the menu. `SettingsService` builds its rows and saves to the user's file only, all or nothing, each value checked against the schema. - **Migration.** Earlier versions kept settings in `` JSON. The first launch without a user config moves `egress.json` into it (the original kept as `egress.migrated.json`), `project-overrides.json` entries move per repository when it's first opened, and the sandbox card's old `localStorage` switch moves on first load. ## Reviews A browsing session is ephemeral: a lens, navigation, a sandbox. A **review** is a session you decide to keep. It adds an author (your global git `user.name`, as a display name), threads anchored to lines and symbols, coverage (what you viewed, keyed by blob), cited sandbox evidence and a verdict. A draft starts with the first mark (comment, viewed tick, flag); a session with no marks leaves nothing behind. A review with `base = head` is an audit: findings, no verdict. The store is specified in [REVIEW-FORMAT.md](REVIEW-FORMAT.md) ("format §N" below). Exporting and fetching are specified in [integrations/README.md](integrations/README.md) ("integrations §N"), with one document per adapter ([integrations/github.md](integrations/github.md)). The reasons behind the main choices: - **Local, in one SQLite file** that only the app writes, not in the reviewed repository. This gives up multi-machine access, sharing without a forge and signed verdicts (format §1.1). - **Identified by root commit** (format §3.3), not by where a clone lives, so reviews survive moving, re-cloning or deleting a clone. - **Columns, and Markdown bodies.** Structure lives in columns. Bodies are Markdown for display and are never parsed for structure. - **Forge-neutral core.** A change is a pair of commits, however it was proposed. Forges appear only through adapters. An export is an explicit action, once per review, lossy by design, and shows what it drops before sending. What is fetched is kept beside the local review, never merged into it (integrations §4). - **`generated_by`** lets content declare that a model wrote it. It describes the content, never the author, and never affects standing or verdicts. ### The store (`src/main/reviews/store/`) - **Location** (`open.ts`, format §4.1): `reviews.sqlite` in ``, one store per profile for every repository. A build that isn't packaged (`bun run dev`) uses `/dev/` instead, so a branch never migrates or rotates the real store; `CODE_BROWSER_REAL_STORE=1` opts in to the real one. - **Opening** (format §4.2) checks the application id, sets the pragmas, and runs `quick_check` after an unclean shutdown. A store that fails is not written: the app offers the newest good backup, or moves the file aside. - **Bindings** (`db.ts`, format §15): one small `Db` interface over `node:sqlite` in the app (`db-node.ts`) and `bun:sqlite` under `bun test` (`db-bun.ts`), chosen at run time. `bun run test:store-node` runs a smoke subset under Electron's Node, so the production backend is tested too. - **Migrations** (`migrate.ts`, `migrations/`, format §4.4) are numbered, hashed and checked against the expected schema. A store written by a newer version, or by another branch, is opened read-only. - **Backups** (`backup.ts`, format §4.6): a routine backup in `backups/` on the first write of each day and on quit after a write, keeping seven; a `before-` copy before migrating; restore from either. - **Writes**: `drafts.ts` and `reviews.ts` (drafts, submitting, evidence and coverage), `entries.ts` (replies, resolves, edits and retractions, format §10), `move.ts` (a draft onto a new head, format §6.5). `anchors.ts` checks anchors against git and computes their context and excerpt. `views.ts` reads what the renderer shows. - **Around it**: `identity.ts` (root commits), `folding.ts` (entries, standing, verdicts and timelines, format §10.4, §11; pure), `integration.ts` (a review's destination, export, links and fetched items), and `index.ts`, the `ReviewService` behind the `review:*` IPC channels. The main process is the only writer, through one connection (format §4.5). Transactions are synchronous and never span an `await`: git reads and network calls happen outside them, and a draft's `rev` refuses a change computed from a stale read. Evidence is written by the main process, not the renderer. `SandboxManager` keeps a window's last 32 finished `sandbox:exec` runs: the command, the exit code, stdout and stderr in the order they arrived (the last 1 MiB, with the full length), the snapshot's repository and commit, and what the command could reach. A draft cites a run by its exec id, and only a run of the same window whose commit belongs to the draft's repository. Anchors are checked the same way: `blob` must be `path` at `commit`, the lines must fall inside it, and the context and excerpt (format §7.3, §7.4) are computed from the blob. Any the renderer sends are ignored. ### Export and fetch (`src/main/reviews/sync/`) The adapter-neutral engines of integrations §5 to §8. Adapters never see the store: they get plain records and return plain values. - `adapter.ts` is the contract: an adapter names and checks destinations, declares its mappings, plans and sends exports step by step, fetches, and classifies its failures. - `destinations.ts`: destinations by system, host and locator, their confirmation, pauses per host after a rate limit. `links.ts`: the record that a review or thread was sent, written once and never changed. - `fetch.ts`: the `Fetcher`. A fetch is **read-only** and **complete**: it reads the whole destination and upserts what is there as remote entries; an item missing from two complete fetches is marked deleted. It runs when a linked review opens, when asked, when planning, after an export, and periodically (at most once a minute per destination). - `plan.ts`: planning runs a fetch, asks the adapter for a plan and its loss report, and keeps it until the user confirms exactly that plan. - `export.ts` and `transitions.ts`: the export state machine. A review is exported once. Each step is recorded, so a crash resumes. A send whose confirmation was lost is checked against markers before anything is sent again. Failures are classified, a rate limit pauses the host, and nothing retries by itself. - `markers.ts` (the hidden last line naming the local item, body digests), `urls.ts` (URLs from fetched data or the renderer are parsed and allowed only on the destination's host), `scrub.ts` (no credentials in errors, data or progress), `bounds.ts` (fetched values fitted to the store) and `locks.ts` (one fetch or export per destination at a time). ### GitHub (`src/main/github/`) The adapter of [integrations/github.md](integrations/github.md), and opening a pull request from its URL. - `adapter.ts` registers the adapter with the engines. `export.ts` maps a review onto one pull request review (github.md §2) and sends it: a pending review, a thread per comment, then submit with the verdict (github.md §3). `fetch.ts` and `queries.ts` read the whole pull request over GraphQL (github.md §4). - `suggest.ts` suggests pull requests from the clone's GitHub remotes, those whose head is the review's head first. A suggestion is shown in full; the user picks. - `api.ts` builds every request from the host and checked ids, never from a URL GitHub returned, and follows redirects only within the same origin. - The token (`auth.ts`, `credentials.ts`, `keychain.ts`) comes from the `github.auth` setting: `gh auth token --hostname `, or a token saved in the OS keychain for that host (github.md §5). It stays in the main process; the renderer sees only the account it belongs to. - `index.ts`: opening a pull request for review, the account, and the pull requests of a review's head. ### Renderer `useReview.ts` holds the reader's review of the open subject and every review of it; `reviewModel.ts` derives what is shown from `ReviewView` (pure, tested); `Review.tsx` has the panel, inline threads, the composer and dialogs, built from the design system's components (`design-system/`). - The reviews list (`ReviewsList.tsx`, ⇧⌘R) shows every review of the repository. `reviewList.ts` (pure, tested) turns the store's summary rows into rows and groups them into timelines by format §11.2 only: `supersedes` chains, then an equal change key, never overlapping files. A row opens its own base and head, so an earlier round opens at its old head. "Needs my reply" is an open thread the reader started or commented on whose last comment is someone else's. - The welcome screen lists recent repositories (`src/main/recent.ts`, `/recent-repos.json`) and what's waiting on the reader in each. It opens them offline and reads their reviews from the store; it never fetches. - A draft is written on the first mark: a comment, a viewed file, a verdict or a summary. Saves are queued, and each names its subject, so a debounced summary never lands in another subject's draft. - Threads are CodeMirror block widgets under their last line. Each widget's DOM is a container React portals into, so a half-typed reply survives redraws. Any line of the file can be commented on (click its number, or select and press `c`). - Viewed files are coverage records, keyed by blob. A file whose content changed is unviewed again without anyone clearing it. - Replies and resolves on submitted reviews are pending entries (format §10.3): those written in a draft post when it is submitted, the others wait for "Post N replies". They stay local; to answer on a forge, the user goes there. - When the head moves under a draft, a dry run of `moveDraft` gives the notice's counts, and "Review " re-anchors by blob, then by context (format §7.5). Symbol re-anchoring isn't done yet. - `ExportDialog.tsx`: choose a destination, see the plan and its loss report, confirm; then the export's state and the destination's. `RemoteItems.tsx` shows what was fetched beside the local review, marked remote, and a linked thread's state there beside its local one. - Everything in the store is displayed as untrusted (format §5.3): `Body.tsx` and `markdown.ts` render Markdown without raw HTML, remote images or links other than `http`, `https` and `mailto`, and logs as text with control characters visible. A fetched link opens only through the main process's check for the destination's host. Costs we accept: - The store is on one machine. Moving it means restoring a backup on the other one. - Two code-browser users see each other's reviews only through a destination both can reach. - Nothing is signed. A verdict at a destination is that account's review, trusted as far as the destination is. ### External systems overlay (cb-dv5) A change with an attachment (integrations §3.1) shows its destination; one without shows nothing, and takes no space. The core stays forge-agnostic: the overlay is drawn only from `DestinationView`, its remote entries and checks, and the adapter's own names (the system's name, `pr` or `mr`). The design is on the canvas "External Systems Overlay" (boards 1, 2, D and E). - **The chip.** Between the omnibar and the SandboxBadge: the system, the locator's short form (`#1287`, `!342`), the destination's state and a failed-check count (`✕ 1`). It also says fetching, no token, fetch failed or that the destination's head is past the subject's (`chipParts` in `src/renderer/src/destination.ts`; no token is a fetch refused or not found without one, `DestinationView.needsToken`). It opens the destination's tab (`DestinationChip`, `src/renderer/src/DestinationPanel.tsx`). - **The destination tab.** A third kind of tab beside `file` and `run` (`src/renderer/src/tabs.ts`), `destination:`, titled with the adapter's kind (`pr`), the locator's short form and the destination's title (`DestinationTab.tsx`). It shows the description, the checks for the destination's head, the reviews there with their verdicts, and the conversation, with a "Start with the first file" action. **Opening a destination's URL opens this tab and shows it**, so a pull request opens on its description instead of an empty canvas. Attaching from a change review opens nothing; resuming a review restores its tabs as they were. - **The right panel** gets tabs: Review, the destination (only while attached) and Sandbox, which now holds the SandboxCard that sat under the review. The destination's panel tab is the compact form for reading code (state, checks, description, reviews, conversation), with Open as tab, Fetch, Open on the destination and Detach. While the destination's own tab is showing, the panel shows Review. The SandboxBadge opens the Sandbox tab. Detaching closes the destination's tab. - **Attach** is on a change review's panel while nothing is attached (`AttachDialog`): a destination suggested from the remotes, the one whose head is the review's preselected, or one pasted by its URL. - **Remote threads** stay where RemoteItems shows them: in the code, and in the Review tab under the destination's name. For an attached destination (cb-dv5.7), what isn't linked to a local item (`unlinkedTree`) is listed under Review, and a thread is shown under its lines while the destination's head is the one shown and it isn't outdated (`remotePlacement`, `src/renderer/src/reviewModel.ts`). - Everything shown from a destination is remote and untrusted (format §5.3), and is marked so. Check logs are not fetched: a check links to its page at the destination. - **One path for every system.** Opening a URL, the tab, the chip and the panel tab take everything system-specific from the adapter's declarations (integrations §3): URL syntax, canonical URL, refs and names, as a `UrlOpening` (`src/shared/systems.ts`; GitHub's is `githubOpening` in `src/shared/github.ts`). `DestinationOpener` (`src/main/opening.ts`) asks the system for the title and base branch, and `RepoRegistry.openDestination` fetches the declared refs; a subject's labels (`/`) lead back to its destination for Fetch and its change key. A GitLab adapter then adds its declarations and its fetch mapping, and no UI. ## Sandbox model ```ts interface SandboxProvider { id: 'vz' | 'remote' | string create(spec: SandboxSpec): Promise } interface Sandbox { exec(cmd, opts): ExecHandle // streaming stdout/stderr, exit code forwardPort(guestPort): Promise<{ hostPort }> lsp(languageId): Promise // JSON-RPC stream to an LSP in the guest dispose(): Promise } ``` **The repository reaches a sandbox as a snapshot, not as a live host mount.** The main process uses `git archive` to write the head tree into a disk image or a virtio-fs share that's **read-only** in the guest. A writable overlay (tmpfs or scratch disk) inside the guest lets builds write without touching the host. ### Local: Virtualization.framework (`native/`, `guest/`) - **Host:** `native/src/vz.rs` wraps VZ through `objc2-virtualization`. Every VM call runs on that VM's own serial dispatch queue, so the wrapper works the same from Electron and from the `vzbench` CLI. - **Guest:** built by `guest/build.sh` in Podman. - Kernel: 6.18 LTS from `allnoconfig` plus `guest/kernel.config`. No modules, no EFI, uncompressed `Image`. - Initramfs: uncompressed, containing `cb-agent` as `/init` and static busybox. - Build-time check: fails if Kconfig silently drops a requested option. - **Repo snapshot:** `git archive | tar -x` into a directory cached per commit. It's attached as a read-only virtio-fs share (tag `repo`) and mounted at `/mnt/repo` when the host sends `attach`. An overlay at `/work`, whose upper layer lives in guest tmpfs, lets builds write without anything reaching the host. - Symlinks in the repo resolve inside the guest. A link to a host path doesn't escape; this was tested with absolute and `..` links. - **Agent protocol:** the host connects to vsock port 1024 and sends one JSON request per connection (`ping` / `attach` / `exec`). The agent streams stdout, stderr and the exit code back as length-prefixed frames. Closing the connection kills the command's whole process group. - **Networking:** off (no network device). - **Entitlement:** starting a VM needs `com.apple.security.virtualization`. `vzbench` is ad-hoc signed with it. #### Measured (M4 Pro, macOS 26, `native/target/release/vzbench`) | Scenario | Ready (median) | | --- | --- | | Cold boot, 1 vCPU / 256 MiB | 104 ms | | Cold boot, 2 vCPU / 512 MiB | 111 ms | | Cold boot, 4 vCPU / 2 GiB | 121 ms | | 4 VMs booting concurrently | 155 ms each | | 8 VMs booting concurrently | 184 ms each | | Restore saved state, 512 MiB | 131 ms (slower than cold) | | Restore saved state, 2 GiB | 256 ms (slower than cold) | | Namespaced process spawn inside a running VM | ~0.2 ms | Where a cold boot's time goes: - ~62 ms in `VZVirtualMachine.start()` on the host. This is fixed overhead; nothing inside the guest can speed it up. - 30–40 ms of guest kernel and init. - The rest is the vsock connect. Memory: an idle VM costs 46 MB of host footprint at 512 MiB guest RAM, and 78 MB at 2 GiB. The guest itself uses ~15 MB at idle. Takeaways: 1. **Cold boot is already fast enough; don't snapshot.** Restore time grows with guest RAM, and saved state is encrypted to this host. Snapshots only pay off once there's expensive userspace state to preserve, such as a warm language-server index. 2. **Hide the ~62 ms VZ floor with a warm pool.** This is done (`src/main/sandbox/pool.ts`). - One VM is booted with an *empty* `repo` device when the app starts. - Creating a sandbox takes that VM, points the share at the snapshot (`setShare` on the running device, ~0.1 ms), sends `attach`, and refills the pool in the background. - Measured in the app: **6 ms** from "Start VM" to a usable sandbox. - Every VM is stopped on quit. 3. **Toolchains, not boot, will set time-to-useful.** Ship them as read-only erofs images shared across VMs, where host page cache makes repeat use cheap. #### Topology: one VM per trust domain A *trust domain* is one review, meaning one repository at one head commit. - Each domain gets its own VM. - Workloads inside that domain (LSP, tests, dev server) run as processes or containers in the same VM, which gives resource limits and a clean restart for each workload. - One shared VM for everything would save ~50 MB per extra VM and allow shared caches. But it would put untrusted code from different repositories behind a single kernel. A hostile build script in review A could then read review B's (possibly private) code, or tamper with B's LSP answers and mislead the reviewer. - Boot at ~110 ms and ~50 MB per VM makes the stronger boundary cheap, and the same model maps onto remote sandboxes (for example Firecracker microVMs). ### Devcontainers (implemented) The repo's `.devcontainer` runs *inside the review's VM*. **The devcontainer config is untrusted repo content.** The host never interprets it; the whole devcontainer toolchain runs in the VM: ``` VM (one per review) guest/rootfs, Debian trixie ├─ dockerd 26 + buildx + compose data-root on the per-VM scratch disk (/dev/vdb) ├─ @devcontainers/cli (as uid 1000 "cb") `cb-devcontainer-up` → devcontainer up --workspace-folder /work │ └─ features, Dockerfile builds, compose, lifecycle hooks │ (initializeCommand, postCreateCommand, …) all run in the VM └─ devcontainer ← commands run via `docker exec -u -w ` ``` Anything host-flavoured in the config resolves against the VM, where it's harmless: - `initializeCommand` - bind mounts of `~/.ssh` or `docker.sock` - `${localEnv:…}` - `--privileged` We don't forward host env, credentials or the ssh agent. Measured: `devcontainer up` for an Alpine base image with a `postCreateCommand` takes **15 s** end to end from a fresh VM. That covers pulling from MCR, the CLI's UID layer build, and `apk add` inside the container. How it works: - **Root filesystem:** `rootfs.erofs` (lz4hc, ~440 MB) is attached read-only as `/dev/vda`. - The initramfs agent mounts it under a tmpfs overlay, moves into it and re-execs itself as PID 1. - Cold boot to agent-ready with the rootfs is ~170 ms. - The kernel adds cgroup-v2 BPF, nf_tables with xt compat, bridge and veth, for Docker networking inside the VM. - **UID mapping like a Linux host:** the CLI maps the container's remote user to the invoking user's UID. - The CLI runs as uid 1000, and `/work` is chowned to it. The overlay uses `metacopy=on`, so only metadata is copied up, never file contents. - Running it as root would remap `vscode` to uid 0. - **Clock:** VZ gives Linux guests no RTC. The host sets the guest clock (`set-time`) when it claims a VM and every 5 minutes after. - **TLS-inspecting endpoints** (Aikido, WARP, Zscaler, …): the host's admin-installed root CAs from the System keychain are installed in the VM when it's pooled. - That covers `update-ca-certificates` in the VM and `NODE_EXTRA_CA_CERTS` for the CLI. At container runtime, the VM's CA bundle is bind-mounted over the container's (on Debian, Ubuntu and Alpine layouts), and `SSL_CERT_FILE`, `REQUESTS_CA_BUNDLE`, `PIP_CERT`, `CARGO_HTTP_CAINFO` and `NODE_EXTRA_CA_CERTS` point at it for tools that ignore the system store (pip's vendored certifi, for one). - **Limitation:** `RUN` steps during image builds (Dockerfile, features) still see the image's own CA bundle. #### Egress: allow-list proxy over vsock The VM has no network device. The only way out: ``` guest process / container ──HTTP(S)_PROXY──▶ cb-agent :3128 (guest) cb-agent ──"dial N" on proxy-control──▶ host host ──vsock connect, proxy-accept N──▶ cb-agent splices the two host: parse CONNECT / absolute-URI → allow-list → DNS on host → refuse private/loopback/link-local IPs → connect → pipe ``` - Proxy settings are injected everywhere: agent exec env, dockerd `proxies` (pulls), and the docker client config (containers and builds, via `172.17.0.1:3128`). - **Policy** (`src/main/sandbox/egress.ts`): - hostnames only, matched exactly or as `*.suffix` - ports 80/443 only - an allow-listed name that resolves to a private address is refused, so nothing reaches the LAN or localhost - The defaults cover container registries, GitHub, OS mirrors, language registries and `containers.dev`. - **Modes** (app-wide, `egress-settings.ts`, `[sandbox.egress]` in the user's config; see [Settings](#settings)): - *Allow-list* (enforce, the default): the policy above. - *Monitor*: everything is allowed, and each connection the allow-list would have blocked is reported as "would block". This is for building a list: run the build once, then add what it needed. - *Allow all*: everything is allowed. - In every mode, only public addresses are dialled. Private, link-local and loopback addresses stay blocked (DNS is resolved on the host), so a sandbox never reaches the LAN, the Mac or cloud metadata endpoints. Garbage hostnames are refused too. - Outside enforce, the sandbox bar shows a warning badge. - **User additions** (hostnames or `*.domain`) live in the same file; the old `egress-allowlist.json` is migrated. The network panel (click the `net` counter) lists every host a sandbox reached, with *Allow* / *Allow \*.parent* buttons for blocked or would-block ones, plus an add box. - One policy object is shared by every sandbox and the registry cache, so changes apply to the next connection of running sandboxes. No restart and no image rebuild: the allow-list lives on the host, not in the guest. - Every decision is streamed to the sandbox panel (`[net] …` lines and an allowed/blocked counter). The registry cache also reports the CDN hosts it follows redirects to (Docker Hub moved blobs to `production.cloudfront.docker.com`, which is how that gap showed up). #### Registry cache (`src/main/sandbox/registry-cache.ts`) A host-side cache shared by every sandbox, in all windows. For known registry hosts only (Docker Hub, GHCR, MCR, Quay, ECR Public, GCR, registry.k8s.io): - **TLS:** the egress proxy terminates TLS with a per-install P-256 CA. - The CA is trusted inside VMs only, and its X.509 **name constraints** permit just those hostnames. Even with its key, it can't vouch for anything else; a test enforces this. - The key never leaves the host (`0600`). - **What's cached:** only content-addressed objects, meaning blobs and manifests requested *by digest*. - Each object is hashed while it streams and stored only if it matches the sha256 in its URL, so a hostile sandbox can't poison it. - The registry's redirect to its CDN is followed on the host, and credentials are never sent to the CDN host. - **Access check on a hit:** before serving from cache, the proxy sends a `HEAD` upstream with the requester's own credentials and serves only if the registry allows it. Cached private layers can't leak to a sandbox without access. - **Everything else** (tags, token flows, ranges) passes straight through. - **One fetch per object:** a request for an object already being fetched waits for that fetch and is then served as a hit (with its own access check). On a cold start the template VM and the review's VM pull the same images at once, and two concurrent fetches of one layer had the CDN send one of them at ~12 KB/s. The CDN sometimes trickles a lone fetch too, so a request waits at most 20 s (healthy waits are 6–16 s) and fetches the object itself if the other fetch isn't done by then or fails. Whichever of the two stores the object first, the other drops its upstream and sends its client the rest from the stored copy (both logged). - **Stalls:** an upstream request that goes 30 s without data (no response yet, or mid-body) is dropped, and so is the sandbox's connection, so its docker retries instead of hanging on it. Time a slow sandbox holds the response back doesn't count. Each drop is logged (`[registry-cache] …`). - **Eviction:** LRU at 20 GiB. Measured with the same devcontainer on a fresh VM: - **Empty cache:** 16 s (3 layers, 252 MB stored). - **Warm cache:** 10 s (all 3 hits). The rest is layer extraction, the CLI's UID layer and `postCreateCommand`. #### Next - Cache *built* layers (features, Dockerfile steps), keyed per repository at most since they derive from untrusted input. - CA injection for build-time `RUN` steps (a BuildKit CA mount or a rewritten build). - Start `devcontainer up` in the background when a review opens. Route LSP/tests into the container, and `forwardPorts` onto the preview pane. ### Code intelligence (`src/main/intel/`) Definition (⌘-click, F12 or `gd`), references (Shift+F12 or `gr`), both also in the viewer's right-click menu, and hover for TypeScript, JavaScript, Python, Go and Rust, per **project**, from two sources tried in order (SCIP, then LSP). A list of references (or of several definitions) opens in the sidebar, grouped by file. F4 / ⇧F4 step through it in that order, across files and wrapping, with the current one highlighted; Esc from the code or the list closes it. **Projects** (`projects.ts`, `versions.ts`). The host maps the commit's tree into projects before anything runs: one toolchain version, one dependency set, one language server and one index each. - Every manifest directory is a project (`pyproject.toml`/`setup.py`/ `requirements*.txt`, `package.json`, `go.mod`, `Cargo.toml`), except that workspaces fold their members into one: Cargo `[workspace]`, `go.work`, npm/yarn/bun `workspaces`, `pnpm-workspace.yaml`, uv `[tool.uv.workspace]`. Vendored and generated trees are skipped; loose TS/JS or Python files get an implicit repo-root project. - A file belongs to the innermost project of its ecosystem containing it. - **Dependencies** between projects come from local path/workspace deps in their manifests: uv `[tool.uv.sources]` and Poetry `path =`, `-e ../x` in requirements files, Node `file:`/`link:`/`portal:`/`workspace:`, Go `replace … => ../x`, Cargo `path =`. A target counts when it is another project's root or workspace member (`ProjectInfo.dependsOn`); the card shows "depends on" / "used by". - **Versions**, nearest file wins, walking up to the repo root: - Python: `.python-version`, `runtime.txt`, `mise.toml`, `.tool-versions`, Pipfile, else `requires-python` / Poetry `python` / `python_requires`; - Node: `.nvmrc`, `.node-version`, mise, `.tool-versions`, `volta`, else `engines.node`; Go: `go.mod` / `go.work` (`toolchain`, else `go`); Rust: `rust-toolchain(.toml)`. - A pin further up is used only if the project's own range allows it. A range picks the VM's version when allowed (no download), else the newest supported match. Unmatched ranges fall back to the default, flagged. - Overrides per project, from the sandbox card, are kept in the user's config under `[repos."".versions]` (never in the repo). - Manifests are untrusted: size-capped, parsed with `smol-toml`/JSON with failures ignored, and they only ever produce display strings and the plan. **Plan.** `cb-intel` (Python, in the VM) gets the projects as a plan and, for each in order: dependencies, then its index, reporting `PROJECT ` lines that the sandbox card shows live. Each project runs in the first of: the devcontainer (when it has the toolchain), its **language container** (below), the VM (Python and Node only); `WHERE …` tells the host, whose language server for the project then runs in the same place (restarted there if a query started it elsewhere first; one already there, as when a query started the same language container, is kept). By default the plan is the projects the review changes, then (up to 8 in all) the projects depending on a changed one, the ones a changed one depends on, and the rest; those left over can be indexed from the card. The host reads structured lines as they stream, not from a capped log tail. 1. **SCIP** (`scip.ts`), built per project in the sandbox: - **Dependencies first**, so imports resolve. Python gets a uv venv per project with **its** interpreter version (the language container's; in the VM uv fetches e.g. CPython 3.8 on demand), `/scratch/venvs/…`; editable path dependencies are installed in setuptools' compat mode (`.pth`), because the default import-hook finders can't be followed statically. Lifecycle scripts are skipped where the package manager allows: npm/pnpm/yarn/bun with `--ignore-scripts` (picked from `packageManager` or the lockfile), uv from `uv.lock`, `requirements*.txt` or `pyproject.toml` into a venv, `go mod download`, `cargo fetch` (rustup installs whatever `rust-toolchain.toml` names). Building a Python sdist can still run its setup code, inside the VM. - **Then the indexers**, one per top-most project directory, to depth 3: `scip-typescript` (tsconfig/jsconfig/package.json; JavaScript-only projects get an inferred config), `scip-python` (installed packages are described to it directly, so it needs no pip), `scip-go` (each go.mod) and `rust-analyzer scip` (each Cargo.toml). - Indexers write paths relative to their project; the host maps `metadata.project_root` to a repo prefix. - Each index is read back over vsock, parsed on the host and cached per commit in `/scip//`. Queries are in-memory lookups: about 1 ms, with no VM needed, and they survive restarts. - **Across indexes**: a symbol an index only references (another project's, or another `go.work` module's) is looked up by name in the commit's other indexes, ignoring the package version (scip-python gives the defining project the commit, a dependent the installed version). Definitions and hover come from the first index that has them, references from all; `local` symbols never leave their document. - The decoder is a small hand-written protobuf reader with hard limits (bytes, documents, occurrences), tests for truncated input, and path validation. Document paths are only used as keys into git. 2. **LSP** (`lsp.ts`) is the live fallback when there's no index or the index has no answer. - Language servers run over an agent `pipe` (stdio ↔ vsock), started when a file opens so the first ⌘-click is fast, and restarted after `cb-intel` so they see the fetched dependencies. - One server per project, rooted at it (at most 6 per sandbox; the least recently used stops), where the project was indexed: `typescript-language-server` (TS and JS), `pyright` with `python.pythonPath` set to the project's venv (not in a devcontainer), `gopls`, `rust-analyzer`. Paths map relative to the repository, not the project, so a jump from `services/billing` into `libs/common` stays a repo file. - Queries try the owning project's index first. - Documents are opened with the **head content from git**, not the VM's disk, which builds may have changed. - While a server reports work in progress (`$/progress`, rust-analyzer's `serverStatus`), an empty answer is retried once it's idle; LSP ContentModified errors are retried as the spec asks. - **Dependencies:** a location outside the commit's tree (`node_modules`, Go module cache and stdlib, crates, Rust `std`, site-packages, typeshed) opens read-only, read from the sandbox (VM, devcontainer or language container, where the server saw it) and labelled by package, e.g. `github.com/google/uuid@v1.6.0/version4.go`. **Language containers** (`images.ts`): without a devcontainer covering it, a project runs in an official image for its version, pinned by digest: `python:<3.8–3.14>-slim`, `node:<18–26>-slim`, and one `golang` and one `rust:slim` (`GOTOOLCHAIN=auto` and rustup fetch whatever `go.mod` / `rust-toolchain.toml` ask for). Refresh the pins with `bun scripts/pin-images.ts`. - Projects with the same image share its container (`cb-env--`, `sleep infinity`, started by `cb-env-up`). `cb-intel` starts them in the background: `dockerd`, then all pulls in parallel (through the shared registry cache; `cb-pull` gives each attempt 150 s and retries once), while projects whose container is ready go first. A language server opened before `cb-intel` got to its project starts the project's container itself (Go/Rust have nowhere else to run). - `/work`, `/scratch` and `/opt/cb-tools` are mounted at the VM's paths, so caches, venvs and indexes are shared with the VM and nothing is translated. `cb-env-run` (the containers' `cb-run`) puts `/opt/cb-tools/bin` first, points caches at `/scratch` and the TLS stacks at the VM's CA bundle (mounted over the image's). - `/opt/cb-tools` holds everything we bring: `gopls`, `scip-go`, `rust-analyzer`, uv, bun, and under its own Node (an official glibc build, off `PATH`) pyright, scip-python, the TypeScript server and indexer and corepack. The TypeScript tools and corepack run on the image's `node` when it has one, so the project's Node version is the one used. - Any failure (pull, start, no index) falls back to the VM for a Python or Node project; a Go or Rust project then fails, saying it needs its language container or a devcontainer with the toolchain. - **Scratch template:** each VM's docker would otherwise unpack its images again (4–15 s each). So the provider builds, once per guest image and image list, a scratch disk with the default-version images (Python 3.13, Node 20, golang, rust) already pulled: a VM runs `cb-prepull` through the registry cache, stops docker and unmounts, ~25 s in the background on first start, ~2.4 GB in `/sandbox/scratch-templates/`. Every pooled VM's scratch disk is an APFS clone of it (`cp -c`, ~3 ms, copy-on-write), and `dockerd` is started while the VM is pooled, in the background (a VM is handed out without waiting for it; `cb-docker-up`'s lock makes the first user of docker wait). `cb-docker-up` starts containerd itself: a dockerd that starts its own waits a second for it (ready in 1.4 s against 0.45 s). Other versions still pull on demand, in parallel with the work that doesn't need them. **Toolchains in the image** (the VM fallback): Python 3 (uv fetches other versions), Node, gcc for build scripts; no Go or Rust (−814 MB, rootfs.erofs 1.25 GB → 840 MB). Python and Node stay because `cb-intel` and the devcontainer CLI need them anyway. All writes go to the per-VM scratch disk (`cb-scratch`, mounted at `/scratch` while the VM is pooled): the `/work` overlay's upper layer, `node_modules`, module and crate caches, venvs and docker's data root. Guest RAM holds none of it. **With a devcontainer:** Go, Rust and Python are indexed and served *inside* it when it has `go`/`cargo`/`python3`, using the project's own toolchain versions, system libraries (e.g. for `-sys` crates) and installed packages (e.g. from `postCreateCommand`); the VM fetches no dependencies for them. The image's static `gopls`, `scip-go`, `rust-analyzer` and `cb-scip-index` are mounted at `/opt/cb-tools`, and indexes come back through a mounted scratch directory. Python only runs in the devcontainer when our Node runs there, so Alpine-based ones don't. TS/JS runs in its language container, which sees the same `/work` (including a `node_modules` from `postCreateCommand`). **Automatic flow:** opening a review starts its sandbox from the warm pool, runs `devcontainer up` when there's a `.devcontainer`, then `cb-intel`. It's on by default, with an "auto" toggle. - Measured, *Review* to ready indexes: a polyglot fixture (Go + Rust + JS + Python, each with a dependency) 6.6 s; the same with a Go devcontainer (image pull included) 15–17 s; this repo (bun install of 143 packages, TS plus two Rust crates) 14.3 s; a monorepo of six projects (Python 3.8 and 3.12 services with uv fetching both interpreters, a shared lib, an npm workspace, a `go.work` of two modules, a Cargo workspace) 12–14 s. From "index" to ready indexes on that monorepo, same driver: 10.8–11.3 s in the VM, 11.8–12.3 s in six language containers (four from the scratch template, ready 0.7 s in; Python 3.8 and 3.12 pulled meanwhile). - SCIP answers in ~1 ms; a warm language server in ~5–300 ms; a cold rust-analyzer waits ~1 s for its workspace to load. **Trust:** both sources come from tools running over untrusted code, so their output is a hint, not proof. - Only locations inside the repo are accepted, and hover is rendered as plain text. - A hostile repo can at worst mislead navigation within its own review. - The SCIP index is also computed from `/work` as it was when indexing ran, after any `postCreateCommand`. Pitfalls found and fixed: - Rust's `io::copy` uses `splice(2)` when one side is a pipe, which silently stalls on AF_VSOCK. The agent uses plain read/write loops. - scip-python asks pip for installed packages; it gets a generated `--environment` instead. ### Rendered Markdown (`src/main/markdown/`, `src/renderer/src/markdown/`) Markdown is repository content, so it's parsed in the review's VM, not on the host. - A Markdown file opens as source. Opening one starts the review's VM if it isn't running. The file head's **Render** button (⇧⌘V) is disabled until the VM is ready. Once a file has been rendered, the button becomes a Source / Rendered toggle. - **Parsing** (`render.ts`): - The host reads the file at head from git. It sends markdown-it's own UMD build and the text to the VM's `node` over an agent `pipe`, as one JSON line. The pipe has no half-close, so the guest answers and then exits. - The answer is one HTML fragment per top-level block, with the source lines it came from. YAML front matter is kept as a code block. - The host checks the shape and clamps the lines. It caps the input at 2 MiB, the output at 16 MiB, and the run at 15 s. - The request is refused unless the sandbox holds that repository at that commit, so one review's text never reaches another review's VM. - **The HTML is untrusted** (`safe.ts`): - The renderer parses each fragment with `DOMParser`, which gives an inert document: no script runs and nothing loads. - Only allow-listed elements and attributes are kept, as plain data that becomes React elements. Nothing is assigned as `innerHTML`. - If a block contains `script`, `iframe`, `svg`, form controls, media and the like, the whole block is shown as source. - **Links:** - `http(s)` and `mailto` links leave through the window-open handler. - Relative links open the file in the repository. - `#anchors` scroll to GitHub-style heading ids. - Everything else, `javascript:` included, is not followed. - **Images:** - Images stored in the repository come from git as `data:` URLs (`subject:image`, 10 MiB cap). - Remote images are fetched by `curl` in the review's VM (`markdown:remoteImage`, `images.ts`), so they go through the egress proxy: the allow-list applies, redirects included, and the egress log shows them. The bytes come back as a `data:` URL, typed by sniffing (the server's Content-Type isn't trusted), capped at 10 MiB. - Hosts that aren't on the allow-list are never asked, whatever the egress mode, because an image request tells its host that someone is reading the file. A notice lists those hosts, with "Allow " and "Show egress log". The renderer never loads a remote URL itself, and CSP `img-src` forbids it anyway. - **Review:** - Each block keeps its first line number in the gutter. - Changed blocks get the add ground and a +; blocks with removed lines get a −. `[` and `]` step through the changed blocks. - **Removed blocks inline** (the same switch as removed lines): the base is rendered too (`markdown:render` with side `base`, still in the head's VM, since it is the same review's content). A base block shows, on the del ground with a −, if any of its lines is in a hunk's old range (`removed.ts`). It goes just before the head block where that hunk lands, so a rewritten paragraph reads − then +. The pair's changed words take add-emph / del-emph (`words.ts`). This is a word diff (LCS) over the text of the two sanitized trees, and the marks go inside the text leaves, so links and emphasis keep working. Code blocks are skipped, and so are pairs with under 40% of their words in common, because those read as replaced rather than edited. - **Math** (`$…$`, `$$…$$`, ```` ```math ````) is set by MathJax in the same guest program: `guest/math.js`, bundled at build time into a string (the `guestBundles` Vite plugin, `bundle.ts`), sent only when the file has a `$` or a math fence. TeX becomes standalone SVG (paths, no fonts, no DOM), shown as a `data:` `` sized in ex and aligned to the text's baseline. MathJax's `currentColor` is replaced with the theme's ink, since an `` can't inherit it. The TeX packages that could link, style or load more code (`html`, `require`, `autoload`) are left out. - **Mermaid** (```` ```mermaid ````) is drawn in the guest too, without a browser: `guest/mermaid.js` runs Mermaid on svgdom, a pure-JS SVG DOM whose `getBBox` measures text with real font metrics (fontkit). Text is measured with the design system's Instrument Sans, and the same font is embedded in each SVG, since an `` can't reach the page's fonts. - Shims cover what Mermaid expects of a page: a ``, `parentElement`, `offsetWidth` (680, the text column), `CSSStyleSheet`, and the extra height of wrapped label rows, which svgdom's text box leaves out. - Mermaid runs with `securityLevel: "loose"`, because DOMPurify can't run on svgdom. For the same reason the bundle swaps DOMPurify itself for a pass-through (`guest/dompurify.js`): Mermaid runs some markup, such as architecture icons, through it whatever the level, and the real one would return nothing. That's acceptable only because the SVG is never shown except as an ``, which runs no script and follows no link. - Mindmaps and architecture diagrams are laid out by cytoscape, which Mermaid hands a hidden `
` to draw in. The bundle swaps in `guest/cytoscape.js` (both swaps are `bundle.ts`'s stand-ins). It runs cytoscape headless with styles on, and adds the base renderer's geometry: the edge endpoints and midpoints Mermaid reads. It leaves out drawing, input and the render loop, and measures labels with the same font. Mindmap circles, squares and hexagons get their labels centred with CSS, because Mermaid leaves that to HTML labels. - Diagrams are drawn for the app's theme (the render request carries it, and the cache is keyed on it), up to 50 distinct diagrams per file. The guest program is async for this. - Checked on Debian trixie's Node 20: flowchart, sequence, class, state, ER, Gantt, pie and gitGraph (gitGraph's rotated commit labels are rough), mindmap (every node shape) and architecture. Any diagram Mermaid can't parse stays as code. - Cost: a 4.5 MB bundle, sent only for files with a mermaid fence; each SVG is ~130 KB with its font. - Fenced code is highlighted with the viewer's own style, using the CodeMirror language named by the fence (Lezer, in the renderer). - The Outline lists the file's headings, read from the source text (`headings.ts`; fenced code and front matter are skipped). A click scrolls to the heading, in either view. - Comment anchors to the block's source lines: a removed block's comment anchors to base lines. Threads show under the block, on their side, that holds their line. ### Remote The same `Sandbox` interface over an authenticated WebSocket or gRPC connection. The snapshot is uploaded (or fetched by the remote from the git remote at the exact commit), and exec, ports and LSP are multiplexed over the connection. ## Roadmap 1. ✅ Shell: Electron + React + CodeMirror, open local/remote repo, pick base/head, file tree with change markers, full-file view with gutter marks and a toggle for inline removed code. 2. ✅ Sandbox interface + VZ native addon: VMs boot from the app (napi, dev-signed Electron), commands stream to a panel, warm pool. 3. ✅ Devcontainers in the VM (guest rootfs, allow-list egress proxy), shared digest-verified registry cache, multi-window/instance. 4. ✅ Code intelligence for TS/JS, Python, Go and Rust: dependencies fetched in the sandbox, SCIP indexes cached per commit, plus LSP in the VM or devcontainer (definition, references, hover). Next: cross-repo symbols, a shared dependency cache across VMs. 5. ✅ Structure via tree-sitter (worker + WebAssembly): changes by symbol (added / removed / renamed / modified / formatting-only / comments-only), outline, sticky scope header. Next: search-based navigation fallback from tags queries (no sandbox needed), more languages. 6. Preview pane via port forwarding. 7. Remote sandbox provider. 8. ✅ Reviews in a local SQLite store with backups ([REVIEW-FORMAT.md](REVIEW-FORMAT.md)); export to a GitHub pull request and read-only fetch of what happens there ([integrations/](integrations/README.md)). Next: sending replies and resolutions, more adapters ([integrations/README.md](integrations/README.md) §9), optional forge sources (GitHub/GitLab) that resolve to `(repo, base, head)`.