You can run several pnpm dev sessions at once, one per git worktree, each with
its own port, its own Electron profile, and (optionally) a signed-in session —
with zero changes to how the primary checkout behaves.
# in a fresh linked worktree's desktop/windows:
pnpm bootstrap # install deps, copy .env, build helpers, print your ports
pnpm dev # runs on THIS worktree's derived port + its own profile
pnpm seed:auth # optional: boot signed-in by copying the primary app's session- The primary checkout (your main
omiclone) is unchanged: rendererhttp://localhost:5179, CDP9222, the default userData profile. - Each linked worktree derives its own renderer port (5180-5279) and CDP port
(9230-9329) from its folder name, plus its own userData profile
(
omi-windows-sandbox-<worktree>) and a— <worktree>window-title suffix. - Run
pnpm dev:instancein any checkout to see exactly what it resolves to.
git fetch origin
git worktree add .worktrees/<name> -b <branch> origin/main
cd .worktrees/<name>/desktop/windows && pnpm bootstrapBranch from origin/main — origin is this repo's fork and its main is the
base for all work. upstream is the read-only BasedHardware remote; never branch
work from upstream/main.
In dev the renderer is served by Vite at http://localhost:<port>, and the app's
sign-in state persists per origin: Firebase auth uses browserLocalPersistence
(src/main/../lib/firebase.ts), and onboarding/prefs live in localStorage
(omi-windows-prefs-v1) — both keyed by the renderer's origin, which includes the
port. So a single pinned port means only one dev app can run, and moving to a
different port silently signs you out (new origin = empty localStorage).
Two dev instances therefore need different ports (so they don't fight over one) but that means different origins (so their saved sessions don't automatically carry). This tooling makes both problems disappear: a stable per-worktree port, an isolated profile so instances never clobber each other, and a one-command seed that copies the session across origins when you want to skip the web login.
src/main/devInstance.ts is the single source of truth.
- Primary vs linked: a linked git worktree has a
.gitfile (agitdir:pointer); the primary checkout has a.gitdirectory. The primary always resolves to{ name: 'primary', rendererPort: 5179, cdpPort: 9222 }and stays on the default profile — nothing about the main flow changes. - Ports: derived from the worktree folder name with the same FNV-1a + avalanche
hash used for packaged builds (
portDerivation.ts). Renderer ports land in 5180-5279, CDP ports in 9230-9329 (a separate band so the two never coincide). Deterministic, so a worktree keeps the same port across launches (stable origin = stable saved session). - Collisions fail loud: Vite runs with
strictPort, so if two worktrees happen to hash to the same renderer port the secondpnpm deverrors out instead of drifting to a new origin. SetOMI_DEV_PORTon one of them to move it.
The Vite config (electron.vite.config.ts) sets the dev server port and also
stamps OMI_INSTANCE / OMI_DEV_CDP_PORT / OMI_SANDBOX into the env for the
Electron main process; the main process re-derives from its own cwd as a fallback,
so correctness never depends on that env being forwarded.
scripts/seed-auth.mjs copies the signed-in session from a running source
instance into a running target instance:
- Connects to the source app's Chrome DevTools Protocol port and reads its
localStorage(the whole signed-in state: Firebase session + onboarding/prefs). - Connects to the target app's CDP port and writes those keys into its
localStorage, then reloads the target so Firebase rehydrates the session.
Defaults to primary (CDP 9222) -> this worktree (target derived from cwd), so
from a worktree you usually just run pnpm seed:auth with the primary app open.
pnpm seed:auth # primary -> this worktree
pnpm seed:auth --to fix-orb # primary -> the "fix-orb" worktree
pnpm seed:auth --from-port 9267 --to-port 9264 # explicit CDP ports
pnpm seed:auth --auth-only # only firebase:* + omi-windows-prefs-v1
pnpm seed:auth --dry-run # show what would be copiedThe session lives in the Chromium profile's Local Storage leveldb, keyed by
origin (http://localhost:5179). A raw file copy into another profile would land
the data under the wrong origin (the target runs on a different port), so the
target renderer wouldn't find it — and leveldb is a single-writer store, so you'd
also have to close the source app first. Reading/writing localStorage over CDP is
just JS on each side, so it translates origins naturally and works while both apps
are running (the normal dev state). It reuses the existing OMI_DEV_REMOTE_DEBUG
seam, which is dev-only: the packaged app never opens a CDP port
(dev/bench.ts gates it on !app.isPackaged).
| Var | Effect |
|---|---|
OMI_INSTANCE=primary |
Force the primary instance (5179, default profile) from any worktree |
OMI_INSTANCE=<name> |
Force a named instance (ports derived from <name>) |
OMI_DEV_PORT=<n> |
Pin the renderer port (e.g. to dodge a collision) |
OMI_DEV_CDP_PORT=<n> |
Pin the CDP port |
OMI_DEV_REMOTE_DEBUG=<n> |
Pin the CDP port; wins over OMI_DEV_CDP_PORT (it is the switch actually bound). pnpm dev:instance / seed:auth resolve the same precedence, so set it in the shell you run both from. |
OMI_SANDBOX=<name> |
Pin the userData profile suffix (OMI_SANDBOX=1 = legacy chat-kg) |
OMI_DEV_NO_REMOTE_DEBUG=1 |
Don't open a CDP port for this instance |
OMI_DEV_HW_GPU=1 |
Use hardware GPU instead of the dev software-render default |
OMI_OZONE=wayland / OMI_OZONE=x11 |
Linux only: force native Wayland or XWayland, overriding the auto-detected default. XWayland is the default everywhere except niri/Sway/Hyprland (detected via each one's own session marker env var — see src/main/linuxCompositor.ts), where the main window can otherwise fail to map at all and native Wayland is the default instead. Native Wayland costs global shortcuts (push-to-talk/overlay summon) and active-window detection, and the companion bar can't be positioned off-screen. |
pnpm deverrors that the port is in use — two worktrees hashed to the same renderer port. This IS fail-loud (VitestrictPort). SetOMI_DEV_PORT=<free port in 5180-5279>on this one.seed:authsays it can't reach a CDP endpoint — the source or target app isn't running (or was started withOMI_DEV_NO_REMOTE_DEBUG=1). Start it withpnpm devin that checkout.seed:authreaches the WRONG app / a CDP port collided — unlike renderer ports, CDP-port collisions are not fail-loud: both apps still launch, but the second to start fails to bind its CDP port, so it can't be seeded (and CDP tools hit the first app). Two worktrees hashing to the same CDP port is the usual cause. Fix it by pinningOMI_DEV_CDP_PORT=<free port in 9230-9329>on one of them (pnpm dev:instanceshows each instance's resolved CDP port).seed:authwarns "no firebase:authUser" — the source app isn't signed in; sign into the primary app once, then re-run.- Worktree looks signed-out on first run — expected: a fresh worktree gets an
empty profile. Run
pnpm seed:auth(or just sign in there once). - Two windows look identical — check the title-bar suffix (
— <worktree>) or runpnpm dev:instanceto confirm which port each is on. - Native Wayland (auto-selected on niri, or forced via
OMI_OZONE=wayland) main window maps but never paints (blank; tray icon works) —pnpm devforces software rendering by default (applyDevGpuStabilityinsrc/main/dev/bench.ts, aimed at Windows GPU-process crashes), and Chromium's software-compositing path has known presentation bugs on native Wayland. Run withOMI_DEV_HW_GPU=1too. Confirmed fix on Asahi Fedora Remix (aarch64) + niri. - Two extra floating windows appear under native Wayland — the
companion bar (
src/main/bar/window.ts) and the focus-halo glow window (src/main/glow/glowWindow.ts, created eagerly at startup) both position themselves with explicitsetBounds({ x, y, ... })intending to stay parked off-screen; native Wayland doesn't support client-requested top-level placement (only XWayland does), so both float in the screen center instead. Both stay functional (bar right-click menu works; glow still renders its ring when triggered) — tile or ignore them. A real fix needs Wayland layer-shell-aware positioning, which niri supports (wlr-layer-shell) but neither window currently uses.
- Only linked worktrees auto-isolate; the primary checkout is deliberately left on the shared default profile (that's where your real data + session live).
- Everything here is dev-only. Packaged builds serve the renderer from
rendererServer.tswith the per-install port fromportDerivation.tsand never run the dev-instance code.