A polished desktop shell around omp + the LucidAgentIDE security harness:
a gated agent chat, plus live security / memory-&-context dashboards — same
renderer as the browser build, with real omp acp wired in.
Browser build — real chat, no Electron (screenshot-able):
bun run desktop:web # from repo root → http://localhost:5319The dev server drives a real omp acp session (with the gate loaded), so
prompts produce genuine model replies in a plain browser — same backend the
desktop app uses. Dashboards are the live read-only /api/security|memory.
The full desktop app:
cd desktop
bun install # one-time: pulls Electron
bun run start # bundles main/preload, launches the windowThe desktop app spawns two children:
bun desktop/dev.ts— serves the renderer + read-only/apidashboards.omp acp -e harness/omp/security_extension.ts— the agent loop with the security gate loaded in-process (invariant #4 holds on the GUI path too).
It reuses your existing ~/.omp credentials (OAuth or API key) — no re-login.
One real backend serves both the browser build and the desktop app; Electron is a thin native shell on top.
desktop/
dev.ts Bun server: bundles renderer/app.ts, serves the read-only /api
dashboards AND the real chat/config backend
acp_backend.ts singleton omp-ACP session (chat, config, commands) — gate loaded
acp.ts minimal Agent Client Protocol client (JSON-RPC over stdio)
main.ts Electron main: bootstrap runtimes, spawn dev.ts, open the window
preload.ts window.lucid = native shell only (crisp zoom + window controls)
runtime.ts runtime resolution + first-run bootstrap (bundled bun/uv → omp + scanner)
splash.ts first-run setup window (shown only when bootstrap has work to do)
updater.ts in-app auto-update via electron-updater (GitHub Releases)
build/ icon.svg (brand mark) + make-icons.ts + mac entitlements
renderer/ the UI (vanilla TS, no framework) — identical in browser & Electron
app.ts · styles.css · dom.ts · ui.ts · icons.ts · bridge.ts · format.ts
renderer/bridge.ts talks to the dev server over HTTP for everything —
/api/security|memory (dashboards), /api/chat (streaming NDJSON), /api/config,
/api/commands — so chat + config are real in the browser too. The only
native-only bits are crisp text zoom (webFrame) and window controls, from
window.lucid; in a plain browser those fall back to CSS zoom.
- Functional chat over real omp ACP (
session/new→session/prompt, streamingagent_message_chunk), with the gate loaded so blocked tool calls surface as a fly-in toast. - Model · Mode · Thinking picker — click the titlebar model badge. The list,
current values, and switching all use omp's live
configOptions(session/set_config_option); no need to drop into omp. - Text zoom — titlebar − / 100% / +, plus Ctrl ± / Ctrl 0 (Electron
webFrame.setZoomFactor; CSSzoomin the browser build). Persisted. - omp commands — the 39 ACP slash commands (
/context,/usage,/tools,/compact,/memory, …) appear in the ⌘K palette; selecting one drops it in the composer to run. - Live telemetry — the status bar's context gauge + cost update from the
session's
usage_updatestream. /goalagentic loop — run an agent to a verifiable stop condition (a shell command that exits 0, or a separate checker model's judgement) with a cheaper recommended checker, durable on-disk memory + resume, and scheduled automations. Built for unattended runs: a budget kill switch halts the loop at a hard$cap, convergence-stall + tool-failure guards stop it spinning on the same blocker, each run ends with an After-Action Report (Mermaid graphs of tool calls, LOC ±, errors, and sites visited, written to.omp/loops/*.report.md), and a cross-run ledger (.omp/loops/run-log.jsonl) tracks success rate and failure breakdown across runs. Every action is still scanned by the fail-closed gate.
Optional: route turns through the AskSage accredited government AI gateway
(api.civ.asksage.ai) instead of (or alongside) direct providers.
- Enable: Settings → Providers → AskSage · Gov gateway → paste your
ASKSAGE_API_KEY(stored locally, git-ignored, never committed). Gov models (GPT-5.2 · AskSage Gov,Claude Opus 4 · AskSage Gov, …) then appear in the model picker automatically. - How:
harness/omp/asksage_extension.tsregisters two omp providers viapi.registerProvider(OpenAI + Anthropic routes), loaded with a second-ealongside the security gate — no fork, gate still fail-closed on every turn. - Monthly usage shows as a "Gov" chip in the status bar (refresh + 5-min poll).
- Personas (composer dropdown) are scanned by the same Unicode scanner as tool calls before use; a poisoned persona is blocked, a clean one is delimited.
- Lockdown: Settings → "AskSage-only" routes every turn through the gateway and hides direct providers.
- Verified: the renderer (screenshotted: chat stream, toast, palette, config
picker, zoom), the dashboards on live data, and — captured from a live omp
16.0.8 ACP turn — the exact wire format now used by
main.ts/acp.ts:session/newconfig options,agent_message_chunk/usage_update/available_commands_update/config_option_update, andsession/set_config_option {sessionId, configId, value}. - Confirm in the running window: tool-call event shapes (
tool_call/tool_call_update) weren't exercised (the probe turn used no tools); the gate's stderr[BLOCKED …]line is the reliable block signal regardless.
See ../DECISIONS.md ADR-0006 for why the gate stays
in-process and the GUI is a front end only.
The app is packaged with electron-builder (config in package.json → build,
mac entitlements in build/entitlements.mac.plist).
| Platform | Targets | Arch |
|---|---|---|
| macOS | .zip app bundle |
arm64, x64 |
| Windows | NSIS …-Setup.exe + …-portable.exe |
x64 |
| Linux | .AppImage |
x64 |
Why mac ships
.zip, not.dmg: electron-builder's default DMG layout mounts the image read-write and drives a Finder/AppleScript background pass, which is fragile on headless GitHub Apple-Silicon runners (hdiutil attach failed - no mountable file systems→ missingbackground.tiff). The.zipbundle is a complete, auto-updatable distribution; users unzip and dragLucidAgentIDE.appto Applications. A signed/notarized.dmgcan be reintroduced later once it can be iterated on a real Mac.
The brand icon's single source of truth is build/icon.svg — a
glowing π core inside a magenta→cyan "agent orbit." bun run icons
(build/make-icons.ts, run automatically by every dist:* script) rasterizes it
to build/icon.png (1024², → macOS .icns) and build/icon.ico (16–256 px, →
the Windows .exe, installer, and taskbar). The generated PNG/ICO/ICNS are
git-ignored — only the SVG is committed.
.github/workflows/build-desktop.yml
builds both installers on native runners (macOS for the .zip app bundle,
Windows for the .exe) — the only way to produce a signed mac build without a Mac.
- Manual: GitHub → Actions → "Build desktop installers" → Run workflow. Installers download as run artifacts.
- On a release: push a tag and the installers attach to that tag's Release:
git tag v0.1.0 && git push origin v0.1.0
Builds are unsigned — no Apple/Windows certificates required to run the workflow.
cd desktop
bun install # pulls electron + electron-builder
bun run dist:mac # → release/LucidAgentIDE-mac-{arm64,x64}.zip (on macOS)
bun run dist:win # → release/LucidAgentIDE-<ver>-Setup.exe (+ portable.exe) (on Windows)A mac build must be produced on macOS — electron-builder can't package/sign a mac app from Windows or Linux. Use the workflow above, or a Mac.
Building the Windows installer locally needs Developer Mode (or an elevated shell): electron-builder extracts a signing toolchain that contains symlinks, which Windows blocks for unprivileged processes (
A required privilege is not held by the client). The packaged app itself (release/win-unpacked/) builds fine without it — only the finalSetup.exestep needs the privilege. The CIwindows-latestrunner has it, so the workflow is the friction-free path.
The installer is designed so the user installs nothing beforehand:
-
Bundled into the app (
Resources/runtimes): the staticbunanduvbinaries, named<tool>-<platform>-<arch>to matchruntime.ts. Fetched bybun run runtimes:mac(build/fetch-runtimes.ts) — whichdist:macruns automatically — and per-OS by the CI workflow. Supply-chain hardened: versions are pinned (bun 1.3.14 / uv 0.11.23, neverlatest) and every archive is SHA-256-verified against a committed hash before it's bundled — a mismatch or missing hash fails the build closed. The committed hashes were cross-checked against the vendors' own checksums (bunSHASUMS256.txt, uv<asset>.sha256). Bump a version, thenREFRESH=1 bun run build/fetch-runtimes.tsto recompute the hashes (re-cross-check before committing). -
Bundled into
Resources/repo(extraResources): the renderer + the LucidAgentIDE repo it runs (harness, tools, scanner-sidecar, desktop sources,node_modules). The main process resolves paths from there when packaged. (Verified on Windows:win-unpacked/resources/repo/contains harness + scanner-sidecar + the in-process security gate.) -
Provisioned on first launch (
runtime.ts→splash.ts) using the bundled bun/uv, into the app's userData:- omp —
bun add -g @oh-my-pi/pi-coding-agentinto a managed global dir. - the scanner interpreter —
uv venv … --python 3.12(the sidecar has zero pip deps, so any 3.11+ interpreter works; uv downloads one if needed).
A small setup splash appears only when there's work to do, then
LUCID_OMP_BIN/SCANNER_PYTHONare passed down to the dev server and its omp + scanner children. On a machine that already has bun/omp/uv (e.g. a dev box), nothing is installed and no splash appears — resolution falls back to the user's own tools at their absolute install paths (~/.bun,~/.local,~/.cargo, and Homebrew/opt/homebrew/bin·/usr/local/bin), then barePATH. The absolute paths are load-bearing: a Finder-launched app inherits a minimalPATH(/usr/bin:/bin), so a barebunwouldspawn … ENOENT. - omp —
If first-run setup fails (offline, etc.), the app still launches; the fail-closed gate blocks tool calls until the scanner is available — it never treats a missing scanner as "safe" (CLAUDE.md invariant #3).
Packaged builds check GitHub Releases on launch and prompt to restart when a
newer version is downloaded (updater.ts + the publish provider
in package.json). To ship an update: bump version, then git tag vX.Y.Z && git push origin vX.Y.Z — the workflow builds installers and uploads the update
feed (latest*.yml + .blockmap) to that Release.
The config builds unsigned by default; signing is opt-in via GitHub
secrets and the workflow signs + notarizes automatically when they're present.
Full secret list and setup: SIGNING.md.
- Unsigned macOS: Gatekeeper — first launch via right-click → Open. (macOS auto-update needs a signed build.)
- Unsigned Windows: SmartScreen — More info → Run anyway. Windows auto-updates fine while unsigned.
Status: the macOS
.zipapp bundle (arm64 + x64) builds on macOS viabun run dist:mac; the brand icon is embedded; the repo, in-process gate, and the staticbun/uvruntimes are bundled. Verified self-contained: the app's bundled dev server boots under a Finder-style minimalPATH(/usr/bin:/bin) and serves/api/health— i.e. nospawn bun ENOENT. The Windows packaging is verified up to the unpacked app (win-unpackedwith icon + repo); its finalSetup.exeis produced by the GitHub Actions workflow on a native Windows runner (run it and grab the artifacts).