forked from bots-garden/ori
| ✨ Workspace panel, selectors, previews, desktop app, sandbox template, resizable file tree, light/dark theme | 1 | = Ori — Source Code Analysis |
| 2 | :toc: left | |
| 3 | :toclevels: 3 | |
| 4 | :icons: font | |
| 5 | :source-highlighter: highlight.js | |
| 6 | :revdate: 2026-09-17 (updated 2026-09-17) | |
| 7 | ||
| 8 | == Overview | |
| 9 | ||
| 10 | Ori is a web client for https://agentclientprotocol.com[ACP (Agent Client Protocol)] code agents. | |
| 11 | A Go backend serves an embedded React SPA and connects over ACP (JSON-RPC on stdio) to an agent | |
| ✨ Switch the ACP adapter to @agentclientprotocol/claude-agent-acp (template 0.0.2) | 12 | subprocess — Claude Code via the Claude Agent SDK ACP adapter (`npx -y @agentclientprotocol/claude-agent-acp`) by default. |
| ✨ Workspace panel, selectors, previews, desktop app, sandbox template, resizable file tree, light/dark theme | 13 | The browser gets a Zed-style agent panel: streamed markdown answers, collapsible thoughts (live |
| 14 | during streaming, collapsible when complete), working spinner with rotating phrases, tool call | |
| 15 | cards (status, locations, diffs), the agent's plan, and permission prompts answered from the UI — | |
| 16 | plus a workspace panel with a VS Code-style file tree (Material icons, indent guides, chevrons), | |
| 17 | Monaco preview/editor with rendered Markdown and AsciiDoc, and an xterm.js terminal. | |
| 18 | ||
| 19 | [source] | |
| 20 | ---- | |
| 21 | browser ⇆ WebSocket /ws ⇆ Go backend ⇆ stdio (ACP) ⇆ agent subprocess | |
| 22 | ---- | |
| 23 | ||
| 24 | * Go module: `rickub.com/bots-garden/ori` | |
| 25 | * ~4,063 lines of source: Go ≈ 1,555 (21 files), TypeScript/TSX ≈ 2,508 (39 files) | |
| 26 | ||
| 27 | == Backend (Go) | |
| 28 | ||
| 29 | === Entry points | |
| 30 | ||
| 31 | [cols="1,3"] | |
| 32 | |=== | |
| 33 | | Package | Responsibility | |
| 34 | ||
| 35 | | `cmd/ori` | |
| 36 | | Server binary. Parses flags (`--addr`, default `:8888`; `--cwd`; `--agent-cmd`, | |
| 37 | whitespace-split), spawns the agent, wires the bridge and HTTP routes. | |
| 38 | ||
| 39 | | `cmd/ori-mock-agent` | |
| 40 | | Deterministic demo ACP agent on stdio (thought → plan → read tool call → permission → | |
| 41 | edit tool call with diff → answer). Used by `make run-mock` and the e2e test. | |
| 42 | |=== | |
| 43 | ||
| 44 | === Internal packages | |
| 45 | ||
| 46 | [cols="1,3"] | |
| 47 | |=== | |
| 48 | | Package | Responsibility | |
| 49 | ||
| 50 | | `internal/config` | |
| 51 | | CLI flag parsing and validation (`Config`, `FromArgs()`). | |
| 52 | ||
| 53 | | `internal/agent` | |
| 54 | | Spawns the agent subprocess and drives the ACP lifecycle via | |
| 55 | `github.com/coder/acp-go-sdk` v0.13.5. Exposes a generic `Handler` interface (session | |
| 56 | updates + blocking permission requests). Implements the `acp.Client` side: real | |
| 57 | filesystem read/write; terminal methods refused (capability not announced). | |
| 58 | ||
| 59 | | `internal/bridge` | |
| 60 | | Hub between the agent and the browsers (~550 LOC). Broadcast with per-client buffers | |
| 61 | (256 items; slow clients are dropped), append-only history capped at 4,096 events and | |
| 62 | replayed on every connection, `user_message` echo so the server is the single source of | |
| 63 | truth, permission routing by `requestId` (pending requests replayed to late joiners). | |
| 64 | Uses `github.com/coder/websocket` v1.8.15; origins restricted to localhost. A | |
| 65 | `Prompter` interface is the seam for future multi-session support. | |
| 66 | ||
| 67 | | `internal/httpserver` | |
| 68 | | Root HTTP mux: `/healthz`, embedded SPA with fallback routing, injection point for | |
| 69 | extra routes (`/ws`, `/ws/terminal`, `/api/*`). | |
| 70 | ||
| 71 | | `internal/files` | |
| 72 | | Workspace file API: list / read / write. Relative paths resolved against `--cwd`, | |
| 73 | absolute paths allowed — access is deliberately unrestricted (ori targets | |
| 74 | already-isolated sandboxes). Binary content → 415, files > 5 MiB → 413. | |
| 75 | ||
| 76 | | `internal/terminal` | |
| 77 | | One shell per WebSocket on `/ws/terminal`, in a PTY (`creack/pty` v1.1.24). Binary | |
| 78 | frames out; JSON `input` / `resize` frames in. Shell chosen from `$SHELL` → bash → sh. | |
| 79 | ||
| 80 | | `internal/mockagent` | |
| 81 | | The scripted demo agent implementation behind `cmd/ori-mock-agent`. | |
| 82 | ||
| 83 | | `ui/embed.go` | |
| 84 | | `go:embed all:dist` — the built SPA embedded into the Go binary. | |
| 85 | |=== | |
| 86 | ||
| 87 | == Frontend (`ui/`) | |
| 88 | ||
| 89 | Vite 7 + React 19 + TypeScript strict. State is held in zustand stores wrapped around pure | |
| 90 | reducers, keeping the logic testable without React or WebSocket coupling. | |
| 91 | ||
| 92 | === Core modules | |
| 93 | ||
| 94 | [cols="1,3"] | |
| 95 | |=== | |
| 96 | | File | Role | |
| 97 | ||
| 98 | | `src/App.tsx` | |
| 99 | | Root layout: topbar (connection status, workspace toggle), chat column (thread, plan, | |
| 100 | permission prompts, input), workspace panel. | |
| 101 | ||
| 102 | | `src/reducer.ts` | |
| 103 | | Pure state machine (handler-table style) folding server messages and connection | |
| 104 | events into the chat state. | |
| 105 | ||
| 106 | | `src/store.ts` | |
| 107 | | Zustand wrapper around the reducer (`useChatState()`, `dispatch()`). | |
| 108 | ||
| 109 | | `src/ws.ts` | |
| 110 | | WebSocket client with exponential-backoff reconnect; the thread resets on `hello` so | |
| 111 | history replays are idempotent. | |
| 112 | ||
| 113 | | `src/protocol.ts` | |
| 114 | | Wire contract mirroring `internal/bridge/protocol.go`; ACP payloads are typed but | |
| 115 | relayed verbatim, keeping the front forward-compatible (unknown update kinds ignored). | |
| 116 | ||
| 117 | | `src/workspace.ts` | |
| 118 | | Separate zustand store for the workspace panel (file tree, tabs, open files). | |
| 119 | ||
| 120 | | `src/api.ts` | |
| 121 | | Client for `/api/files` and `/api/file`. | |
| 122 | ||
| 123 | | `src/lang.ts` | |
| 124 | | File extension → language ID mapping for Monaco editor. | |
| 125 | ||
| 126 | | `src/monaco-setup.ts` | |
| 127 | | Monaco editor environment configuration (bundled workers, no CDN). | |
| 128 | |=== | |
| 129 | ||
| 130 | === Components | |
| 131 | ||
| 132 | * **Chat panel**: `ChatThread` (with `ThoughtEntry` for live/collapsed thoughts), `Markdown`, | |
| 133 | `InlineText` (backtick spans → `<code>` for tool titles), `ToolCallCard` (status badge, | |
| 134 | collapsible content, diffs, file locations), `PermissionPrompt`, `PlanView`, `PromptInput`, | |
| 135 | `WorkingIndicator` (spinner with rotating phrases). | |
| 136 | * **Workspace panel**: `Workspace` (tabs), `FileTree` (VS Code style with Material icons from | |
| 137 | `vscode-material-icons` — 910 SVGs copied to `public/material-icons/` by | |
| 138 | `scripts/copy-icons.mjs`; click = preview, double-click = edit, chevrons, indent guides, | |
| 139 | active highlight), `PreviewPane` (Monaco read-only for code, rendered Markdown via | |
| 140 | `react-markdown` + `remark-gfm`, rendered AsciiDoc via lazily-imported `@asciidoctor/core` | |
| 141 | v4 async API, Rendered/Source toggle), `EditorPane` (Monaco, dirty ● indicator, Save / | |
| 142 | Ctrl+S), `TerminalPane` (xterm.js on `/ws/terminal` with `@xterm/addon-fit`, dark theme, | |
| 143 | started on first activation), `MonacoViewer` (shared by preview and editor), `CodeView` | |
| 144 | (syntax-highlighted code block for diffs). | |
| 145 | * Inactive panes are hidden, not unmounted, so shell and editor state survive tab switches. | |
| 146 | * `execute` tool-call output renders in a monospace `<pre>`, never as markdown. | |
| 147 | * Monaco workers are bundled (no CDN); the editor loads as a lazy chunk (~1 MB gzip). | |
| 148 | ||
| 149 | == HTTP / WebSocket endpoints | |
| 150 | ||
| 151 | [cols="1,1,3"] | |
| 152 | |=== | |
| 153 | | Endpoint | Method | Purpose | |
| 154 | ||
| 155 | | `/healthz` | GET | Health check (`{"status":"ok"}`). | |
| 156 | | `/ws` | GET (upgrade) | Agent ⇆ browser relay: history replay then live events. | |
| 157 | | `/ws/terminal` | GET (upgrade) | Interactive PTY shell. | |
| 158 | | `/api/files` | GET | List a directory (`?path=`). | |
| 159 | | `/api/file` | GET | Read a file (max 5 MiB). | |
| 160 | | `/api/file` | PUT | Write a file. | |
| 161 | | `/` | GET | Embedded SPA (index.html fallback). | |
| 162 | |=== | |
| 163 | ||
| 164 | == WebSocket protocol (bridge) | |
| 165 | ||
| 166 | Browser → server: `prompt`, `cancel`, `permission_response` (by `requestId`). | |
| 167 | ||
| 168 | Server → browser: `hello` (session id, turn state — resets the client thread), | |
| 169 | `user_message` (echo), `session_update` (raw ACP `SessionUpdate`), `permission_request` | |
| 170 | (raw ACP `RequestPermissionRequest` + `requestId`), `permission_resolved`, | |
| 171 | `turn_started`, `turn_ended` (stop reason), `error`. | |
| 172 | ||
| 173 | Reference: `internal/bridge/protocol.go`, `ui/src/protocol.ts`, and | |
| 174 | `docs/en/reference/websocket-protocol.md`. | |
| 175 | ||
| 176 | == Tests | |
| 177 | ||
| 178 | * *Go*: 55 tests across 8 packages plus one end-to-end test (`cmd/ori/e2e_test.go`, | |
| 179 | full server + mock agent subprocess + real WebSocket; skipped with `-short`). | |
| 180 | Race-clean (`go test -race ./...`). | |
| 181 | * *Frontend*: 75 vitest tests across 13 test files — reducer (48), components | |
| 182 | (WorkingIndicator, InlineText, AsciiDocView with real converter, ToolCallCard, FileTree, | |
| 183 | EditorPane, PreviewPane, TerminalPane, App…), WebSocket client, workspace store, language | |
| 184 | detection. | |
| 185 | * Quality gate (qlty): PASS as of 2026-09-17 — 0 errors, 0 warnings, 0 smells (2 formatting | |
| 186 | notes). | |
| 187 | ||
| 188 | == Build and run | |
| 189 | ||
| 190 | [cols="1,3"] | |
| 191 | |=== | |
| 192 | | Target | Effect | |
| 193 | ||
| 194 | | `make deps` | `npm install` in `ui/`. | |
| 195 | | `make build` | SPA build then Go binaries — order matters: the Go build embeds `ui/dist`. | |
| 196 | | `make test` | `go test ./...` + `vitest run`. | |
| 197 | | `make run` | Run with the Claude Code ACP adapter. | |
| 198 | | `make run-mock` | Run with the bundled demo agent (no Claude, no network needed). | |
| 199 | | `make dev` | Vite dev server on :5173. | |
| 200 | |=== | |
| 201 | ||
| 202 | == Key design decisions | |
| 203 | ||
| 204 | * ACP payloads cross the WebSocket *verbatim*, keeping the frontend forward-compatible. | |
| 205 | * The server owns the thread (history + `user_message` echo); the SPA rebuilds from | |
| 206 | replay on every (re)connection. | |
| 207 | * One agent session shared by all browsers in v1; a second `prompt` during an active | |
| 208 | turn is rejected. Multi-session is a planned evolution behind the `bridge.Prompter` | |
| 209 | seam. | |
| 210 | * File API access is deliberately unrestricted: ori targets already-isolated sandboxes. | |
| ✨ Switch the ACP adapter to @agentclientprotocol/claude-agent-acp (template 0.0.2) | 211 | * Claude Code is reached through the Agent Client Protocol adapter (`@agentclientprotocol/claude-agent-acp`, |
| 212 | which bundles its own Claude Code CLI via the Claude Agent SDK; default since 2026-09-18, | |
| 213 | replacing Zed's `@zed-industries/claude-code-acp` whose embedded CLI 2.1.44 the API now | |
| 214 | rejects) — the `claude` CLI has no native ACP mode. | |
| ✨ Workspace panel, selectors, previews, desktop app, sandbox template, resizable file tree, light/dark theme | 215 | * Material icons are copied from `vscode-material-icons` package to `public/` via an npm |
| 216 | pre-hook script, making them static assets bundled with the SPA. | |
| 217 | * AsciiDoc rendering uses `@asciidoctor/core` v4's async `convert` named export (v3's | |
| 218 | factory function no longer exists). | |
| 219 | * Terminal always renders with a dark theme (`#1e1e1e`) regardless of app theme. | |
| 220 | ||
| 221 | == Known limitations | |
| 222 | ||
| 223 | * `--agent-cmd` is split on whitespace; arguments containing spaces need a wrapper script. | |
| 224 | * One prompt turn at a time per shared session. | |
| 225 | * ACP terminal methods are not supported (capability not announced; answered with JSON-RPC | |
| 226 | method-not-found). | |
| 227 | * Only text content blocks are rendered in messages; images/audio/resources are not yet | |
| 228 | supported. | |
| 229 | * Running ori inside a Claude Code sandbox requires `env -u CLAUDECODE ./bin/ori` to bypass | |
| 230 | the nested-session guard. | |
| 231 | ||
| 232 | == Sandbox deployment | |
| 233 | ||
| 234 | Ori ships as a Docker Sandbox template (`template/Dockerfile`, FROM | |
| 235 | `docker/sandbox-templates:claude-code`) paired with a kit (`kits/ori/`): | |
| 236 | ||
| ✨ Switch the ACP adapter to @agentclientprotocol/claude-agent-acp (template 0.0.2) | 237 | * Create: `sbx run -d claude <project> --template k33g/ori:0.0.2 --kit <ori-repo>/kits/ori --name ori -p 8888:8888/tcp` |
| ✨ Workspace panel, selectors, previews, desktop app, sandbox template, resizable file tree, light/dark theme | 238 | * The `--detached` (`-d`) flag is **required**: sandboxd auto-stops a sandbox 30 seconds |
| 239 | after its last CLI sentinel session closes; without `-d`, the container stops shortly after | |
| 240 | the prompt returns. Browser traffic on a published port does not hold the sandbox up. | |
| 241 | * The `detached` field is create-time only (no PATCH); an existing non-detached sandbox must | |
| 242 | be `sbx rm`'d and recreated with `sbx run -d`. | |
| 243 | * Build the template image: `make template` (builds binaries first, copies to the image). | |
| 244 | ||
| 245 | === Environment considerations (sandbox development) | |
| 246 | ||
| 247 | When developing ori inside a Claude Code sandbox: | |
| 248 | ||
| 249 | * `~/.qlty` and `~/.npm` are 488MB dedicated volumes. The qlty tool cache was moved to | |
| 250 | `~/.qlty-cache` (symlinked from `~/.qlty/cache`), and npm cache to `~/.npm-big` (via | |
| 251 | `NPM_CONFIG_CACHE` in `/etc/sandbox-persistent.sh`) to avoid "No space left" errors. | |
| 252 | * npm blocks install scripts by default; esbuild's postinstall was approved via | |
| 253 | `npm approve-scripts` (recorded in `ui/package.json` `allowScripts` field). | |
| 254 | * The `radarlint-python` qlty plugin was removed (its ~300MB JDK cannot fit the dedicated | |
| 255 | volume; diagnosis in `.qlty/qlty.toml` comments). | |
| 256 | ||
| 257 | == Documentation | |
| 258 | ||
| 259 | `docs/` follows the Diátaxis method, in English (`docs/en/`) and French (`docs/fr/`): | |
| 260 | tutorials (getting started), how-to guides (run the tests, run with Claude Code, run in a | |
| 261 | sandbox, use the workspace, use another agent), reference (CLI, WebSocket protocol, workspace | |
| 262 | API) and explanation (architecture). |