A browser and desktop GUI for oh-my-pi, built as a separate project on top of omp's documented RPC protocol.
Addresses oh-my-pi#5742.
Under active development, and it needs testing. Everything below works against the author's own setup — one machine, one provider, one workspace at a time. Other providers, other platforms, and sustained multi-session use are unverified. Treat rough edges as expected and please report them.
The upstream issue has been open since 2026-07-16 and is blocked on a maintainer
scope decision, not on implementation. CONTRIBUTING.md requires prior Discord
discussion for "new subsystems, large UI changes, new dependencies, and changes
that span several packages" — a GUI is all four — and warns that discussion does
not guarantee a merge.
Building outside the tree turned out to cost very little, because the pieces a GUI needs are already published:
| Need | Already exists |
|---|---|
| Agent protocol | omp --mode rpc — ~40 commands, documented |
| Protocol client | RpcClient, shipped inside the npm package |
| Event shapes | @oh-my-pi/pi-wire, published to npm |
| Transcript + 30 tool renderers | packages/collab-web, MIT (vendored — see below) |
What did not exist, and is what this repository actually adds: a daemon that owns sessions rather than attaching to a TUI-hosted one, a WebSocket transport, concurrent multi-session support, and a session list.
browser (React SPA)
│ WebSocket ── @omp-gui/protocol
▼
omp-gui daemon (Bun)
│ one child process per session
▼
omp --mode rpc omp --mode rpc …
(session A) (session B)
packages/protocol— the daemon↔browser wire contract. Depends only on@oh-my-pi/pi-wire, so the browser never pulls in the coding agent.packages/daemon—Bun.serveHTTP + WebSocket. Wraps oneRpcClientper session, assigns sequence numbers to agent events, keeps a ring buffer for reconnect replay, and serves the built SPA.packages/web— React client. First-party shell (session list, header, composer) around vendored upstream renderers.
RPC's AgentSessionEvent is a structural superset of pi-wire's AgentEvent.
Shared variants carry at least the fields pi-wire declares, so the daemon
forwards agent events verbatim and upstream's renderers consume them with no
translation layer.
RPC is single-session per process. switch_session swaps which session a
process drives; it does not multiplex. Concurrent sessions therefore mean
concurrent processes — which also contains a crash to one session.
Tool approval is not its own protocol frame. The tool wrapper asks by
calling uiContext.select(prompt, ["Approve", "Deny"]), so approval reaches a
host as an ordinary extension_ui_request — indistinguishable from a dialog an
extension raised for itself. It also carries no timeout, so an unanswered one
stalls the turn forever. See Extension UI.
Live events drive the transcript during a turn so text streams in. They are not
treated as the source of truth: at agent_end the client re-attaches for a full
resync from the daemon. Optimistic rendering stays responsive without drifting
permanently out of step with the session on disk.
Everything runs in Docker. The one hard requirement is a glibc base image —
omp's pi-natives addon ships glibc-only builds and fails to load on Alpine
(linked against glibc (DT_NEEDED libm.so.6)).
# Build the web bundle and start the daemon.
docker compose run --rm tools run build
WORKSPACE=/path/to/your/project docker compose upThe daemon prints a URL with the access token in the fragment:
open: http://127.0.0.1:7890/#token=…
The client moves that token into sessionStorage and strips it from the address
bar, so it does not linger in history or a screenshot of the URL.
bun install
bun run build
bun run serveRequires Bun 1.4+ and a configured omp provider — the daemon spawns the real
omp CLI, which exits if no model is available.
bun run check # tsgo typecheck + biome
bun run smoke # boots the daemon and exercises the protocol end to end
bun run build # web bundle into packages/web/distbun run smoke covers auth rejection, the hello/list/attach/close flow, a real
session spawn, a full extension UI round trip, and slash command listing and
output. scripts/fixtures/ holds a probe extension that raises one dialog per
session, so the approval path is exercised without a model call. Session creation
needs a provider key:
ANTHROPIC_API_KEY=sk-ant-dummy bun run smokeInstall node_modules onto a volume, not a bind mount. Bun's default
hardlink backend cannot link across the bind-mount boundary and fails with
Fail extracting tarball. docker-compose.yml already does this; the
ompgui-nm volume in the dev setup is the same idea.
Bun 1.4 uses isolated installs: the store lives in node_modules/.bun and each
workspace gets symlinks. An empty-looking root node_modules is expected.
packages/web/src/vendor/ is copied from oh-my-pi's packages/collab-web —
the transcript, the markdown renderer, and ~30 per-tool React renderers. That
package is private: true and unpublished, so vendoring is the only way to
consume it. It is MIT-licensed; see NOTICE.
Refresh it with:
bun run vendor:collab-web -- --source /path/to/oh-my-piTwo consequences of keeping those copies byte-identical:
- Biome excludes
src/vendor/**. Formatting it would produce a diff against upstream on every re-vendor. - The project does not set
noUncheckedIndexedAccess, because upstream does not. Seepackages/web/src/vendor/README-STRICTNESS.md.
Type-checking uses tsgo (upstream's checker) rather than tsc. They differ on
one line in the vendored markdown renderer; matching upstream's checker is what
keeps vendored code clean.
Anything the agent needs a human for — tool approval, an extension's own
confirm/select/input/editor dialog — arrives over one RPC channel as an
extension_ui_request. The daemon forwards the four blocking methods to the
browser and answers them from the client's reply. notify and open_url become
notices; setStatus, setWidget, setTitle, and set_editor_text are terminal
chrome with no browser equivalent and are dropped.
Three things about this path are easy to get wrong:
- Dropping a blocking request wedges the session. Approval dialogs carry no
timeout, so nothing settles them but an answer. Listeners are therefore
attached before
RpcClient.start(): an extension can raise a dialog fromsession_start, and a request emitted during startup would otherwise be dropped and the session would come up already stuck. - A blocked agent emits no events, so pending requests ride on the
attachedsnapshot rather than the event replay. Reconnecting mid-dialog shows the dialog again instead of a session that looks hung for no reason. - A deadline is sent as time remaining, not as an instant. omp expires its own dialogs without telling the host, so the client has to expire them too, and neither side has to trust the other's clock.
Approval mode defaults to write (prompt before exec-tier tools) rather than
omp's own yolo, which is the right default only for an embedding that cannot
ask. OMP_GUI_APPROVAL_MODE overrides it.
RpcClient drops two classes of server frame that a host actually needs, so
patches/ reopens both. It is applied at install time via Bun's
patchedDependencies.
- Extension UI. The listener set is private and populated only inside
login(), so as shipped there is no way to observe or answer a request. AddsonExtensionUiRequestandrespondToExtensionUi. - Command output.
command_outputframes — how every ACP builtin (/model,/cost,/compact, …) reports — are not agent events, so they match no listener and are discarded. A slash command appears to do nothing at all. AddsonCommandOutput.
Both expose machinery that already exists, and both are deliberately the
smallest possible diff, because this is also the patch to send upstream. If it
lands, delete patches/ and the patchedDependencies entry in package.json.
The daemon executes agent tools — arbitrary file reads, edits, and shell commands. Accordingly:
- It binds
127.0.0.1by default and refuses to bind another interface unlessOMP_GUI_ALLOW_REMOTE=1is set. - Every HTTP and WebSocket request needs the bearer token, compared in constant time.
- Cross-origin WebSocket handshakes are rejected. The token alone is not enough:
any page can open a WebSocket to localhost, and
Originis what separates the GUI from a tab that guessed the port. - New sessions are confined to the configured workspace; a
cwdthat escapes it is refused. - The file tree and viewer are read-only, capped at 512 KB per file, and confined
the same way. Every path is resolved to its real location and re-checked
against the workspace root, because
path.resolvealone would follow a symlink anywhere on the disk.bun run smokecovers relative, absolute, and symlink-based escapes. - The diff viewer runs
gitwith argument arrays rather than a command string, confines every client-supplied path before it is passed, and passes it after a--separator so it can never be read as a revision or a flag.
Exposing this beyond loopback needs a real authenticating proxy. The upstream issue's "SSH-forward it and use it from a tablet" framing treats remote access as incidental; it is not.
Early. The pieces below are built and exercised by bun run smoke, but "built"
here means the path works end to end on one setup — not that it has been tested
broadly. Reports from other providers, platforms, and workloads are the most
useful contribution right now.
Working: session list, create/resume/close, streaming transcript with tool cards, prompt and steer, interrupt, model switching, reconnect with gap replay, approval prompts, image attachments, slash commands, a session settings panel, a workspace file tree with a read-only viewer, and a diff viewer over uncommitted changes.
Not built yet: the subagent panel and the Tauri desktop shell.
Known gaps in what is built:
- The approval prompt has been driven end to end by the smoke run's probe extension, but not yet against a real model call reaching a real approval gate.
- An extension that raises a dialog from
session_startand then awaits it cannot be answered at all. omp only begins reading stdin once startup has finished, so the dialog blocks the very loop that would collect its answer — a response written by any RPC host sits unread in the pipe. Session creation now fails with that explanation instead of a bare timeout, but the dialog itself is unanswerable until upstream reads stdin earlier. Raising one without awaiting is fine, and is what the smoke probe does. - Nothing has been tested with more than a handful of concurrent sessions.
- Slash command output is transient. It is not an agent event, so it is neither written to the session file nor replayed after a reconnect.
- The diff viewer shows the working tree against
HEAD, so staged and unstaged changes appear together. It cannot stage, unstage, or commit — it is for reading, like the file viewer. - The file tree has no watcher. It reflects the workspace as of the last listing, and a directory is re-read only when collapsed and expanded again, or on Refresh.
- The settings panel omits auto-retry.
RpcClientcan set it, butget_statenever reports it, so a switch for it could only ever display a guess.
| Variable | Default | Meaning |
|---|---|---|
OMP_GUI_HOST |
127.0.0.1 |
Bind address |
OMP_GUI_PORT |
7890 |
Bind port |
OMP_GUI_TOKEN |
generated | Pins the access token across restarts |
OMP_GUI_WORKSPACE |
cwd |
Root for new sessions |
OMP_GUI_CLI |
resolved | Path to omp's dist/cli.js |
OMP_GUI_REPLAY_BUFFER |
2000 |
Events retained per session for replay |
OMP_GUI_APPROVAL_MODE |
write |
always-ask, write, or yolo |
OMP_GUI_OMP_ARGS |
unset | Extra flags for every omp spawn |
OMP_GUI_ALLOW_REMOTE |
unset | Required to bind a non-loopback address |
MIT. See LICENSE and NOTICE.