Developer guide#

Everything a new person on the team needs to go from a clean machine to a merged change. Read Architecture and the Coding guide alongside it; this one is the procedure.

What you are working on#

Ohm is a browser built on Chromium (Electron today, a Chromium fork later) with a scripting language built in: Lua 5.4 with LaTeX math, compiled line-for-line to Lua and run in a worker thread. Around that sit projects, prompts, experiments, curves, graphs, specs and document annotation — the parts that make it a research tool rather than a browser with a macro language.

Two constraints shape almost every decision:

  1. The engine is replaceable. Only packages/engine-electron may import electron.
  2. Files are the source of truth. Everything the user makes is a plain file in a folder they own.

Setting up#

You need Node 22 or newer and git. Nothing else — no Rust, no Python, no C++ toolchain unless you rebuild llama.cpp from source, which nobody does.

git clone <repo> && cd ohm
npm install
npm run check      # ~40 seconds; everything should pass on a clean checkout
npm run dev        # Vite for the UI, esbuild watching the main process

The installer workspace is separate and has its own dependencies. You only need it when you are packaging:

cd installers && npm install && npm run assets

On Windows, build.bat does the whole thing; on macOS and Linux, ./build.sh.

Platform notes#

Platform Notes
Windows ELECTRON_ENABLE_LOGGING=1 or you see no console output from the main process. Git Bash works; PowerShell mangles $ and backslashes in arguments.
macOS Building the DMG needs macOS (hdiutil, codesign). Unsigned builds run locally and warn elsewhere.
Linux AppImage and .deb need Linux tooling; --targets tar.gz works from any host.

The map#

Path Owns
apps/desktop the composition root: picks the engine, resolves folders, wires core
apps/ui React + Monaco renderer, served from ohm-app://, engine-agnostic
packages/engine-api the interfaces a browser engine must provide
packages/engine-electron the Electron implementation — the only import 'electron'
packages/protocol every method and event between UI and core, as types
packages/core tabs, scripts, projects, prompts, experiments, settings, models, AI
packages/ohm-lang splitter → lexer → parser → Lua codegen, and the MathML renderer
packages/ohm-runtime wasmoon in a worker thread, the cooperative scheduler, the matrix prelude
packages/curves piecewise curves and the safe expression language
packages/graph graph components and the graph→Ohm compiler
packages/docview PDF, Word, spreadsheet and slide parsing
packages/annotate capture modes, anchoring and drift detection
scripts/ the checks and generators: engine boundary, specs, docs, screenshots
docs/ the documentation, compiled into the app's Help tab
installers/ Windows, macOS and Linux packaging

The daily loop#

npm run dev
  • The UI hot-reloads. Save a .tsx and the window updates.
  • The main process rebuilds and Electron restarts. You lose open tabs; that is normal.
  • The worker is rebuilt with the main process, so a language change needs the restart.

Useful while you work:

npx vitest run packages/core/test/app-config.test.ts    # one test file
npx vitest                                              # watch mode
npm run typecheck                                       # just the compiler
OHM_DEVTOOLS=1 npm run dev                              # DevTools for the UI

Run a throwaway instance that cannot touch your own data:

Ohm.exe --data-dir /tmp/ohm/data --profile-dir /tmp/ohm/profile --scripts-dir /tmp/ohm/scripts

Adding a feature, end to end#

The worked example is the one in the repository: moving Ohm's folders. It touched every layer, in this order.

1. Decide where the logic lives, and keep it out of Electron. packages/core/src/app-config.ts takes argv, env and defaults and returns paths. It imports node:fs and node:path and nothing else, which is why it is testable and why the engine check stays green.

2. Add the protocol. 'config.get': [void, AppDirsState] and friends in packages/protocol/src/index.ts, with the interfaces beside them. Now CoreHandlers demands a handler and the UI gets types.

3. Add an engine capability if you need one. Restarting is something only the engine can do, so BrowserEngine.restart() went into engine-api and app.relaunch() + app.exit(0) into engine-electron.

4. Handle it in core. Small handlers that delegate. Validate the input (checkDirKey), and refuse impossible requests with a sentence the user can act on.

5. Wire the composition root. apps/desktop/src/main.ts. Order matters here more than anywhere: userData and sessionData must be set before Chromium opens them, so the config is read synchronously at module scope, before whenReady.

