nandi/oripublic Fork 0
7895c1d1c9bb1048807dc04f7246dc456c47e025
Commits
Clone
git clone https://git.rickub.com/nandi/ori.git
git clone ssh://git@rickub.com/nandi/ori.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

forked from bots-garden/ori

architecture.md · 58 lines · 8.6 KBmarkdown Blame HistoryRaw
✨ Workspace panel, selectors, previews, desktop app, sandbox template, resizable file tree, light/dark theme 76d62ac k33g yesterday1# Architecture — explanation
2
3## What is this about?
4
5Ori brings the experience of an editor's agent panel — Zed's, specifically — to the browser. The interesting constraint is that the [Agent Client Protocol](https://agentclientprotocol.com) was designed for editors: a client spawns the agent as a subprocess and speaks JSON-RPC over its stdio. A browser cannot spawn processes, so something must stand in the middle. That something is Ori's Go backend.
6
7```
8browser ⇆ WebSocket ⇆ Go backend ⇆ stdio (ACP / JSON-RPC) ⇆ agent (claude-code-acp, …)
9```
10
11The package-dependency diagram is kept at [`docs/diagrams/packages.drawio`](../../diagrams/packages.drawio) (open it with diagrams.net or the VS Code Draw.io extension).
12
13## The pieces
14
15- **`ui/` (React SPA)** — renders the conversation: streamed markdown, collapsible thoughts, tool call cards with statuses and diffs, the agent's plan, permission prompts. All its state derives from server events folded by a pure reducer, which is what makes the UI testable without a browser or network.
16- **`internal/httpserver`** — serves the SPA (embedded in the binary via `go:embed`, so Ori ships as a single file) and mounts extra routes such as `/ws`.
17- **`internal/bridge`** — the heart of the backend. It translates between two asynchronous worlds: the agent streams updates and blocks on permission requests, while browsers connect, disconnect and answer at human speed. It broadcasts events, replays history to late joiners, and routes each permission decision back to the exact JSON-RPC call waiting for it.
18- **`internal/agent`** — spawns the agent process and drives the ACP session lifecycle (initialize, session/new, prompt, cancel) through the [`coder/acp-go-sdk`](https://github.com/coder/acp-go-sdk) client. It is agent-agnostic: anything speaking ACP on stdio plugs in.
19- **`internal/mockagent` + `cmd/ori-mock-agent`** — a deterministic ACP agent used by the end-to-end test and by `make run-mock`, so the whole application can be exercised without any AI agent, account or network.
20- **`internal/skills`** — discovers Claude Code skills (`SKILL.md` frontmatter) in the project and the user's home for the composer's `/` selector.
21- **`internal/files`** — the workspace file API (list, search, read, raw bytes, write) behind the SPA's file tree, preview and editor. Access is deliberately unrestricted: ori targets already-isolated sandboxes, and the agent has the same reach anyway.
22- **`internal/terminal`** — one real shell per WebSocket connection, inside a pseudo-terminal (`creack/pty`), driven by xterm.js in the browser.
23
24## Mentions, skills and the ACP prompt
25
26The composer's `@` selector had to answer one design question: how does a file reach the agent? ACP already has the answer — every agent must accept `resource_link` content blocks in a prompt — so the browser sends the text plus a small `attachments` list, and the bridge converts each into a `resource_link` (`file://` URI) next to the text block. The `@path` mention stays visible in the text, so the conversation reads naturally in every client and in replays, while the structured link lets the agent open the file without guessing. The `/` selector needed no protocol at all: Claude Code invokes a skill or slash command from the text `/name …`, so the popup only inserts text, merging the skills found on disk with the commands the agent announces through `available_commands_update`.
27
28## Previews: images and draw.io
29
30Images are streamed by `GET /api/raw` and rendered by the browser itself, which is both the simplest and the most capable renderer available. draw.io diagrams are the one preview that leaves the "bundled, no CDN" rule Monaco set: there is no offline renderer ori could ship — draw.io's own `viewer.min.js` is not published on npm, the archived `mxgraph` package lacks draw.io's shape library, and the one browser converter on npm (`@markdown-viewer/drawio2svg`) is GPL-3.0-only. So the pane embeds `embed.diagrams.net` in an iframe and hands it the XML over its postMessage protocol — the file never leaves the browser — and falls back to a notice plus the XML source when the host is unreachable, as it may be in an isolated sandbox. Vendoring `viewer-static.min.js` from the Apache-2.0 draw.io repository (about 3 MB) remains the path to an offline renderer if that trade-off ever changes.
31
32## The workspace panel
33
34The SPA grew from a single chat column into chat-plus-workspace: a file tree and three tabs (Preview, Editor, Terminal). Three choices shape it. Monaco serves both the read-only code preview and the editor — one heavy dependency instead of two highlighting stacks — and is bundled with its workers so nothing loads from a CDN; it is lazy-loaded, keeping the initial bundle small. Markdown renders through the same component as chat messages, and AsciiDoc through a lazily-imported Asciidoctor. Finally, inactive panes are hidden rather than unmounted, so the terminal's shell session and the editor's unsaved buffer survive tab switches and the panel being collapsed. The file tree column is resizable, and its width follows the same "the store owns the state" rule as the rest of the panel: it lives in the workspace store, reaches the stylesheet as a CSS custom property (`--filetree-width`) set on the panel, and is persisted in localStorage — a per-browser convenience, not server state, so it deliberately stays out of the WebSocket protocol.
35
36The colour theme is the same kind of state. Light is the default; the dark palette is keyed on a `data-theme="dark"` attribute that a small zustand store (`ui/src/theme.ts`) stamps on `<html>` and remembers in localStorage, and Monaco reads that store rather than the OS preference so the editor never disagrees with the page. The earlier behaviour — following `prefers-color-scheme` — was dropped in favour of an explicit choice, because a shared Ori server is often viewed from machines whose OS settings the user does not control (a sandbox's browser, a colleague's laptop). Like the file tree width, the theme is a per-browser convenience, so it lives outside the WebSocket protocol and the server never hears about it.
37
38## Why the server owns the conversation
39
40The bridge records every event of the session and replays it on each connection, and it even echoes the user's own prompts back as `user_message` events. This makes the server the single source of truth: the SPA never trusts its local memory, it resets on every `hello` and rebuilds from the replay. The payoff is that reconnections (laptop sleep, network blip, opening a second tab) are trivially correct — every browser converges on the same thread — at the cost of a bounded in-memory history (4096 events).
41
42## Why ACP payloads cross the WebSocket verbatim
43
44The bridge could have translated ACP updates into its own message shapes. It deliberately does not: `session_update` and `permission_request` carry the raw ACP objects. The protocol is well documented and evolving (plans, modes, richer tool content); relaying it verbatim means new update kinds reach the frontend without touching Go code, and the frontend can adopt them at its own pace — unknown kinds are ignored by design.
45
46## Why an adapter process for Claude Code
47
48The Claude Code CLI has no native ACP mode (verified against v2.1.274: no such option or subcommand). Zed's own integration goes through [`@zed-industries/claude-code-acp`](https://www.npmjs.com/package/@zed-industries/claude-code-acp), which wraps the Claude Code SDK and exposes ACP on stdio. Ori spawns exactly that adapter by default.
49
50## Rejected alternatives
51
52- **Driving the `claude` CLI's `stream-json` mode directly** — a hand-written bridge to Claude's proprietary streaming format would have been more work, tied Ori to one agent, and thrown away the ecosystem of ACP agents (Gemini CLI, and whatever comes next).
53- **Server-sent events or polling instead of a WebSocket** — permission requests need messages in both directions on one connection; SSE would have required a second channel for responses.
54- **Multiple concurrent sessions in v1** — the bridge deliberately drives one agent session shared by all connected browsers. The `Prompter` interface and the per-connection subscription model are the seams where multi-session support can be added without reshaping the design.
55
56## How the tests mirror the architecture
57
58Each seam has its own double: the agent package is tested against an in-memory ACP agent (no subprocess), the bridge against a fake session (no agent), the SPA reducer against plain message objects (no socket), and one end-to-end test compiles the real mock agent binary and drives the full production wiring through a real WebSocket. See [how to run the tests](../how-to/run-the-tests.md).