Architecture#

The shape#

┌──────────────────────────── Electron main ────────────────────────────┐
│  apps/desktop        composition root: picks the engine, wires core   │
│                                                                       │
│  packages/engine-electron    the ONLY package that imports electron   │
│        │ implements                                                   │
│  packages/engine-api         BrowserEngine / EngineWindow / EngineTab │
│        ▲ depends on                          / Automation / SecretStore│
│  packages/core               tabs, scripts, projects, models, AI      │
│    ├─ ohm-lang               splitter → lexer → parser → Lua codegen  │
│    ├─ ohm-runtime            wasmoon VM in a worker, async scheduler  │
│    ├─ curves, graph, docview                                          │
│    └─ protocol               typed methods and events                 │
└───────────────────────────────────────────────────────────────────────┘
                    ▲ ohm:call / ohm:event over a 2-function preload
┌───────────────────────────── Renderer ────────────────────────────────┐
│  apps/ui             React + Monaco, served from ohm-app://           │
│                      engine-agnostic: it only knows the HostBridge    │
└───────────────────────────────────────────────────────────────────────┘

The engine seam#

Ohm is built to outlive Electron. packages/engine-api defines what a browser engine must provide — windows, tabs, page state, raw CDP automation, file dialogs, a secret store — and packages/engine-electron is the only package allowed to import 'electron'. scripts/check-engine-imports.mjs fails the build otherwise, and npm run check runs it first.

The proof that the seam is real: the tab tests run a complete second implementation of EngineTab and EngineWindow with no Electron at all.

Automation is deliberately raw CDP rather than an Electron-shaped API, because a Chromium fork speaks CDP natively — so every script that drives a page survives the engine swap.

To replace the engine: implement the engine-api interfaces, and change the one file that constructs an engine (apps/desktop/src/main.ts).

Where state lives#

Core owns everything. The engine reports (state, openRequest) and obeys (navigate, attach); the UI renders snapshots and sends commands. Every tab mutation funnels through one function that emits a complete tabs.changed snapshot — no incremental events, no client-side merge.

The page view is a real Chromium view layered over the UI, so the UI hides a page by reporting a zero-sized rectangle rather than by unmounting anything. Bounds flow UI → core → engine via a ResizeObserver.

Process and thread model#

Runs in What
Main process core, tabs, projects, models, embedded llama.cpp
Worker thread the Ohm interpreter (one per session), so a runaway script can be terminated
Renderer the UI, and web models (WebGPU exists only there)
Renderer worker Monaco's workers, transformers.js

Scripts never run in the renderer, and the renderer never touches the filesystem — it asks core.

Custom schemes#

Scheme Serves
ohm-app:// the built UI, so it has a real origin (workers, wasm, code cache)
ohm-models:// local model weights, for transformers.js
ohm-file:// project documents, read-only, for the viewers

Each handler resolves the request against its root and refuses anything that escapes it. The production CSP is injected at build time; the dev server runs without one because React refresh needs inline scripts — so if something works in npm run dev but not npm start, suspect the CSP first.

The language pipeline#

source.ohm
  → splitter      Lua | $inline$ | $$display$$   (string- and comment-aware)
  → lexer         LaTeX tokens
  → parser        AST
  → codegen       Lua, line for line
  → wasmoon       Lua 5.4 in a worker thread

Line preservation is the only source map: the generated Lua has the same line numbering as the .ohm file, so a runtime error names the right line with no extra machinery.

Host calls cross as JSON strings only — no live object proxies — and the Lua side runs a cooperative scheduler over coroutines so asynchronous calls read as straight-line code.

Data flow for a typical action#

Running a script from the editor:

  1. UI: host.call('scripts.run', { path })
  2. preload → ipcMain.handle('ohm:call') → authorised by webContents id
  3. core: compile (diagnostics stream back as err lines), start a worker
  4. worker: run the Lua, emitting output messages and hostCall requests
  5. core: serves host calls (tabs, AI, files, projects) and forwards output as scripts.output events
  6. UI: appends to the console live; a final scripts.status closes the run

Testing#

npm run check runs the engine-boundary check, the spec validator, the typecheck and the test suite. The tests cover the language (splitter through codegen), the runtime (including real worker termination), curves and sweeps, graphs, projects, specs, settings and models, document parsing and the tab state machine — with fakes for the engine, so none of it needs a running browser.

The app is also drivable from outside over CDP (--remote-debugging-port), which is how the end-to-end checks in Build and package work.