Skip to content

Latest commit

 

History

History
306 lines (260 loc) · 16.6 KB

DESIGN.md

File metadata and controls

306 lines (260 loc) · 16.6 KB

ompweb — Product & Architecture Contract

Purpose

ompweb is a local, browser-based workspace for the oh-my-pi (omp) coding agent. It lets a user browse the same local sessions they use in the terminal, continue live work, configure supported OMP settings, and inspect project files without creating a second agent runtime or a second source of truth.

The project originated from agegr/pi-web (MIT), but it is maintained as an OMP-focused downstream. We preserve the license and attribution, and selectively learn from upstream improvements; we do not assume that Pi-specific implementation changes can be merged unchanged.

Product principles

  1. OMP remains authoritative. Sessions, credentials, providers, and agent behavior belong to omp (run as ompweb's own build of it, runner/). ompweb must not invent a parallel data format or credential store.
  2. Local-first by default. The server binds to 127.0.0.1; remote access is an explicit user choice and must be protected by a trusted network boundary and HTTPS.
  3. Node-first installation. A normal user installs Node.js 24+ and OMP, then runs npx ompweb@latest or installs ompweb globally. The npm package brings its own Bun for the web server, so users do not install Bun themselves.
  4. Native compatibility over imitation. Prefer OMP's CLI and documented on-disk formats to copied SDK internals. If a capability cannot be done safely through those boundaries, leave it out rather than emulating it speculatively.
  5. A calm, capable workspace. The UI should make active work, session history, configuration, and project context understandable without hiding the agent's state or expanding the app into a general remote-control plane.

Distribution and identity

  • npm package and CLI command: ompweb.
  • Default server address: http://127.0.0.1:30177.
  • Existing OMP_WEB_* environment variables remain the configuration prefix for compatibility: OMP_WEB_HOSTNAME, OMP_WEB_NO_OPEN, and OMP_WEB_OMP_BIN. OMP_WEB_PASSWORD is accepted only as a migration step (see the security contract).
  • PI_CODING_AGENT_DIR and OMP's own directory conventions are respected because they identify the user's existing OMP state.
  • The web UI displays its own package version separately from the detected installed OMP version; those versions may legitimately differ.

Runtime architecture

Browser
  │ HTTP / Server-Sent Events
  ▼
ompweb (Bun server, started by the Node launcher)
  ├─ reads native OMP session files and selected configuration
  ├─ serves allow-listed project files
  └─ starts one `ompweb-omp --headless` child per active session
       │ ompweb channel, NDJSON over stdio
       ▼
     ompweb's own omp build (runner/) and the existing ~/.omp/agent state

The omp boundary

OMP SDK packages are Bun-only TypeScript and import Bun APIs. Importing @oh-my-pi/* or @earendil-works/* into the ompweb server, even though it runs on Bun, would tie it to omp's internals and make the application unreliable. Therefore, the ompweb server and daemon must not add those runtime dependencies.

Live work goes through ompweb's own omp build, runner/, compiled against the fork's integration build: an ompweb-dev/oh-my-pi release that is an upstream omp release plus the PRs the fork carries (among them runSessionHost, which the headless mode needs), pinned to one release in runner/omp-build.json and re-pinned to the newest one by each ompweb release. The runner replaces omp's launch command: its session factory connects every session to ompweb host over ompweb's channel. The host receives the same AgentSession events and calls the same session methods omp's TUI does, so nothing depends on omp's RPC mode. A session started from the runner's TUI connects to the daemon's Unix socket and shows up live in the web; dialogs open on both sides and the first answer wins. Each ompweb release attaches its runners (ompweb-omp-<platform>), and every install downloads the one of its own version (lib/omp/runner-fetch.ts), so omp updates with ompweb. The design and the wire are in docs/specs/2026-11-12-omp-runner-design.md; the fork build in docs/specs/2026-11-12-omp-build-in-ompweb-design.md.

The Host seam

Every machine-local action of a session goes through one interface, Host (lib/hosts/host.ts): spawning ompweb-omp --headless, checking that a session file exists, invalidating cached session lists, and the file-reading operations behind the session and project routes. The web server has no direct path to any machine, its own included: it reaches every host through the link described below. The implementation on the web server is RemoteHost; the daemon (ompweb host) runs the other side, LocalHost, on the machine whose omp it owns.

Browser ─▶ ompweb ◀── link (loopback)  ── ompweb host ── spawn ompweb-omp --headless (stdio)
                 ◀── link (WebSocket) ── ompweb host on another machine
                                           └─ spawn ompweb-omp --headless

"This machine" is the host with the fixed id local, linked to the daemon that the service installers set up next to the web service. Because the daemon owns the omp processes, restarting or redeploying the web server does not end a run: the restarted server adopts the live children from the daemon's hello, with their open blocking requests replayed. A direct spawn path would tie every run to the web server's lifetime, which is why none exists.

The omp boundary above holds on every machine: a remote machine runs its own runner build, its own ~/.omp/agent and its own credentials, unless sharing is turned on for that host (Settings → Hosts): then its sessions use the server's provider logins through omp auth-broker serve, and the host never gets a refresh token. ompweb only routes. RpcProcess takes its byte pipe (RpcTransport) from the host, so the session wrapper, event streams, notifications and queue handle frames without knowing where omp runs. Session routes address a session by id; the owning host resolves the file path, so a remote path never reaches the serving machine's filesystem.

The link is one WebSocket that the ompweb host daemon opens to the server: text frames carry the messages in both directions and binary frames carry large op results (daemon → server). The daemon forwards omp's NDJSON unchanged and answers a fixed allow-list of host operations; there is no generic exec or file-read operation. A host is trusted like the serving machine: control of the server or of a host token is a shell on that host. Tokens are stored hashed, shown once, and revocable, and the daemon requires https:// except for loopback or an explicit --allow-http. The full protocol is in docs/specs/2026-10-28-remote-hosts-design.md.

Data and mutation boundaries

OMP-owned state

  • ~/.omp/agent is the source of truth for sessions, configuration, models, skills, plugins, and blobs.
  • agent.db contains authentication data. ompweb never reads or writes it; authentication actions go through the ompweb-omp process.
  • A live OMP process owns writes to its session file. ompweb routes supported live actions through the runner's channel and never races a live file rewrite.

Direct file access

Session browsing is implemented in pure Node against OMP JSONL files. The reader tolerates the fixed title slot and older session shapes, resolves blob references when needed, and builds the active branch context from the entry tree. The session and project routes run it in the daemon of the session's host (session-ops.ts), so the same code serves this machine and every linked one.

Direct session mutation is deliberately narrow and explicit: rename/title, archive, deletion, and required branch-parent maintenance. These writes are atomic where possible; archive or deletion stops the associated live process first. ompweb does not provide a general editor for session JSONL or opaque OMP state.

Models and allow-listed OMP settings use surgical YAML updates that preserve unrelated content. Plugin operations run the installed omp plugin CLI. MCP configuration is project-local, validated before writing, and saved atomically.

Reconnect and foreground catch-up

Completed conversation history is identified by persisted session entry IDs. Live message_end frames have no durable ID and can arrive before OMP writes the entry, so they trigger catch-up rather than append an unidentified copy. Streaming text, active tool output, and an optimistic user prompt remain separate from confirmed history.

GET /api/sessions/:id/context?sync=1 returns a SessionSyncResponse:

  • cursor contains firstEntryId and lastEntryId (both null for empty history). Send its JSON value in the next request's cursor query parameter.
  • mode: "append" returns only messages after that position; baseEntryId identifies the expected client prefix. An orphaned cursor or changed compaction prefix returns mode: "replace".
  • context.messages and context.entryIds remain aligned. limit is an integer from 1 to 200 (default 200); follow hasMore with the returned cursor. Only the selected page's image blobs are resolved.
  • Existing leafId, includePreCompaction, deferThinking, and deferMedia view parameters remain available. A historical view excludes live output.
  • live contains the web-owned process's current partial message, active tool snapshots, and lifecycle flags, or null for a file-only session. Reading history never starts an OMP process. A live process can supply its first partial before its session file exists, but a missing file cannot erase a nonempty confirmed cursor.
  • live.responseObserved retains positive visible-answer evidence for the current run after completion, even before its entry is readable on disk. New runs and process/session changes reset it; tool calls alone do not count.

GET /api/sessions/:id/context?boundary=1 returns only { entryIds } for the current active context. Prompt and interrupt dispatch use this fresh ID-only boundary instead of downloading the transcript. It shares the history index without hydrating message bodies or blobs and cannot be combined with sync, pagination, or historical-view parameters.

SSE events carry web: { streamId, sequence }. The stream epoch changes when the native process or session identity changes. These values order live snapshots; they are not a persisted replay journal or Last-Event-ID support. Subscription precedes the connected cursor announcement. The client keeps processing live events while history loads and applies snapshot fields only when they cannot overwrite newer message, tool, or lifecycle state.

Open/reopen, foreground/online, message completion, and persisted-file notifications share one coalesced catch-up loop. Pages are accumulated privately and published only when the selected history is complete. Failed reads retain the last complete cursor and displayed history. Overlapping full loads preserve newer complete history rather than treating an intermediate page as authoritative. Full initial loads and terminal metadata refreshes still update the branch tree. A completion or persistence notification during an in-flight read schedules one follow-up read from the newly returned cursor. Raw SSE completions never append a second copy of an entry that the history response already includes.

Unchanged history pages reuse a file-versioned offset index and seek only the requested message bodies. The in-memory index cache is bounded to 32 views and 32 MiB of charged metadata; it does not retain transcript bodies or increase the existing 256 MiB raw-file cache budget or 1 GiB load ceiling. Cold indexes and changed files require a full metadata scan. Growth alone is not proof of an append: the same inode can be rewritten and then extended, so any file version change invalidates offsets rather than trusting an unchecked prefix. Failed reads never advance the confirmed cursor.

File-only catch-up also refreshes model and thinking metadata without replacing newer or pending channel choices. Replacement observer connections restore browser registrations and the subagent roster only after successfully connecting.

Deploy the frontend and API support together; no native OMP upgrade is required.

Security contract

  • Bind loopback-only by default. A non-loopback hostname is an explicit opt-in.
  • OMP_WEB_PASSWORD_HASH protects every route with a password-only sign-in screen. Only a scrypt hash reaches the server (ompweb hash-password), because every omp session inherits the server's environment and an agent could print the password into its transcript, session file and model-provider traffic. --password, or a plaintext OMP_WEB_PASSWORD next to a hash, refuses to start. A lone OMP_WEB_PASSWORD is hashed by the launcher, removed from the server's environment, and logged as a warning with the hash to use instead; the server itself still refuses a plaintext. Successful sign-in creates an HTTP-only, signed cookie with a 30-day expiry; the signing key is a per-installation random value mixed with the hash, so a copied password hash cannot forge a session and changing the password still invalidates existing sessions. Exposed deployments require HTTPS through a trusted reverse proxy or VPN. With OMP_WEB_TRUSTED_HEADER_SHA256 set, a request whose X-Omp-Web-Auth header hashes to that digest counts as signed in; omp-web never holds the secret itself. Deciding which clients get the header is the reverse proxy's job, because omp-web has no client address it can trust.
  • omp-web's own secrets (OMP_WEB_PASSWORD, OMP_WEB_PASSWORD_HASH, OMP_WEB_TRUSTED_HEADER_SHA256 and its legacy name OMP_WEB_TRUSTED_HEADER_SECRET) are stripped from every child process environment through createChildProcessEnvironment (lib/project-command-env.ts). OMP_WEB_* values that are configuration rather than secrets (OMP_WEB_PACKAGE_DIR, OMP_WEB_OMP_BIN, ...) are kept.
  • API requests are origin-checked. Do not add browser-to-host execution paths that bypass this boundary.
  • Host tools (host_tool_call) come from a fixed set: the browser's own tools (open a URL or file, notify) and the server's session tools in lib/agent-tools.ts; none runs a host command. A browser request must not become arbitrary host command execution through an extension callback.
  • File APIs are not a general filesystem browser. They are restricted to selected workspaces, valid Git worktrees, session-referenced directories, and explicitly selected roots. Paths are canonicalized to reject traversal and symlink escapes.
  • Secrets, raw API keys, and auth database contents never appear in API responses, logs, or the browser.

UX contract

  • The session sidebar is the durable navigation model: projects, sessions, branches, worktrees, and files must agree about the selected workspace.
  • Streaming state is explicit. The UI reconciles Server-Sent Events with the session's state so a background tab cannot remain falsely “running”.
  • Desktop and mobile share the same core workflow. Mobile controls keep usable touch targets and a visible loading state rather than a blank shell.
  • Accessibility and motion preferences are first-class. Components use the shared design tokens and UI primitives rather than one-off colors or controls.
  • Expensive rendering is deferred until needed; responsiveness and initial bundle size are part of the product contract.

Upstream and release strategy

agegr/pi-web is the historical source and a useful source of UI ideas, bug fixes, and tests. Before adopting an upstream change, verify that it does not depend on Pi runtime behavior or Bun-only APIs. Port the user-visible behavior, not blindly the implementation.

Releases are independent:

  1. Run typecheck, lint, relevant tests, and a production build.
  2. Confirm npm pack --dry-run contains the built dist/ output and exposes the ompweb binary.
  3. Publish ompweb@<version> only from an npm account authorized for that package.
  4. Tag and release the repository that owns this downstream project.

Non-goals

  • Reimplementing OMP, its provider registry, or its credential database.
  • Embedding Bun-only OMP SDK packages in the omp-web server.
  • Turning a local agent workspace into an internet-facing multi-user service.
  • Unrestricted filesystem browsing or arbitrary browser-triggered host tools.
  • Automatic bulk synchronization from agegr/pi-web.

When a proposed feature conflicts with one of these boundaries, preserve the boundary unless the design is intentionally revised first.