A fixed pool of named bundles (omi-e2e-1 … omi-e2e-N) that a human grants
every macOS permission once, and that headless agents then lease per task
instead of minting a fresh bundle. scripts/omi-e2e-pool owns the leases;
run.sh refuses to build a pool slot without one.
macOS binds every TCC grant — Microphone, Screen Recording, Accessibility, System Audio, Notifications, Automation, folder access — to the bundle ID plus the signing certificate. Two consequences:
- A grant survives every rebuild as long as neither changes. That is what makes a slot reusable from any worktree, at any commit.
- Nothing but a human can create the grant. No agent, rule, or script can click
the system dialog, and Screen Recording and Accessibility cannot be granted by
tccutil, an MDM profile, or a database edit on a machine with SIP enabled.
run.sh's default of deriving omi-<worktree> per linked worktree is right for
interactive work and wrong for headless work: every task starts with zero grants
and stops at the first dialog. A pool moves the human step from "once per task"
to "once per slot".
Pick the pool size from how many lanes actually run at once; each slot costs one grant pass. The default is 3 and any positive integer works:
export OMI_E2E_POOL_SIZE=3 # put it in the shell profile of the host
cd desktop/macos
./scripts/omi-e2e-pool setup # prints the checklist below, per slotFor each slot:
- Build and launch it once from any checkout — with
--full, because a slot's first build has nothing to reuse (later launches take the--fast-onlydefault). The pool pins the signing identity for the slot on first use (defaultOmi Local Dev Signing, the stable self-signed identityrun.shcreates without a GUI; seelocal-code-signing.md). SetOMI_E2E_POOL_SIGN_IDENTITYbefore the first acquire to pin an Apple identity instead; changing it later resets every grant the slot holds../scripts/omi-e2e-pool run --slot 1 -- ./run.sh --yolo --full --no-wait
- Open the slot's Permissions page and grant every row when macOS asks. Screen
Recording and Accessibility land in System Settings › Privacy & Security; let
the app "Quit & Reopen" if it asks.
OMI_AUTOMATION_PORT=47701 ./scripts/omi-ctl navigate settings permissions --show
- Prove it, then release the slot:
./scripts/omi-e2e-pool check --slot 1 # every required row must read "granted" ./scripts/omi-e2e-pool release --slot 1
Recurring: macOS 15 and later periodically re-ask whether an app may keep
recording the screen. check reports that as screen_recording=stale; one
click clears it.
- shared — a full launch clones the Omi Dev session before start, exactly
as any named bundle does. Developer sources dump from a JSON file under
Application Support (
developer-secrets/<bundle-id>.json), so cloning works from a Background agent shell (launchdBackgroundsession, ssh) as well as a GUI shell. Rebuilds never prompt for Keychain access. Sign in once to Omi Dev; later slots clone that file-backed session. Tests then run as the developer's account against the dev backend, so their writes are real. - isolated — the slot keeps its own session. Sign in once inside the
slot app with a dedicated test account; the session persists in the slot's
own developer-secrets file across rebuilds. The Rewind history is not cloned either.
The mode sticks to the slot, not to the lane that set it, and
./scripts/omi-e2e-pool acquire --slot 2 --auth isolated
statusshows it.
Headless default. Developer-bundle dump/seed no longer needs the login
keychain, so --auth shared works from a Background session. The pool still
defaults a non-Aqua acquire to isolated unless --auth is passed. An
explicit --auth always wins. GUI acquires keep the shared default.
cd desktop/macos
./scripts/omi-e2e-pool acquire # first free slot; prints its number
eval "$(./scripts/omi-e2e-pool env)" # OMI_APP_NAME, ports, identity, auth mode
./run.sh --yolo --fast-only --no-wait # builds into the leased slot
./scripts/omi-e2e-pool check # fail closed on a missing grant OR a signed-out slot
./scripts/omi-ctl wait-ready && ./scripts/omi-ctl navigate rewind
…
./scripts/omi-e2e-pool release # when the lane is doneOr in one step: ./scripts/omi-e2e-pool run -- ./run.sh --yolo — the wrapper
injects --fast-only when no lane was chosen.
acquire also writes <worktree>/.dev/e2e-pool.env, so any later shell in the
same worktree finds its slot with env and never needs to re-acquire. Every
env, verify, check, and run refreshes the lease's heartbeat.
The launch path enforces the headless rules that used to live only in this document:
--fast-onlyis the pool default.omi-e2e-pool runinjects it into a bare./run.shinvocation, andrun.shperforms the full rebuild on its own whenever the fast-bundle fingerprint says one is required (first build, changed inputs, incomplete runtime payload).- An explicit
--full/OMI_FORCE_FULL_BUNDLE=1on a leased slot is refused while the installed bundle is fast-reusable (exit 2). You never need it: rebuilds that are genuinely required are not blocked. A rewind reseed (OMI_FORCE_REWIND_SEED=1) still forces the full lane deliberately. - An empty auth dump never wipes anything. When seeding cannot run (the source developer-secrets file is missing or has no tokens), the slot keeps its existing session and the log says so — there is no "Launching cold" for pool slots.
- Never reset a pool slot's secret store.
omi-local-profile-keychain-reset.shrefusescom.omi.omi-e2e-*(any pool size and any configuredOMI_E2E_POOL_PREFIX) outright: pool slots are not local-emulator profiles, and their developer-secrets file is the persisted session a human signed in for. - A signed-out slot is a hard fail.
checkexits 2 — the same class as a missing TCC grant — and points at the one-time human fix. Health-only is not ready: the ready gate isomi-e2e-pool checkfor grants/sign-in, thenomi-ctl wait-readyfor the live signed-in owner-ready snapshot.
Each slot has fixed ports, so nothing needs to be threaded through by hand:
| Slot N | Bundle | Bridge port | Desktop backend | Python backend |
|---|---|---|---|---|
| N | com.omi.omi-e2e-N |
47700 + N | 10100 + N | 8300 + N |
All three bases sit outside the per-worktree ranges scripts/dev-instance.sh
derives — the bridge and desktop-backend bases below them, the Python base
above the bounded 8080 + offset range (max 8279) — so a pool slot never
collides with an auto-isolated worktree.
./scripts/omi-e2e-pool slots prints the table for the configured size.
A lease belongs to a worktree. run.sh calls omi-e2e-pool verify <slug>
before it touches /Applications, and lets the build proceed only when the
calling worktree holds the slot (or carries the slot's OMI_E2E_POOL_TOKEN).
Anyone else is refused with the holder's name and worktree — in seconds, not
after a flow times out 30 s later inside a route that reads like a product bug.
A lease is defunct, and the next acquire reclaims it loudly, when:
- the holder's worktree directory no longer exists (
omi-lane finish,git worktree remove, a deleted checkout); - the holder's
.dev/e2e-pool.envis gone while the worktree remains — a lane that deleted its pool state has given the slot up; - a holder pid recorded with
--pidhas died (pass the pid of the agent run or harness that owns the lane, when there is one); - as a backstop only, no pool command has touched it for
OMI_E2E_POOL_STALEseconds (default 6 h).
A live holder is never evicted: acquire on a full pool lists the holders
and stops, and says how to grow the pool. reap frees defunct leases and
reports — but does not kill — a slot app left running by a vanished lane; the
next launch replaces it.
acquire, release, and reap decide from the lease files and then write
them, so those sequences run under one pool lock: two lanes acquiring at the
same moment are serialized, and each ends up holding a distinct slot.
A holder that comes back after the backstop simply refreshes its own lease; the backstop reclaims slots from lanes that vanished, it does not lock a live lane out of its own slot.
status shows every slot: installed or not, app running, bridge port bound,
holder, heartbeat age, auth mode.
- Shared hardware. Slots can coexist, but there is one microphone and one ScreenCaptureKit on the machine. A test asserting on captured audio or frames needs a separate capacity-1 capture lease; two lanes feeding audio at once produce misdirected failures, not contention errors.
- The GUI session. Launching a GUI app from a background agent shell works only while a user is logged in at the console with WindowServer running. That is a host precondition, not something a slot can supply.
- Onboarding and permission flows themselves. A pool slot is already past
onboarding and already granted. To test those flows, use a throwaway named
bundle as
AGENTS.mddescribes.
tests/test-omi-e2e-pool.sh is the hermetic contract (no app, no TCC, no
/Applications) and runs in the launcher-test discovery loop on every CI run.