6. Build the UI. A component in apps/ui/src/components, a section in SettingsPane, styles scoped to your own classes in styles.css.

7. Test the logic, not the plumbing. 23 tests for precedence, parsing, corrupt files and refusals. Two of them failed first and were right to fail — the flag/env order was wrong, and restartRequired was claiming a restart would help when a flag would survive it.

8. Then verify it for real. Tests do not prove that Chromium actually wrote its profile where you asked. Drive the app over CDP (--remote-debugging-port), or run it by hand and look.

9. Document and specify. docs/reference/environment.md, docs/guide/storage.md, a section in docs/specs/src/12-storage.spec.mjs, then npm run specs, npm run docs, npm run shots if a screen changed.

Shorter recipes#

To add… Do this
A function scripts can call a method on ScriptHost, then the table in docs/reference/script-api.md and the editor's completion data
An operator (\Foo_{\Psi}) the operator table in packages/ohm-lang, codegen, a host implementation, the guide and a compile test
A settings page a component beside LibrarySettings.tsx, a Section in SettingsPane.tsx, a row in SECTION_LABELS
A project item type ProjectService.listItems kinds, an editor view, the file-format reference
A document viewer a parser in packages/docview, a view in apps/ui/src/views, a case in DocumentViewer.tsx

Debugging#

Symptom Where to look
Nothing in the console on Windows ELECTRON_ENABLE_LOGGING=1
Works in npm run dev, not in npm start the production CSP, injected at build time
A script hangs Shift+F5 stops the worker; the scheduler is cooperative, so a tight Lua loop never yields
A page action does nothing the CDP automation is attached per tab — check the tab is not discarded
Wrong line in an error compilation is line-for-line; if it drifted, the codegen emitted a newline it should not have
The UI cannot see a file the renderer has no filesystem. It needs a core method or an ohm-file:// URL

To drive a running instance from outside — which is how the screenshot and verification scripts work:

Ohm.exe --remote-debugging-port=9333

Then connect to http://127.0.0.1:9333/json/list, find the target whose URL contains ohm-app, and talk CDP to it. scripts/capture-docs.mjs is a complete worked example.

Before you hand it over#

A review here asks these questions:

  • npm run check passes.
  • Nothing outside engine-electron imports electron.
  • New behaviour has a test, and the test would fail without the change.
  • Errors say what happened and what to do about it.
  • Secrets stay in core; nothing new is sent to the renderer that should not be.
  • Paths from outside are contained; deletes go to the trash; writes are atomic.
  • Comments explain decisions, not syntax, and none of them are now false.
  • Docs, specs and screenshots updated for anything a user can see.
  • No new dependency without a sentence justifying it.
  • You ran it, not just the tests.

Building and releasing#

./build.sh all             # macOS, Linux: check, build, package
build.bat all              # Windows

Or by hand — see Build and package for the detail:

npm run build                                  # app only
cd installers
npm run build:win                              # NSIS wizard + portable
npm run build:mac                              # DMG + zip     (on macOS)
npm run build:linux                            # AppImage + deb (on Linux)
npm run build:linux -- --targets tar.gz        # works from any host
npm run build:mac -- --validate                # check the config from anywhere

Then actually install the result and run it. The end-to-end checklist is in Build and package; at minimum, install, launch, run a script, open a page, and uninstall.

Long work, across sessions#

plans/ holds work that spans more than a sitting. One markdown file per step, with its status in the frontmatter, so the plan diffs in git and merges like code.

npm run plan                          # where everything stands
node scripts/plan.mjs next            # the next step, in full
node scripts/plan.mjs done 05-ocr "tessdata was the slow part"

Every step is self-contained on purpose: what to change, how to verify it, what done means. Someone who has never seen the discussion that produced the plan should be able to read one file and do the work. When you stop mid-step, leave what you learned in its Notes — that is what makes the next start cheap. npm run check validates the plans, so a step that depends on something that does not exist, or is marked done while its dependencies are not, fails the build.

When you are stuck#

  1. Read the spec for the area: docs/specs/*.spec.md are written to be rebuildable from, and they record the decisions and the known gaps.
  2. Search the tests. Most non-obvious behaviour has one, named in plain English.
  3. Check the traps table in the Coding guide. Several hours of this project are already recorded there.