--- id: architecture type: architecture-design status: active title: ThinkRail — top-level architecture parent: goal-and-requirements covers: [client-host-split, cli-entrypoint, wire-contract, transport-endpoint, ui-shell-panels, git-worktrees, remote-tailscale, hydrate-then-stream, domain-vs-view-state, frontend-local-workbench-frame, client-local-navigation, central-integration, portable-pi-packages, thinkrail-extensions] tags: [architecture] --- ## Drivers The product is built around the `pi` agent, run **in-process** (`createAgentSession`). Two additive launchers share the same host library: the retained CLI boots the engine host and opens a browser, while Electrobun packages that host with a native system-webview shell. The desktop profile is local only; a later shared-client profile can dial an existing host. The UI ships independently of the host and dials it over the network; a phone reaches the selected host over Tailscale. ## Topology — three rings - **Engine host** (`packages/server` + `packages/shared`, launched by `apps/cli` or `apps/desktop` in local-host mode): owns `pi`, session state, persistence, and serves the wire endpoint. It bundles pi extensions (`pi-web-access`, `pi-spec-graph`, `pi-thinkrail-workflow`, `pi-todos`) and the ThinkRail extensions from its server registry (`visualize`) into every session. - **The wire** (`packages/contracts`): the typed, versioned protocol — the only coupling between client and host. - **UI client** (`apps/web`): a mobile-first React client, transport-driven and endpoint-configurable, shippable as static assets independent of the host. ``` apps/cli browser host launcher: boot server + open browser ── depends on ─▶ packages/server apps/web UI client (mobile-first) ── depends on ─▶ packages/contracts, packages/locales, packages/ui, packages/extension-api/web, packages/resource-renderers, thinkrail-extensions/*/web apps/desktop Electrobun local-host launcher ── depends on ─▶ packages/server, packages/contracts, packages/shared, packages/locales apps/website public landing + blog + /vibecoding (Cloudflare Pages) ── depends on ─▶ packages/website-analytics packages/website-analytics dependency-free browser analytics policy for the public website packages/server createServer(): Bun.serve(HTTP+WS) + AgentSessionManager (in-process pi) ── depends on ─▶ packages/contracts, packages/locales, packages/shared, packages/pi-background-commands, packages/pi-delegation, packages/pi-subagents, packages/extension-api/server, thinkrail-extensions/*/server packages/contracts the wire (types plus tiny pure runtime) packages/locales UI string catalogs (bundled en.json) + pure catalog rules; ./react holds the one i18next instance ── depends on ─▶ nothing in the workspace (i18next, react-i18next) packages/shared shellEnv (server-side only) packages/spec-graph portable pi extension: spec_* tools + skill (bundled into every session by packages/server; its pi-free core/ read model also backs the host's spec.graph read method) packages/pi-delegation portable pure-pi package: the delegation core — controlled child sessions for live session parents or independent resource owners packages/pi-dag portable durable backend DAGs over pi-delegation; host-owned resources; not bundled into ThinkRail, no workflow UI, not a second pi runtime packages/pi-background-commands portable explicit session-owned background commands over Pi's executor (bundled into every ThinkRail parent session by packages/server) packages/pi-subagents portable pure-pi extension: Agent + get_subagent_result tools over pi-delegation (bundled into every ThinkRail parent session by packages/server) packages/pi-thinkrail-workflow pi extension: the workflow skill system + its always-on routing rule (bundled into every session; workspace-internal, not portable) pi-extensions/* portable pi packages published to npm as @thinkrail/pi-; work in vanilla pi (decided, Decision 20; the pi-* packages above move here per publish wave) thinkrail-extensions/* ThinkRail extensions: a pi capability + ./server and ./web halves, composed by one registry file per side (Decision 21; visualize shipped) ── depends on ─▶ pi-extensions/*, packages/extension-api, packages/ui, packages/locales (web half) packages/extension-api types + define* helpers for extension halves ── depends on ─▶ packages/contracts, packages/locales (types) packages/ui owned shadcn/Radix primitives + cn + onThemeSwap ── may depend on ─▶ packages/contracts, packages/locales packages/resource-renderers file/diff renderers, registry + review surfaces behind a host seam ── may depend on ─▶ packages/contracts, packages/locales, packages/ui ``` Artifact verification is a separate source-only workspace, [[module-artifact-tests]]. It depends on CLI build metadata, server test fixtures, and shared teardown; root tools and browser E2E consume it. No product package imports the test workspace, and it has no application build step or Electrobun SDK dependency. This keeps test process drivers outside both launchers and the server library. ## Decisions 1. **Client/host split.** Engine host owns `pi` and state; the UI is a portable client; the wire is the only coupling. **Rule: `apps/web` depends on `contracts`, `locales`, `ui`, `extension-api/web`, `resource-renderers`, and `thinkrail-extensions/*/web` only** — never on `server` or `shared`. All added edges are browser-safe presentation packages; the UI remains shippable without the host. 2. **Launchers are thin; the host is a library.** `apps/cli` and `apps/desktop` both embed the shared boot path in-process. CLI opens a browser; desktop opens a native system webview on a fresh one-origin loopback host. Neither owns engine logic or spawns the other. The CLI remains a complete independent artifact and rollback. A later desktop shared-client profile may omit the local host; every profile uses the same wire and web artifact. **One feature path across deployments.** An ordinary product feature changes its contract, the owning server feature module, the shared web client, and their tests — never each launcher. Launchers and future deployments own only composition, lifecycle, endpoint selection, native presentation, and artifact packaging. A real second environment that cannot supply an existing host operation earns one narrow port in the feature module that owns that behavior; do not pre-abstract the host behind a global platform adapter. Physical runtime requirements are declared once through the server-owned build-support manifest, then transformed by each packager. The same behavior and artifact suites run through every launcher, so reuse is enforced by boundaries and conformance rather than parallel implementations. 3. **The wire is versioned.** `contracts` is types plus a small dependency-free runtime: wire constants, versions, the config default, and guards. Any other runtime is a named exception in its spec, today only the quit-confirmation rule; adding one updates this decision. `server.welcome` carries a protocol version so an independently-shipped UI can detect host-version drift. 4. **Transport endpoint is a parameter.** Defaults to same-origin (`location.host`); a remote browser, desktop, or mobile client points it at the selected host's Tailscale MagicDNS name. Native resume state is keyed by backend profile so ids from one host are never interpreted against another. 5. **UI = panels + shell.** Layout-agnostic, store-driven panels (project→workspace nav, file tree, the code renderer, changes/diff, workspace-local review, terminal, chat, composer) never know their arrangement. Each desktop frontend window owns one locally persisted, resource-free workbench frame: a recursively split center plus auxiliary groups in vertical left/right stacks and a horizontally grouped bottom region. The frame's topology, singleton-tool placement, visibility, folds, geometry, and alignment remain unchanged when that window switches workspace; workspace-scoped resources and attention project into it from separate local views. Terminals may occupy center or auxiliary groups, with new workspaces defaulting one terminal to bottom. Another window never rearranges this one. A future mobile shell may project the same panels differently; desktop docking does not define that projection. Detail: [[submodule-web-shell-layout]]. 6. **Workspaces are git worktrees.** project (git repo) → workspace (`git worktree` on its own branch/cwd, under `~/.thinkrail/worktrees`) → {chats, files, terminals}. **Two deliberate exceptions, both `kind`-marked on the wire and both *user-owned* — never renamed or reclaimed by ThinkRail:** every project carries exactly one built-in **Default workspace** (`kind: "default"`) whose cwd is the project folder itself (git's *main working tree*) — non-removable, non-renamable, and entered explicitly from the project's Welcome fork ("Work in project folder"), never auto-entered — the "just work in my project folder" anchor for users lost in the worktree model; and an **existing worktree** the user explicitly attaches in place (`kind: "external"`), which ThinkRail may forget but never mutates (see [[submodule-server-workspaces]]). The shell is built first, `pi` connected last. **Open PR** is a deterministic, host-side push + open/update of the branch's GitHub PR through the user's own `gh` CLI (no stored tokens, no provider REST API), body rendered from the verified plan, with a compare-URL fallback when `gh`/GitHub isn't available (see [[submodule-server-pr]]). 7. **Auth is external.** Tailscale ACLs / device identity are the auth; the app carries an `owner` field, not a login UI. 8. **Hydrate-then-stream (every client reconstructs domain state from the host).** A client never relies on having *witnessed* events to know domain state—on connect it **reads** current state, then **subscribes** to live deltas. The host exposes `project.list` / `workspace.list` / **`session.list`** / **`session.getMessages`** alongside `pi.event`. A reload, second tab, phone, or **host restart** therefore rebuilds the same projects, workspaces, sessions, and transcripts. `session.list` unions in-memory sessions with pi's on-disk sessions; on that authoritative read a surface hydrates its locally placed chats, then passively auto-opens a bounded number of the newest unplaced sessions that are still live or carry open todos (a single most-recent session opens as a fallback when nothing qualifies and nothing is placed yet, so a workspace never lands empty while chat history exists), and lists everything else in history for explicit reopen. This one auto-open attempt fires once per surface-workspace connection, not on every catalog re-read, and a surface resolving an exact-chat route target defers it entirely to that target. `session.created` supplies that history-only live delta when another frontend starts a session; reconnect repairs a missed delta through `session.list`. The client is a **stateless projection of domain state**, never a second domain source of truth; it separately owns frontend-local navigation and workbench view state. An automatic agent run remains active through retries, compaction, and queued continuations: pi's `agent_end` is only an attempt boundary and may precede more work; `agent_settled` is the authoritative transition to idle. **Normalized session state.** The host projects execution, concrete input blockers, Pi-owned queue count, and the latest completion as orthogonal facts. Needs-input is level-triggered and cannot be cleared by viewing; a completion is created only by `agent_settled` or restart reconstruction. Success, failure/length, and interruption remain owner-globally unread until the exact result is seen: rendered in the focused, unobscured chat of a focused window, with the transcript at its latest edge. Explicit Stop is quiet, and an idle chat's completion becomes unread only once its background commands and subagents have ended; meanwhile the host reports that background work, so the chat reads as working. The only explicit read gestures are per workspace — *Mark as unread* relights its most recent result, *Mark as read* clears every unread one — and they write the same host receipts, so seeing stays the default path. Every client hydrates the same state and exact completion receipts from the host, while focus, attendance and scroll position remain frontend-local evidence of seeing. **Chat-title contract.** A workspace display name, its Git branch/cwd, and each chat title are independent identities; no rename cascades between them. A chat title is pi's durable session name (`session_info`), never browser view state or a host sidecar. **The main agent names its chat and workspace** through the `set_title` tool once the task is clear: it has the context and the tools to read a linked PR/issue/ticket (`Review #567 `), in any tracker. No helper model, first-words heuristic, or host-side link parsing names anything: each of those produced names users wanted to fix. Every write is conditional on the target still being unnamed, so any manual name always wins and the first name is final — nothing retitles automatically. While a target is unnamed, each turn's system prompt says so; guidelines alone were measurably skipped. While a managed workspace is unnamed, every call must carry its name, and the agent is asked to pass all missing names in one call so chat and workspace agree, but they stay independent identities written separately — not a cascade. If the agent never calls the tool, the target stays unnamed (accepted); an unnamed chat is *displayed* by the head of its first prompt, which the host projects beside the title (`firstPrompt`) and never writes anywhere — a read-only label, not a name: rename fields start from the real (empty) title, and the agent's or user's title replaces the label on every surface at once. Clients hydrate `SessionSummary.title`, converge live on `session_info_changed`, and continue to route by session id, so duplicate human titles are legal. 9. **Domain state, frontend-local frame, and workspace-local views.** *Domain* state — projects, workspaces, **sessions + their transcripts**, terminal catalogs/PTYs, and git — is backend-owned, shared, and persistent; every client hydrates it from the host. Current workbench state is view state and never crosses the wire. Each browser tab or native window owns exactly one resource-free `WorkbenchFrame` for center and left/right/bottom topology, singleton-tool placement, visibility, folds, normalized geometry, bottom alignment, and restore targets. It separately owns one `WorkspaceViewState` per workspace for open file/diff/chat/document/terminal placements, tab order, and previews, plus a per-workspace `LayoutAttention` overlay keyed into that frame. The mounted workbench is a projection of those local values, not another authority. Frame mutations are local to one frontend window and persist through its shell-owned local storage adapter. Switching workspace changes only the projected workspace view. Empty groups remain until an explicit frame command removes or merges them; such a command atomically rehomes affected resources in every locally retained workspace view. Applying a preset does the same. Another browser, device, or window neither receives nor adopts those changes. Built-in presets and the default used by an explicit local frame reset remain client-owned; only bounded, resource-free custom preset definitions are host-persisted and broadcast as settings. No current-layout snapshot, revision, mutation, read/write method, or push channel exists on the wire. This remains placement only, never resource lifetime. Closing a file/chat placement is local and the session remains; terminal close retains its explicit host-domain PTY semantics. The active client location is likewise local: one backend-relative route names main / Project Home / workspace / exact chat; web stores it in a versioned fragment, while native shells persist it per backend profile and window. Incoming ids are validated against hydrated host state, and no backend-owned “current screen” or current layout lets one client move another. A frontend surface with no valid local document starts directly from the Balanced frame. Previously persisted host layout snapshots and old browser attention entries are never read, migrated, or deleted; retired config, preset, and terminal-marker shapes are ignored rather than upgraded. Detail: [[submodule-web-shell-layout]] and [[submodule-web-shell-layout-state]]. 10. **Dependencies pin exact versions.** Every dependency in every manifest pins an **exact** version — no ranges (`^` `~` `>` `<` `.x` `*`). Rationale: `pi` ships breaking releases daily, so a floating range is a live wire; more broadly, a silent minor/patch bump is the classic irreproducible-build trap. Exact pins make the lockfile the single source of a dependency's version and turn every upgrade into an explicit, reviewable diff. Cross-cutting deps (pi, TypeScript, typebox, bun types) are pinned **once** in the root `workspaces.catalog` and referenced via `catalog:`, so their version lives in exactly one place **and only there: specs never restate it.** A spec that depends on pi behavior names *what* it verified (the dist file, the function, the observed rule) and says "re-verify on a pi bump"; it does not carry "pinned against vX", which is a second copy of the catalog that goes stale on every bump and adds no information. Historical rationale ("pi 0.86 made the loader choice runtime-dependent") is different: it explains *why* a decision exists and never needs updating. **Enforced**, not just documented: `scripts/check-catalog.ts` (`bun run check:deps`, in pre-commit + CI) rejects any range, any catalog drift, and a lockfile graph that resolves `react` or `react-dom` outside its one catalog pin (the React pin rationale belongs to [[module-web]]). Every pi upgrade also follows [[pi-compatibility]], the architecture-owned registry of host adaptations and their removal gates; detailed rationale stays in the owning module specs. Exempt: `peerDependencies` (extension packages declare `"*"` on purpose — the host provides the dep) and local protocols (`workspace:` / `link:` / `file:`). An exact SemVer prerelease/build suffix is still an exact pin (`19.3.0-canary-a1124489-20260826`); the checker accepts the full identifier grammar, including hyphens, without admitting a range. The root `packageManager` field also pins Bun for development, CI, and CLI compilation; Bun types live in the catalog. Bun `1.4.4` aligns these paths with the desktop runtime, which the desktop config selects through Electrobun's `build.bun.version` (see [[module-desktop]]); a desktop test keeps the two pins equal. CI reads the root pin rather than maintaining a second version in workflow YAML. 11. **Terminal = xterm.js on the DOM renderer.** The browser terminal is `@xterm/xterm`, driven from `apps/web/src/panels/TerminalInstance.tsx` against a real PTY (`bun-pty`) in `packages/server/src/terminal`. It stays the choice because it is the only production-ready browser terminal: the credible alternatives are all Ghostty's VT engine compiled to WebAssembly (`ghostty-web`, `restty`, `wterm`), and the most mature of them has a single tagged release that can do neither mouse reporting nor OSC 8 links — vim/htop/lazygit would regress. **The renderer is deliberately the default DOM one**, not `addon-webgl`: xterm's own maintainer names the DOM renderer a prerequisite for touch support, and WebGL carries defects we would inherit (`WebglAddon.dispose()` leaks its WebGL2 context — fatal for our per-worktree terminal churn — plus iOS context-limit crashes). Loading `addon-webgl` would be a regression, not an upgrade; ligatures and `rescaleOverlappingGlyphs` are the accepted cost. Coupling is kept deliberately thin (about a dozen xterm API members; no parser hooks, decorations or serialization), so a swap stays a contained rewrite of one file. **Re-evaluate when both** (a) upstream tags `libghostty-vt` with an official WASM/npm distribution, and (b) `ghostty-web` ships past 0.4.0 with mouse reporting and OSC 8 working. 12. **A shell belongs to a tab, and the host owns the mapping.** Terminals are keyed by `(workspaceId, tabKey)`; `terminal.reserve` may durably establish the catalog tab without a process, while one idempotent `terminal.attach` remains the only way its PTY is born. Reservation persists before publishing membership and rolls back its in-memory insertion if persistence fails. This separation lets a synchronized hidden default placement survive reload and another client without starting a shell. The client keeps no tab→shell pointer of its own. Shells are **owner-scoped**, matching `history`/`todos`/`templates`, so they survive a reload, a closed browser and a different browser — attach is exclusive, and taking a tab over notifies the displaced client. Lifetime is bounded by reference (no tab → no shell) plus the host process, **not** by timers: no idle culling, no abandoned-client reap. A host restart cannot preserve shells (in-process `pi`, PTY hangup), so tabs are revived with fresh shells showing recorded output. **tmux was rejected** as the persistence layer: an unassumable dependency on Windows, a competing tab model, env-propagation breakage, and polling-based capture — for restart survival we have already decided not to hold. Detail: [[submodule-server-terminal]]. 13. **Central's cross-module lifecycle has one architectural owner.** Its adapter, runtime generation, wire status/quota, synchronized preferences, provider card, and top-bar readout remain in their bounded modules; the correspondence between those surfaces and their liveness obligations belongs to [[central-integration]]. This keeps feature-specific mechanics in their leaf specs while making a non-terminating composition visible at the architecture layer. 14. **The public website is one origin and production deployment.** `apps/website` owns `/`, `/blog/`, and `/vibecoding/` in one static Astro build deployed with same-project Cloudflare Pages Functions. D1-backed [[submodule-website-attribution]] provides short-lived browser claims under that deployment; it transfers bounded campaign/referrer touch data, not page identity, and is not a second product host or identity owner. React and Tailwind are permitted only inside [[submodule-website-vibecoding]]; unrelated routes retain their vanilla runtime and hand-written stylesheet. Browser analytics and consent initialize once on the exact `thinkrail.ai` origin. The retired `vibecoding.thinkrail.ai` hostname is an edge redirect that preserves path and query, never a proxy to a second site. 15. **Desktop packaging preserves the host/runtime boundary.** Electrobun `2.0.5` explicitly selects the pinned Bun `1.4.4` runtime and embeds the host in that process, not the experimental Cottontail runtime; it never wraps or spawns the CLI. Its exact npm bootstrap pin selects the Hutch build toolchain and generated SDK; Bun remains the workspace package manager. The native window loads the packaged web build from the host's actual loopback port so UI, wire, files, and SPA fallback keep one origin. Native resources that require paths stay unpacked. The shell sets the staged `bun-pty` library before server import and loads PI from a separately bundled `.ts` runtime so external TypeScript extensions receive PI's bundled virtual modules rather than nonexistent built-Node aliases. The CLI and desktop share host boot and graceful shutdown, but launchers enforce no process-wide single-instance or canonical-data-directory ownership policy. Each host binds its own loopback port; when multiple hosts point at the same mutable data directory, cross-process consistency is intentionally not guaranteed. Desktop artifacts are additive; native WebKitGTK on Ubuntu 24.04+/glibc 2.38 is the supported Linux floor. The standard framework CLI/configuration owns bundling and installers; a documented pre-build hook prepares ThinkRail's physical resources and PI runtime. Release workflows in `JetBrains/thinkrail-signing` consume the public build recipes and coordinate build → JetBrains service signing → publication for an explicit public source commit. Product code and ordinary CI stay public; credentials and publication stay private. macOS service signing consumes the framework's expanded app archive and finalizes the DMG through the documented JetBrains SRE flow, without a local Apple credential flow or mutation of Electrobun's compressed wrapper. Exact handoff and release verification contracts belong to [[module-ci-release]]. Detail: [[module-desktop]], [[module-ci-release]]. 16. **Delegation is portable; ThinkRail is one embedder.** `packages/pi-delegation` owns the session fabric: one creation primitive with orthogonal axes, a run-owning handle, lineage, registry, and lifecycle events. `packages/pi-subagents` consumes it to expose the `Agent` tools. Both work under vanilla pi with the SDK as a `peerDependency` (peer deps are exempt from the exact-pin rule, decision #10), create in-process hidden pi sessions, and keep their host bindings optional. ThinkRail composes them in `packages/server`: one service per workspace, child transcripts under the host data dir, a curated child-extension set, and the exact `ModelRuntime` retained by each parent session so children stay on that parent's provider generation across Central changes. The wire mirrors only the UI-facing run details and exposes transcript reads; neither portable package depends on ThinkRail. The same core supports independent resource owners with explicit execution context and immutable captured-history forks for durable orchestrators; it never fabricates a parent chat or acquires scheduling/storage policy. Contract, semantics, and the full decision log: [[module-pi-delegation]], [[module-pi-subagents]], and [[submodule-server-agent]]. 17. **Durable DAGs are host-owned resources, not parent chats.** [[module-pi-dag]] is a separately scoped portable consumer of [[module-pi-delegation]], not a dependency of subagents or the workflow skill system. Delegation owns canonical history capture/forking, retained resource execution contexts, child assembly/reopen and the existing run loop; DAG owns persistence, scheduling, gates and recovery. ThinkRail's selected future composition is workspace-owned, with an explicit host runtime, so no heading session is required. Embedders may apply different lifetime policies through trusted lifecycle controls without another scheduler or human-gate bypass. Restore paused; one process controls each DAG. ThinkRail neither bundles it nor exposes it over the wire or in the UI. 18. **Chat Resources projects capability owners; it is not a generic resource runtime.** [[module-pi-background-commands]] supplies explicit, session-owned log-only commands over Pi's public executor; normal Bash and workspace PTYs stay unchanged. `packages/server` embeds that portable package through `agent` and projects it beside direct [[module-pi-delegation]] children into the current-chat Resources view. Parent session entries own injected command services and retained subagent completion delivery across extension reload; scoped reads, controls, and invalidations are composed through the agent barrel. Detailed integration belongs to [[submodule-server-agent]], and command lifetime/retention to [[module-pi-background-commands]]. 19. **The review surface is engine-neutral; renderers are registered, not hard-wired.** A resource (a file or one side-pair of a diff) is shown by the renderer the `@thinkrail/resource-renderers` registry selects by match, rank and capabilities for the current intent and viewport class; review comments anchor through `contracts`' `ReviewSelector` set (`lineRange`/`textQuote` for text, `structural` schemes such as `json-pointer`/`table-cell`/`ipynb-cell`, `region` geometry), which every renderer maps onto its own geometry and reports back as placed or unplaced — the host's anchors are the only authority. `@pierre/diffs` renders every source diff and every phone-class code surface; rich formats render through their own renderers' diffs. Monaco renders files on desktop and never loads on a phone. Write-paths are host-derived and compare-and-swap guarded (`change.revert`/`change.undo`, [[submodule-server-changes]]); the client never sends bytes to write. ThinkRail hosts no VS Code extensions: language intelligence (TextMate grammars, Shiki) is imported as libraries. Active content (HTML, SVG, notebook outputs) renders only inside sandboxed, network-denying frames. The renderers, registry and review surfaces are one package ([[module-resource-renderers]]) behind a host seam (markdown, code highlighting, byte URLs, link navigation), so every surface that shows a file uses the same stack. Detail: [[module-resource-renderers]], [[submodule-web-panels]], [[submodule-server-reviews]]. 20. **Portable pi packages are published, scoped, and held to a vanilla-parity bar.** Capabilities the agent can use anywhere live under `pi-extensions/*` and ship to npm as `@thinkrail/pi-<name>` (the npm org is `thinkrail`, which also reserves the scope of the private, never-published `@thinkrail/*` workspace packages; raw TypeScript, `pi` manifest, `pi-package` keyword; unscoped `pi-*` names collide with third parties). "Works in vanilla pi" is defined, not assumed: install from the packed tarball into an isolated fixture, load through pi's own loader under **Node** (vanilla pi's runtime — ThinkRail runs the same code under Bun, so shipped code is dual-runtime), register and execute tools, render in a real terminal. Releases go through Changesets and npm Trusted Publishing with provenance; the host keeps consuming the packages through `workspace:*`. A portable *library* (delegation) is published without a manifest and is never given a fake factory. Detail and the gate: [[module-pi-extensions]]. 21. **ThinkRail extensions are separate packages over a small host UI SDK, composed from registry files.** `thinkrail-extensions/<name>` composes a pi capability with a `./server` half (what the host bundles, as named inline factories; skill packages named by specifier and resolved from the extension, not the host) and a `./web` half (tool renderers keyed by tool name, plus named exports the host uses directly). The halves never import each other or host internals; host-owned scoped state reaches an extension only through explicit seams — the one property fixed now so later extensions (wire methods, panels) extend the `define*` objects instead of replacing them. Composition is static: a server registry (`packages/server/src/extensions/registry.ts`) whose static imports carry factories into every launcher without generated factory lists (the packagers' generated lists now cover only the pi packages not yet wrapped as extensions), and a web registry (`apps/web/src/extensions/registry.ts`). Skill packages are resolved from the extension's own dependency graph, never the host's. The SDK is `packages/extension-api` (types + `define*`) and `packages/ui` (owned primitives, `cn`, `onThemeSwap`); the highlighted `CodeBlock` stays app-local until a second consumer exists. Decision 1's browser-only dependency rule is enforced with source-half and public-subpath checks, including the SDK's separate `./web` and `./server` contracts. **Delivery rule:** every extension arrives as three independently shippable PRs — new pi package beside the old, web half moved, server half + wiring with the old package deleted — none of which changes anything a user can observe. **Enable/disable has shipped:** Settings → Extensions lists the server registry (`extension.list`) with a switch per extension; the global `AppConfig.disabledExtensions` reaches `agent` through a host-injected resolver (no `agent` → settings edge), which filters registry factories at every loader (re)load and withholds a disabled extension's tools from live sessions through pi's active-tool API, while web renderers stay registered so past transcripts render. Install UX, marketplace, per-extension settings, a per-workspace on/off override and runtime-loaded third-party extensions remain explicit deferrals. Rejected: runtime-loaded bundles now (React singleton, versioned UI API, security story first), a logical extension inside the apps (three physical homes, no boundary), generated factory lists derived from descriptors (functions yield no import specifiers); keeping path-based dev loading beside the inline composition (one composition mode; `{ name, factory }` keeps diagnostics); resolving `skillPackages` from the host (the host no longer depends on extension-owned pi packages, so `MODULE_NOT_FOUND` in isolated layouts). Pilot, shipped in three PRs: visualize — `lovely-mermaid` for TUI rendering and best-effort validation in the portable package, strict `mermaid`+`linkedom` validation injected by the ThinkRail server half through `createVisualizeExtension({ validateMermaid })`; the old `packages/pi-visualize` is deleted. Detail: [[module-thinkrail-extensions]]. 22. **A path's spelling follows its kind.** A directory identity (`Project.path`, `Workspace.worktreePath`) is the host's **resolved** spelling (`path.resolve`), exactly what pi records as a transcript's cwd (pi resolves the cwd it is handed), so comparing it with a pi-reported cwd is a plain `===`. Paths that enter in another spelling are resolved where they enter: git prints `C:/…` on Windows, `THINKRAIL_DATA_DIR` may be relative, and records written before this rule are healed on load ([[submodule-server-persistence]]). A **workspace-relative** path on the wire or in a store (file tree, changes, review anchors and reading order, fs events) is POSIX `/` and contained in the worktree; the server converts native `path.relative` output and model-written paths where they enter, and git object names (`<ref>:<path>`) use `/`. Absolute paths pi reports (tool arguments, transcript cwd) stay pi's and may use either separator; the browser reads them through `projectRelativePath` ([[submodule-web-lib]]), and in agent-written prose or paths a backslash is a separator, never a Markdown escape. Why: two spellings of one Windows directory broke every raw comparison (#766), so chats in Default and external workspaces could not be listed, reopened, renamed, deleted, or scoped in history. Rejected: normalizing at each comparison (every future comparison must remember; the first patch missed nine of twelve sites); `realpath` as the canonical form (diverges from headers already on disk wherever a stored path crosses a symlink or 8.3 name); folding case (pi's own cwd identity is case-sensitive, and git and `resolve` both keep on-disk case). Accepted cost: an older ThinkRail sharing `~/.thinkrail` fails reopen-from-Recent (`ALREADY_OPEN`) for a healed project until it updates. 23. **UI strings are keyed catalogs; local language files are runtime data, not code.** Every user-facing string in `apps/web`, `packages/ui`, and extension web halves is a named flat key (`settings.language.title`) in [[module-locales]]'s `en.json`, the single English source of truth, rendered through one shared i18next instance (`@thinkrail/locales/react`). **A lint gate fails on hardcoded user-facing text** (JSX text and text-bearing attributes such as `aria-label`, `title`, `placeholder`, `alt`, `label`) in those trees, so a new string cannot ship without a key. ThinkRail ships bundled catalogs (English plus translations held to `en.json` by a test); a user adds or overrides a language by dropping a flat i18next-JSON file into the **global** data dir (`~/.thinkrail/locales/<tag>.json`) — never a workspace, so a cloned repo cannot rewrite the UI. The host validates and watches those files ([[submodule-server-locales]]) and serves them over the wire; the web merges them over the bundled catalogs ([[submodule-web-i18n]]) with per-string fallback *local exact → bundled exact → bundled base → English*. The choice (`AppConfig.locale`, default `"system"`) follows the user like the theme. Agent, tool, skill, prompt output and server error text are never translated. Stored and wire data never carry translated text: defaults ThinkRail writes (untitled chats, default terminals, the host-named default workspace, chat round notices) are worded at render, so a switch re-words all of them, while names set by the user or the agent show verbatim. This is the one runtime-loaded user resource; code extensions and themes stay compiled in (Decision 21 and the themes non-goal) because a string map renders as escaped text and carries no executable surface. Rejected: English-text keys (a wording fix orphans every translation); a host-served bundled catalog (bundled strings belong to the web build that renders them); an in-house runtime (i18next chosen for its standard format and tooling); catalogs in `contracts` (data and translation logic are not wire types). Deferred: RTL layout, an agent translation skill. ## Invariants - Never **value**-import `pi` in browser-bundled code; import types only, from the `pi-ai` / `pi-agent-core` package roots (type-only imports are erased at build, keeping the bundle provider-free). `@earendil-works/pi-coding-agent` is server-only — it never reaches `contracts`/`web`. - One id model: the UI tab id vs `session.sessionId` (the `AgentSession` id). No separate pi UUID. - The agent runs in-process with **no crash isolation** — wrap session calls and forward errors; a fatal fault takes the whole host down (accepted tradeoff vs the subprocess RPC mode). - `pi` owns state and emits the truth; the host is a thin bridge — it **exposes** `pi`'s state through read methods (it does not recompute it) and forwards `pi`'s events as deltas. Clients **hydrate from the reads, then stream the deltas** — they hold only view state of their own. - Background console children launched by the host or CLI set **`windowsHide: true`**. Bounded Windows children also remain **non-detached**, so ordinary helpers inherit a nonvisual console rather than opening their own; the platform policy and native regression belong to [[submodule-server-subprocess]]. This includes PR revalidation on window focus; terminal-busy checks are close actions and Central quota resumes on visibility changes, not ordinary focus. Sync and fire-and-forget cases use `@thinkrail/shared/spawn`; bespoke bounded runners set the option directly. Exempt: spawns that inherit an existing terminal's stdio (the `update`/`uninstall` CLI subcommands, the build script) and shell probes that are no-ops on win32 (`shellEnv`).