OpenCode — Deep Architecture Analysis (Phase 0 Research)
Reference: references/opencode — snapshot at commit 0bff28de09105088ff5bdefab91413d55c28dff1 (2026-08-09), repo github.com/anomalyco/opencode, version 1.18.x.
Method: real code-path tracing (imports, agent loop, tool registry, render tree, permission checks, persistence), not README reading. All paths are relative to the OpenCode repo root. Key symbols are cited as evidence.
Important context: this snapshot is the post-rewrite OpenCode. The old Go/bubbletea TUI is gone; the entire product is now TypeScript on Bun, with a SolidJS-driven terminal renderer (@opentui/*). The codebase also visibly contains two coexisting generations: the live "v1" runtime (packages/opencode/src/**, schemas in packages/core/src/v1/**) and an in-progress event-sourced "v2" layer (packages/core/src/{session,agent,permission,config,snapshot,tool}). Both write to the same SQLite database. This mid-migration state is itself an architectural lesson (see "What OpenCode does poorly").
1. Monorepo and technology stack
- Bun workspace monorepo (
package.jsonat root,packageManager: bun@1.3.14, turbo for typecheck, oxlint). ~30 packages underpackages/: the ones that matter for a terminal agent are:packages/opencode— the CLI + engine (agent loop, tools, sessions, server routes).packages/core— shared services: database, event system, filesystem, ripgrep, permission/agent/config schemas (v1 + v2), snapshots, shell, PTY.packages/tui— the terminal UI (SolidJS +@opentui/solid).packages/sdk—openapi.json+ generated JS client (packages/sdk/js/src/gen/{client.gen.ts,sdk.gen.ts,types.gen.ts}).packages/schema— Effect Schema definitions shared by everything (session.tsv1,session-message.tsv2,permission,revert, identifiers).- Plus web/app/desktop/console/enterprise packages that are irrelevant to the terminal product but bloat the repo.
- Core libraries: Effect 4 (services, layers, streams, structured concurrency) everywhere in the engine; SolidJS 1.9 +
@opentui/core|solid|keymap0.4.5 for the TUI;aiSDK 6 for provider streaming; drizzle-orm + SQLite for persistence; web-tree-sitter (bash/powershell WASM grammars) for command parsing; fuzzysort for fuzzy matching; ripgrep (bundled binary service,packages/core/src/ripgrep). - The engine is written in "Effect service" style: every subsystem is a
Context.Servicewith aLayerand explicit dependency list (e.g.SessionProcessor.node = LayerNode.make({ service, layer, deps: [Session.node, Config.node, Snapshot.node, ...] })inpackages/opencode/src/session/processor.ts:699). This gives explicit wiring and testability at the cost of a steep idiom (generators,Effect.gen,Deferred,PubSubeverywhere).
2. Client/server architecture
2.1 Process model — one process, virtual HTTP
The headline finding: OpenCode is architected as client/server but usually runs as a single process.
- The server is an Effect HttpApi app (not raw hono in this generation):
packages/opencode/src/server/server.tsbuildsHttpApiApp.webHandler()and exposes bothfetch(request)and an in-memoryrequest(input, init)entry (Server.Default,server.ts:57-66).openapi()derives the public OpenAPI document fromPublicApi. - The default
opencodecommand (packages/opencode/src/cli/cmd/tui.ts,TuiThreadCommand) does not open a socket. It spawns the engine in a Bun Worker (packages/opencode/src/cli/tui/worker.ts) and gives the TUI:createWorkerFetch(client)— afetchimplementation that serializes{url, method, headers, body}over an RPC channel; the worker replays it againstServer.Default().app.fetch(request)(worker.ts:31-49). The base URL is a placeholder (http://opencode.internal).createEventSource(client)— subscribes to"global.event"RPC events instead of SSE (cli/cmd/tui.ts:42-49); the worker forwards everyGlobalBusevent over RPC (worker.ts:24-26).
- The same TUI can run against a real HTTP server:
packages/cli/src/tui.ts(runTui({url, headers})) uses plain HTTP + SSE with exponential backoff.opencode serve/opencode attach/opencode web(packages/opencode/src/cli/cmd/{serve,attach,web}.ts) expose the same API over the network;server.tssupports mDNS advertisement (MDNS) and CORS.
Consequence: the client is written 100% against the SDK/API surface, but the common path pays no network tax and needs no daemon lifecycle management. UI ↔ engine isolation also means a render-thread stall never blocks tool execution and vice versa.
2.2 API surface and SDK
- Route groups:
packages/opencode/src/server/routes/instance/httpapi/groups/{session,permission,question,provider,config,file,event,project,workspace,pty,mcp,tui,global,...}.ts. - The JS SDK is generated from
packages/sdk/openapi.jsonwith@hey-api/openapi-ts(packages/sdk/js/script/build.ts; ~162 paths / 188 operations intoclient.gen.ts/sdk.gen.ts/types.gen.ts), so client and server cannot drift silently. The TUI consumes it viacreateOpencodeClient({baseUrl, fetch, directory, headers})(packages/tui/src/context/sdk.tsx). Apackages/sdk-nextvariant embeds the engine in-process for SDK consumers. - Events reach clients through SSE handlers — per-instance
/eventand server-wide/global/event(server/routes/instance/httpapi/handlers/{event,global}.ts):text/event-stream, listener registered into an unbounded queue before the stream body starts ("Listener registration is eager, so events published after this point cannot be lost"), filtered by instance directory/workspace, prefixed withserver.connected, merged with a 10sserver.heartbeat. Response gzip middleware explicitly bypasses SSE paths. - Multiple clients (TUI + web + IDE + ACP) can attach to one engine; the server is multi-tenant across project directories via an
x-opencode-directoryheader, with per-directory engine state held in aScopedCachekeyed onctx.directory(packages/opencode/src/effect/instance-state.ts) and instance boot deduplicated throughDeferreds (packages/opencode/src/project/instance-store.ts). The share feature and control-plane routes build on the same event stream.
2.3 Event system — the engine's spine
Three layers (all evidence in packages/opencode/src/bus/global.ts, packages/core/src/event.ts, packages/opencode/src/event-v2-bridge.ts):
GlobalBus— a plain in-memoryEventEmitterfor process-level fan-out to SSE/RPC clients.EventV2— typed pub/sub on EffectPubSub, with a critical property: event definitions can be durable (options: {durable: {aggregate: "sessionID", version: 1}}inpackages/schema/src/v1/session.ts). Publishing a durable event transactionally appends it to theeventtable (per-aggregateseqfromevent_sequence) and runs registered projectors in the same transaction ("Local operational projection committed atomically with a new durable event").EventV2Bridge— attaches location info (directory/workspace/project) to every publish and re-emits ontoGlobalBus, including a secondsyncenvelope for durable events (replication/share).
Persistence is a projection of events. packages/core/src/session/projector.ts maps SessionV1.Event.{Created,Updated,MessageUpdated,PartUpdated,...} onto the session / message / part tables; session cost and token counters are maintained incrementally (sql\${col} + ${delta}`applied fromstep-finishparts, with negative deltas on part replacement/removal).packages/opencode/src/session/session.ts updateMessage/updatePart` never touch the DB directly — they only publish events. The UI, the DB, and remote clients are all consumers of the same stream. This is the single most elegant structural decision in the codebase.
3. TUI framework
3.1 Stack and rendering model
- SolidJS 1.9.10 (patched) +
@opentui/core+@opentui/solid+@opentui/keymap— opentui is the team's own terminal UI engine: a retained-mode renderable tree with Yoga flexbox layout painted into an optimized cell buffer at a target FPS, driven declaratively through Solid's fine-grained reactivity. JSX intrinsics are terminal renderables:<box>,<text>,<span>,<scrollbox>,<textarea>,<input>,<markdown>,<code>,<diff>,<spinner>. - Bootstrap:
packages/tui/src/app.tsx:191-213—createCliRenderer({ targetFps: 60, useKittyKeyboard: {}, exitOnCtrlC: false, externalOutputMode: "passthrough", useMouse: ... })inside an EffectacquireRelease;render(() => <Providers…/>, renderer)from@opentui/solid. - Rendering is dirty-driven, not full-redraw: components call
renderer.requestRender()only for imperative fixups; normal updates flow from Solid signals into individual renderables. Custom cell-level painting is possible (FrameBufferRenderablesubclass withrenderSelf(buffer: OptimizedBuffer)inpackages/tui/src/component/bg-pulse.tsx). - No web tech, no marked, no shiki in the TUI: markdown is a native opentui renderable (
<markdown streaming={true} internalBlockMode="top-level" …/>), and syntax highlighting is tree-sitter WASM grammars + nvim-treesitter highlight queries (35+ languages declared inpackages/tui/src/parsers-config.ts, registered viaaddDefaultParsers). Highlight styles are generated from the active theme (generateSyntax(theme)inpackages/tui/src/theme/index.ts).
3.2 Component architecture
- Routing is a plain Solid store, not a router library:
packages/tui/src/context/route.tsx(Route = HomeRoute | SessionRoute | PluginRoute,navigate()viareconcile). Top-level<Switch>inapp.tsx:1112→<Home/>/<Session/>; the session subtree remounts keyed on session id. - ~25 nested context providers compose the app (
app.tsx:247-349): Exit → ErrorBoundary → Keymap → SDK → Sync → Theme → Local → Dialog → Frecency → PromptHistory → … - The session view (
packages/tui/src/routes/session/index.tsx, 2725 lines) renders messages with a plain<For>inside an opentui<scrollbox stickyScroll stickyStart="bottom">. There is no list virtualization; instead the sync store hard-caps at 100 messages per session and GCs older parts (packages/tui/src/context/sync.tsx:341-358). Bounding the data instead of virtualizing the view is a deliberate simplification. - Tool parts dispatch through
PART_MAPPING = { text: TextPart, tool: ToolPart, reasoning: ReasoningPart }and atoolDisplay()switch to per-tool components (Shell,Edit,Read,Task, …), built on two presentation primitives:InlineTool(one line, collapsed) andBlockTool(bordered block). - The sidebar (
routes/session/sidebar.tsx, fixed width 42, auto-shown when width > 120) is composed entirely of plugin slots — even first-party UI (todos, LSP, MCP, changed files) is a "feature plugin" underpackages/tui/src/feature-plugins/.
3.3 State sync — server state mirrored into a Solid store
packages/tui/src/context/sync.tsx is the heart of the client:
- One
createStoreholdingsession,message,part,permission,question,todo,agent,provider,config,lsp,mcp,vcs, … Sessions/messages/parts are sorted arrays maintained with binary search (search()helper) so each event application is O(log n) + splice. - Streaming text arrives as
message.part.deltaevents, appended in place withproduce()— Solid's granular reactivity then repaints only the affected<markdown>node. This is the entire streaming render path; there is no diffing of whole messages. - SSE events are coalesced in a 16 ms window and applied inside Solid's
batch()("Batch all event emissions so all store updates result in a single render",packages/tui/src/context/sdk.tsx:48-80). - Three-phase bootstrap (
loading→partial→complete): a small blocking fetch set (providers, agents, config, project), then everything else non-blocking. Sessions hydrate lazily (session.sync(sessionID)fetches info + last 100 messages + todos + diff), with an explicit race guard so a slower REST snapshot never clobbers fresher SSE-streamed text (hydratingSessions; regression teststest/cli/cmd/tui/sync-live-hydration.test.tsx).
3.4 Keyboard system
@opentui/keymapwrapped bypackages/tui/src/keymap.tsx. Bindings are declared reactively and locally viauseBindings(() => ({ mode, commands, bindings }))in whatever component owns them; command metadata ({name, title, category, slashName, slashAliases, suggested, run}) doubles as the source for both the command palette and slash commands.- Leader key (
<leader>=ctrl+x, 2 s timeout,registerTimedLeader), key aliases, and a mode stack (base/modal/autocomplete): dialogs push"modal"mode, which automatically suppresses allbase-mode layers — that is how overlays capture input without manual focus bookkeeping. - ~230 rebindable actions in
packages/tui/src/config/keybind.ts(Definitionswith defaults + descriptions), validated by Effect Schema, configured in a separatetui.json(deliberately split fromopencode.json). A "which-key" overlay (feature-plugins/system/which-key.tsx) shows pending key sequences.
3.5 The composer (prompt)
packages/tui/src/component/prompt/index.tsx (1716 lines) — the single most engineered component:
- One
<textarea>renderable, min height 1, max heightmax(6, height/3), multiline viashift+return/ctrl+j, with syntax-styled text and configurable cursor. - Extmarks (editor-style virtual text spans) are the core mechanism: file mentions, agent mentions, and collapsed pastes are inserted as
input.extmarks.create({start, end, virtual: true, styleId}), mapped to structuredPromptInfo.partsviaextmarkToPartIndex;syncExtmarksWithPromptParts()rederives part offsets from live extmark positions on every edit. The submitted prompt is thus structured parts (text + file refs + agent refs), not a flat string. @file mentions: width-aware trigger detection (Intl.Segmenter+Bun.stringWidthso offsets match terminal cells,packages/tui/src/prompt/display.ts), server-side fuzzy file finding (sdk.client.v2.fs.find, ranked server-side and deliberately not re-sorted), line-range syntax@file#12-40, agent and MCP-resource completion in the same popup.- Frecency: JSONL at
<state>/frecency.jsonl, scorefrequency / (1 + ageDays), folded into autocomplete ranking (score * (1 + frecencyScore)). - Slash commands are just palette commands with a
slashName, merged with server-defined commands, ranked by fuzzysort with an exact-prefix bonus. - Paste intelligence: bracketed paste decoding; ≥3 lines or >150 chars collapses to a
[Pasted ~N lines]extmark expanded only at submit; path-looking pastes become file attachments; images/PDFs become base64 file parts. - Shell mode:
!at offset 0 switches the composer into shell mode; submit routes tosession.shellinstead of the agent. - History (
<state>/prompt-history.jsonl, 50 entries, cursor-position-aware navigation), stash (named drafts), external$EDITORround-trip that re-locates extmark placeholders afterwards, IME-safe submit (doublesetTimeoutflush), and an explicit double-Enter race guard with a regression test (test/cli/tui/prompt-submit-race.test.ts).
3.6 Dialogs, palette, model selector
packages/tui/src/ui/dialog.tsx: a dialog stack rendered as an absolutely-positionedzIndex={3000}layer with an alpha scrim; focus is saved on open and restored only if the previous renderable is still mounted. Escape/ctrl+c dismissal is disabled while a mouse text-selection is active — a small detail that protects copy behavior.packages/tui/src/ui/dialog-select.tsx(791 lines) is the generic list dialog: categories, fuzzysort filter, footer key hints, action buttons, mouse + keyboard. Every picker (model, agent, session, theme, workspace…) is a thin wrapper over it.- Command palette (
component/command-palette.tsx,ctrl+p) reads reachable commands from the keymap itself (keymap.getCommandEntries({namespace: "palette", visibility: "reachable"})) and shows their live bindings — one registry powers keys, palette, and slash commands. - Model selector (
component/dialog-model.tsx): Favorites / Recent categories, provider ordering, deprecated filtering, sub-dialogs for provider and variant.
3.7 Terminal capabilities, theming, resize
- Terminal palette is queried (
renderer.getPalette({size: 16})) and a "system" theme is synthesized from the actual terminal colors (generateSystem(colors, mode)); dark/light is detected viawaitForThemeModeplus a raw DEC escape sniffer for live OS theme switches. 33 bundled themes as JSON assets; custom themes from~/.config/opencode/themes/and.opencode/themes/with SIGUSR2 hot reload. - Kitty keyboard protocol enabled; mouse optional; win32 gets FFI-level console-mode fixes (
packages/tui/src/terminal-win32.ts,dlopen("kernel32.dll"));ctrl+zsuspend/resume handled properly (renderer.suspend()+SIGCONT). - Resize: everything derives from
useTerminalDimensions()memos (wide() = width > 120toggles sidebar and split-vs-unified diffs).externalOutputMode: "passthrough"keeps strayconsole.logfrom corrupting frames. - Diffs render through opentui's native
<diff>renderable (split/unified, syntax highlighting, wrap modes, full theme color set), used in the edit tool view, permission previews, and a full-screen diff viewer feature-plugin (feature-plugins/system/diff-viewer.tsx, 1077 lines: file tree, hunk navigation]/[, git/branch/last-turn modes).
4. Agent architecture
4.1 Agents = permission policies (mostly), not prompts
packages/opencode/src/agent/agent.ts defines Agent.Info = {name, mode: "subagent"|"primary"|"all", permission: Ruleset, model?, prompt?, temperature?, steps?, hidden?, …}.
The decisive finding: build and plan share the same system prompt and the same tool registry — they differ only in permission rulesets (plus reminder injection):
build: defaults +{question: "allow", plan_enter: "allow"}.plan: defaults +{question: "allow", plan_exit: "allow", edit: {"*": "deny", ".opencode/plans/*.md": "allow"}, task: {general: "deny"}}(agent.ts:157-181). Plan mode's edit-blocking is pure permission policy;Permission.disabled()also derives tool visibility from the ruleset (a tool whose last matching rule ispattern:"*", action:"deny"is removed from the model's tool list), sowrite/edit/apply_patchdisappear in plan mode rather than erroring.- Plan behavior is reinforced by synthetic reminder parts appended to the last user message (
packages/opencode/src/session/reminders.tsinjectingsession/prompt/plan.txt,plan-mode.txt,build-switch.txt) — not by a different system prompt. - Neither built-in primary agent has a
promptfield; the system prompt is selected by model family:SystemPrompt.provider(model)inpackages/opencode/src/session/system.tspickssession/prompt/anthropic.txtfor Claude,gpt.txt/gemini.txt/etc. otherwise. An agentpromptfield replaces this entirely (session/llm/request.ts:60). - Utility agents (
explore,compaction,title,summary) are hidden agents with their own prompts and"*": "deny"+ allow-lists — e.g.exploreis a read-only search subagent (agent/prompt/explore.txt). - Custom agents: markdown files in
.opencode/agent{s}/**/*.mdwith YAML frontmatter (ConfigAgent.load, schemaConfigAgentV1.Info) or JSON config; a permissive YAML fallback exists "because other coding agents like claude code allow invalid yaml". - Mid-session agent switching is trivial because agent is a per-message field (
SessionV1.User.agent), cycled with Tab in the TUI.plan_exitis a tool that asks the user a question and then simply writes a new user message withagent: "build"(packages/opencode/src/tool/plan.ts).
4.2 The agent loop — state-machine over persisted messages
The loop lives in packages/opencode/src/session/prompt.ts (SessionPrompt.runLoop, lines 1081-1341), with per-turn stream handling in packages/opencode/src/session/processor.ts (SessionProcessor). Shape:
while (true) {
msgs = MessageV2.filterCompactedEffect(sessionID) // re-read persisted state
{lastUser, lastAssistant, tasks} = MessageV2.latest(msgs)
if (assistant finished && no pending tool calls) break // exit condition derived from state
task = tasks.pop()
if (task is "subtask") { handleSubtask(...); continue }
if (task is "compaction") { compaction.process(...); continue }
if (lastFinished overflows) { compaction.create(...); continue }
msg = new assistant message
handle = processor.create({assistantMessage, sessionID, model})
tools = SessionTools.resolve({agent, session, model, ...})
system = [environment, instructions, mcp, skills]
result = handle.process({system, messages, tools, model}) // one streamed LLM step
if (result === "stop") break
if (result === "compact") compaction.create(...)
}Key properties, all verified in source:
- The database is the loop's state. Each iteration re-derives what to do from persisted messages/parts; compaction and subagent invocations are persisted parts (
compaction,subtaskpart types) popped as tasks. A crashed process can resume mid-conversation because nothing lives only in loop-local variables.SessionPrompt.loopwrapsrunLoopinstate.ensureRunning(...)so concurrent prompts join the running loop instead of double-driving it. SessionProcessoris a pure stream-event reducer (processor.ts:278-537): ahandleEventswitch overtext-start/delta/end,reasoning-*,tool-input-*,tool-call,tool-result,tool-error,step-start/finish,finish. Every event immediately becomes a part upsert (session.updatePart) or a delta event (session.updatePartDelta) — persistence and UI streaming are the same operation.- Doom-loop detection: if the last 3 parts are the same tool with byte-identical JSON input, a
doom_looppermission request interrupts the run (processor.ts:353-380,DOOM_LOOP_THRESHOLD = 3). - Snapshots bracket every step:
snapshot.track()before the stream and atstep-start/step-finish; apatchpart with{hash, files}is recorded whenever files changed (processor.ts:424-470) — this powers revert and the diff viewer. - Interrupts are first-class:
Effect.onInterruptmarks the assistant message aborted;cleanup()waits up to 250 ms for in-flight tool calls, then marks stragglersstatus: "error", metadata.interrupted: true; the message-to-model converter later turns pending/running tool parts into"[Tool execution was interrupted]"results so Anthropic never sees a danglingtool_use(message-v2.ts toModelMessagesEffect). - Retry is a policy around the stream (
SessionRetry.policy, surfaced as a liveretrystatus with attempt count), and providercontent-filterfinishes are converted into visible errors instead of silent idles (prompt.ts:1301-1308). - Usage/cost come from real provider metadata at
step-finish(Session.getUsage), incrementally accumulated onto the assistant message and session row.
4.3 Subagents
packages/opencode/src/tool/task.ts:
taskspawns a child session (sessions.create({parentID, agent, permission})) — fresh context by design;task_idlets the model resume a prior child session. The parent's model/variant is inherited unless the subagent pins its own.- Permission derivation (
agent/subagent-permissions.ts): only the parent's deny rules andexternal_directoryrules propagate to the child; the subagent's own ruleset defines its capabilities.taskandtodowriteare force-denied for children unless explicitly granted — combined withsubagent_depth(default 1, checked by walkingparentID), this prevents recursive agent explosions. - Background subagents (behind a flag): results are injected back into the parent session as synthetic
<task id=… state=…>text parts; aBackgroundJobservice manages wait/promotion/cancel. - Rendering: the parent's TUI shows child-session progress live because child events flow over the same bus.
5. State and persistence
5.1 Storage backend
- SQLite + drizzle, WAL mode, at
~/.local/share/opencode/opencode.db(packages/core/src/database/database.ts; pragmas: WAL,synchronous=NORMAL,busy_timeout=5000, 64 MB cache). Legacy JSON-file storage (packages/opencode/src/storage/storage.ts) survives only forsession_diff. - Tables (
packages/core/src/session/sql.ts+database/schema.gen.ts):session(typed columns: project/workspace/parent ids, directory, title, cost, token counters,revertjson,permissionjson, agent, model),messageandpart(ids + timestamps as columns, payload as one JSON blob — schema-flexible, index-poor by design),todo,event/event_sequence(durable event log),permission(persisted approvals, v2),project,project_directory,workspace. - IDs are monotonic ULID-like strings (
msg…,prt…, hex-time prefix + counter,packages/schema/src/identifier.ts) soORDER BY idequals insertion order — this quietly simplifies pagination, part ordering, and merge logic everywhere. - Reads use keyset pagination (
MessageV2.page(), cursor = base64{id, time}) and batched part hydration.
5.2 Message model
packages/schema/src/v1/session.ts: Info = User | Assistant (role-discriminated); Part is a 12-variant union: text, reasoning, file, tool, step-start, step-finish, snapshot, patch, agent, subtask, retry, compaction. Tool state is a status-discriminated union pending → running → completed | error with time{start,end,compacted?} — time.compacted marks a completed tool result whose output was pruned. Assistant carries parentID (its user message), cost, tokens{input,output,reasoning,cache{read,write}}, finish, error (a typed union of named errors: AbortedError | APIError | AuthError | ContextOverflowError | OutputLengthError | ContentFilterError | UnknownError).
The v2 model (packages/schema/src/session-message.ts) flattens this to a message union (User | Assistant | Shell | Compaction | AgentSwitched | ModelSwitched | …) with assistant content inline — evidence the team found message+parts too granular in practice.
5.3 Snapshots and revert — the git shadow repository
packages/opencode/src/snapshot/index.ts is one of OpenCode's best ideas:
- A separate git dir per project/worktree at
~/.local/share/opencode/snapshot/<projectID>/<hash(worktree)>, operated asgit --git-dir <shadow> --work-tree <real>. The user's repo is never touched — no commits, no index changes, invisible togit status. - Performance: the shadow repo's
objects/info/alternatespoints at the real repo's object DB and the real index is copied on seed — "on huge repos like chromium …git add --allrebuilding the hashes can take minutes. By doing this we eliminate this at all." track()=git add --all(candidates computed fromdiff-files+ untracked, ignoring >2 MiB untracked files) +git write-tree→ a tree hash stored instep-start/step-finish/patchparts.restore(hash)=read-tree+checkout-index -a -f;revert(patches)= per-filegit checkout <hash> -- <file>with existence-aware deletion.- Session revert (
packages/opencode/src/session/revert.ts): pick a message boundary → restore files from accumulated patch parts → session marked reverted but messages intact (fully undoable viaunrevert) → only when the user prompts past the revert are trailing messages destructively removed.
5.4 Configuration
opencode.json[c]global (~/.config/opencode/) + project (walk-up from cwd to worktree, nearest wins), plus.opencode/dirs, env (OPENCODE_CONFIG,OPENCODE_CONFIG_CONTENT,OPENCODE_PERMISSION), remote/org/MDM layers — ten merge stages inpackages/opencode/src/config/config.ts(deep-merge via remeda, arrays forinstructionsset-unioned).{env:VAR}and{file:path}substitution in any config value (config/variable.ts). Everything validated by Effect Schema (not zod). Writes preserve JSONC formatting viajsonc-parseredits;$schemaauto-injected.- TUI concerns (keybinds, theme) are deliberately quarantined in
tui.json— engine config stays client-agnostic. - Instructions files:
AGENTS.md(plusCLAUDE.mdcompatibility unless disabled) — global first-hit, then first match walking up from cwd to worktree ("The first project-level match wins so we don't stack AGENTS.md/CLAUDE.md from every ancestor"), plusconfig.instructionsglobs/URLs (packages/opencode/src/session/instruction.ts). Two clever behaviors: (1) lazy injection — reading a file under a directory with its own AGENTS.md appends that file's instructions to the tool output as a<system-reminder>, deduped per assistant message; (2) the v2SystemContextlayer diffs instruction changes mid-session and issues explicit "these instructions replace…" deltas.
6. Tools
6.1 Design philosophy: few tools, tiny schemas, rich outputs
The core registry (packages/opencode/src/tool/registry.ts) wires: bash(shell), read, write, edit, apply_patch, grep, glob, task, todowrite, question, skill, webfetch, websearch, lsp, plan_exit (+ MCP/plugin tools). Schemas are deliberately minimal — evidence:
edit: 4 parameters (filePath,oldString,newString,replaceAll?) —tool/edit.ts:47-56.read: 3 (filePath,offset?,limit?);grep: 3 (pattern,path?,include?);glob: 2;write: 2;bash: 3 (command,timeout?,workdir?— "Use this instead of 'cd' commands",tool/shell/prompt.ts).- Long guidance lives in the description text files (
edit.txt,read.txt,shell.txt, …), not in parameter complexity. Descriptions can even vary by shell (PowerShell vs bash chaining notes are templated intoshell.txt). Tool.define(tool/tool.ts) wraps every tool with: schema decode → typedInvalidArgumentsErrorwhose message is model-facing repair prose ("Please rewrite the input so it satisfies the expected schema"), automatic output truncation with file spill (Truncate.output— oversized output goes to~/.local/share/opencode/tool-output/and the model is told to Read/Grep it), and tracing spans.- Tool context (
Tool.Context) exposesask()(permission),metadata()(streamed progress metadata for the UI),abortsignal, and the session messages — tools are UI-aware without owning rendering.
6.2 The edit tool — nine-stage replacer cascade
packages/opencode/src/tool/edit.ts (approaches credited in-file to Cline and gemini-cli). replace() (line 682) runs a generator-based Replacer cascade, first match wins:
SimpleReplacer— exact string.LineTrimmedReplacer— line-by-line comparison with trimmed whitespace.BlockAnchorReplacer— first/last lines as anchors (≥3 lines), middle lines matched by Levenshtein similarity ≥ 0.65, block-size tolerance ±25%, best-of-multiple-candidates.WhitespaceNormalizedReplacer— all whitespace collapsed.IndentationFlexibleReplacer— common leading indentation removed.EscapeNormalizedReplacer— unescapes\n,\t,\"… (LLMs over-escape).TrimmedBoundaryReplacer— trimmed-boundary match.ContextAwareReplacer— anchor lines + ≥50% middle-line match.MultiOccurrenceReplacer— all exact occurrences (forreplaceAll).
Guards: uniqueness required unless replaceAll (index !== lastIndex → continue); isDisproportionateMatch() refuses fuzzy matches much larger than oldString ("Re-read the file and provide the full exact oldString"); distinct error messages for not found vs ambiguous. Around the replacement: CRLF detection and preservation (detectLineEnding/convertToLineEnding), BOM preservation (Bom.split/join/syncFile), per-file semaphore locks, atomic-ish write + auto-format hook (Format.Service), a unified diff computed and attached to both permission request metadata and tool metadata, and LSP diagnostics appended to the tool output ("LSP errors detected in this file, please fix: …") — the model gets type errors in the same turn as the edit.
6.3 read / grep / glob / write / bash
read(tool/read.ts): 2000-line default, 50 KB byte cap, per-line 2000-char truncation, streaming line reader that stops at the cap; binary sniffing (extension list + non-printable ratio on a 4 KB sample); images/PDFs returned as real attachments; directories readable by the same tool (entry listing); "Did you mean" suggestions on miss (miss()lists up to 3 near-name files); output wrapped in<path>/<type>/<content>tags with(Showing lines X–Y of Z. Use offset=N to continue.)continuation hints; background LSP warm-up on read.grep/glob(tool/grep.ts,tool/glob.ts): thin wrappers over a bundled ripgrep service, hard limit 100 results, structured concise output with explicit truncation notices ("Consider using a more specific path or pattern") — never dumps thousands of lines into context.write(tool/write.ts): full-file write; diff computed against existing content for the permission request; BOM/format preservation; LSP diagnostics for the file and up to 5 other affected files.bash(tool/shell.ts, 645 lines): commands are parsed with tree-sitter (bash + PowerShell WASM grammars) before execution — for permission patterns (below) and for detecting filesystem-verb arguments escaping the workspace (external_directorychecks). Cross-shell support is real (bash/zsh/PowerShell 5/7/cmd with per-shell prompt guidance). Output beyond line/byte limits spills to a file with instructions to Read/Grep it.
6.4 What KHAELOR's spec calls process
OpenCode does not expose a first-class process.start/list/read/write/stop tool to the model. It has: a PTY subsystem (packages/core/src/pty, server routes groups/pty.ts) used by clients (desktop/web terminals), background jobs for subagents (packages/opencode/src/background/job.ts), and shell-mode/! for user-run commands. Long-running dev servers under model control are a gap — KHAELOR's planned process tool goes beyond OpenCode here.
7. Context management
- Budget:
usable()= model input limit minus a reserved compaction buffer (COMPACTION_BUFFER = 20_000or configuredcompaction.reserved),isOverflow()compares real usage (input+output+cache tokens from provider metadata) against it (packages/opencode/src/session/overflow.ts). - Compaction is part of the loop, not a side process: when a step finishes in overflow, the processor sets
needsCompaction, the stream is cut (Stream.takeUntil), and the loop persists acompactiontask part; the next iteration runscompaction.process()— a hiddencompactionagent generates a summary assistant message (summary: true) with its own prompt (agent/prompt/compaction.txt); subsequent context building readsfilterCompactedEffect(messages after the last summary + the summary itself). A configurable recent tail is preserved verbatim (25% of usable, clamped 2k–8k tokens,MIN/MAX_PRESERVE_RECENT_TOKENSinsession/compaction.ts). - Pruning is a second, cheaper mechanism:
compaction.prune()walks backwards protecting the newestPRUNE_PROTECT = 40_000tokens of tool outputs, then blanks older tool outputs (markingtime.compacted, rendered to the model as"[Old tool result content cleared]") if at leastPRUNE_MINIMUM = 20_000tokens are reclaimable;skilloutputs are never pruned. Old tool noise disappears without paying an LLM summarization pass. - System prompt assembly per step (
prompt.ts:1257-1269): environment info, instruction files, MCP instructions, skills — all as separate system blocks; the model-family prompt fromSystemPrompt.provider(). - Prompt caching:
packages/opencode/src/provider/transform.tsappliescacheControl: {type: "ephemeral"}breakpoints for Anthropic-family models (transform.ts:362-380) — cache hits/writes then show up in the real token accounting.
8. Permission system and UX
8.1 Model
- Rules are
{permission, pattern, action}triples; evaluation = last matching rule wins with wildcard matching on both fields; unmatched default isask(packages/opencode/src/permission/index.ts:evaluate()is a 4-linefindLast).merge()is array concatenation — later rulesets override by position. Config key order is preserved (propertyOrder: "original") so users control precedence by ordering. - Permission keys are capability-ish tool families:
read, edit, bash, task, external_directory, webfetch, websearch, question, doom_loop, skill, todowrite, glob, grep, lsp— notewrite/apply_patchmap ontoedit, and MCP resource tools ontoread, so policy is written against capabilities rather than tool names. - Sensible defaults (
Agent.fromConfigdefaults inagent/agent.ts:119-136):"*": "allow"butread: {"*.env": "ask", "*.env.example": "allow"},external_directory: {"*": "ask"},doom_loop: "ask",question: "deny"(enabled per-agent).
8.2 Bash gets special treatment — tree-sitter + arity
tool/shell.ts parses the command AST and issues a permission request whose patterns are the exact command texts and whose always suggestions come from BashArity.prefix() (permission/arity.ts) — a generated dictionary of how many tokens constitute a meaningful command prefix (git: 2, git config: 3, npm run: 3, docker compose: 3…). So running git push origin main offers "always allow git push *" — precise, human-meaningful generalization instead of all-or-nothing. Filesystem verbs (rm/cp/mv/… + PowerShell/cmd equivalents) additionally trigger external_directory checks when arguments resolve outside the workspace.
8.3 Flow and UX
- Tools call
ctx.ask({permission, patterns, always, metadata}); the service evaluates againstmerge(agent.permission, session.permission);denythrows immediately (typedDeniedError),allowpasses,askparks aDeferredand publishespermission.askedover the bus (permission/index.ts:67-107). - The TUI renders an inline panel in the session view (
packages/tui/src/routes/session/permission.tsx), with metadata-driven bodies — an edit permission shows the actual diff (via<diff>), bash shows the command,doom_loopgets special copy. Replies: once / always / reject; rejecting in a subagent context opens a feedback textarea whose text is delivered to the model as a typedCorrectedError("the user said no, and here is why") — rejection becomes steering, not a dead end. alwaysapprovals are session-scoped in-memory in v1 (per docs: "for the rest of the current OpenCode session"); durable per-project persistence exists only in the v2permissiontable (packages/core/src/permission/saved.ts). Grantingalwaysauto-resolves other pending requests that now evaluate to allow; rejecting one rejects all pending requests in the session.- Deny rules also shape the tool list (
Permission.disabled), and non-interactiveopencode runinjects denies forquestion/plan_enter/plan_exit.
9. Performance engineering
What makes OpenCode feel fast even when inference is slow — all verified:
- Delta-only streaming end to end: provider delta →
updatePartDeltaevent → SSE/RPC →produce()append in the Solid store → single<markdown>node repaint. No message re-render, no layout thrash. - 16 ms event coalescing +
batch()at the client boundary (context/sdk.tsx) — one render per frame regardless of event rate. - Sorted arrays + binary search for store updates;
reconcile/producekeep Solid subscriptions stable. - Bounded session data (100-message cap with part GC) instead of virtualization complexity.
- In-process worker RPC instead of sockets for the default path; UI and engine on separate threads.
- SQLite/WAL with incremental counters — cost/token totals maintained by delta at part-write time, never recomputed by scanning.
- Shadow-git snapshots with object alternates + copied index — checkpointing is near-free even on huge repos.
- ripgrep for all search, hard result limits everywhere, tool-output spill-to-file with model-side pagination.
- Prompt-cache breakpoints for Anthropic (ephemeral cacheControl) plus a session-scoped
promptCacheKey(provider/transform.ts) — real latency/cost reduction on every step. The models.dev catalog is disk-cached with a cross-process flock and inlined into the compiled binary at build time (OPENCODE_MODELS_DEVdefine) so startup never blocks on the network. - TUI micro-craft: theme
SyntaxStyledestruction deferred torenderer.idle(); palette prewarm before first paint to avoid theme flash; startup loader appears only after 500 ms (then holds ≥3 s to avoid flicker); FPS dropped 60→30 while a decorative animation is mounted; autocomplete returns the previous options while loading to avoid list flicker; spinner degrades to a static glyph when animations are disabled. - Startup discipline: the CLI ships as a
bun build --compilebinary (OPENCODE_WORKER_PATHcompiled in, bunfig/dotenv autoload disabled), with a codified lazy-import rule (rootAGENTS.md:64) andlazy()memo helpers (packages/opencode/src/util/lazy.ts) keeping heavy modules (provider SDK packages — 23 bundled as lazy thunks, othersnpm installed at runtime withignoreScripts: true— tree-sitter, LSP) off the cold-start path. The TUI itself uses no dynamicimport(); "lazy" there means lazy data (per-session hydration,createResource), not lazy modules.
Known warts, admitted in-source: several setTimeout(0) + markDirty() + requestRender() sequences in the composer labeled "workaround… needs to be addressed properly" (component/prompt/index.tsx:241-247, 1217-1221).
10. Testing and quality signals
- Regression tests exist precisely where races live:
test/cli/tui/prompt-submit-race.test.ts(double-Enter phantom prompt),test/cli/cmd/tui/sync-live-hydration.test.tsx(SSE vs REST hydration race),test/permission/arity.test.ts,test/agent/plan-mode-subagent-bypass.test.ts(plan-mode subagents can still edit). - Comments frequently encode why: dropped orphan reasoning deltas, ConPTY paste normalization, IME flush timing, Windows console modes via FFI. The codebase reads like several years of accumulated terminal-reality scar tissue — this is exactly the knowledge KHAELOR should mine.
WHAT OPENCODE DOES VERY WELL
- Event-sourced session state. Durable typed events + atomic projectors (
packages/core/src/event.ts,session/projector.ts) make the DB, the TUI, remote clients, and share/replay all consumers of one stream. Persistence and streaming are literally the same write. - A loop that derives its next action from persisted state.
SessionPrompt.runLoopre-reads messages each iteration; compaction and subtasks are persisted parts, exits are derived from message state — crash-safe, resumable, and steerable by construction. - Permissions as data, evaluated in four lines.
findLast(wildcard-match)over rule arrays, capability-style keys (writefolds intoedit), tool visibility derived from the same rules, and agents/modes as permission policies rather than parallel agent implementations. - The bash permission bridge. Tree-sitter parsing + the arity dictionary turn "always allow" into precise, human-meaningful patterns (
git push *), and catch filesystem escapes (external_directory) before execution. - The edit replacer cascade — nine matching strategies with uniqueness and disproportionate-match guards, CRLF/BOM preservation, diff in the permission prompt, and LSP diagnostics fed back in the tool result. Model-facing error messages are written as repair instructions.
- Shadow-git snapshots. Zero-pollution checkpointing of the working tree (alternates + copied index for O(1) seeding on huge repos), powering per-step patch parts, revert/unrevert, and the diff viewer.
- Streaming render economics. Delta events → Solid fine-grained updates → single-node repaints, with 16 ms coalescing and
batch(). The TUI stays at 60 fps during full-speed token streams. - Composer engineering. Extmark-backed structured prompt parts (mentions, collapsed pastes), frecency-ranked server-side file search, cursor-aware history, stash, external-editor round-trip, IME and paste correctness. This is the best terminal input widget in any agent surveyed.
- One command registry feeding keybindings, command palette, and slash commands, with a mode stack that makes dialog input capture automatic.
- Tool schema minimalism with rich prose. 2–4 parameters per tool; guidance in description text; oversized output spilled to files the model can Read/Grep; explicit truncation/continuation hints in every output.
- Terminal-reality hardening: kitty keyboard, terminal-palette-derived "system" theme, live dark/light detection, win32 FFI console fixes, suspend/resume, selection-safe dialogs, copy-on-select.
WHAT OPENCODE DOES POORLY
- Two coexisting architectures. v1 and v2 session/permission/agent/config systems live side by side (
packages/core/src/v1/**vspackages/core/src/{session,permission,...}), with bridges (EventV2Bridge,event-v2-bridge.ts) and duplicated schemas. Every subsystem must be read twice to know what is live. A migration is understandable; shipping both indefinitely is architectural debt. - Sheer scale and surface area. ~30 packages, web/desktop/console/enterprise/slack/stats alongside the CLI; the session route component is 2725 lines; config merging has ten layers including remote org configs and MDM. The essential terminal agent is maybe 20% of the repo.
- Effect as a hard prerequisite. Effect 4 (beta) generators, layers,
Deferred,PubSubpervade everything. It buys real structured concurrency, but the abstraction tax is high and it pins the project to a fast-moving beta dependency (plus a patched solid-js, patched effect, 15+ patched deps in rootpackage.json). - "Always allow" is not durable in the live path — v1 approvals are in-memory per session; durable persistence exists only in the not-yet-primary v2 tables. Users re-approve across restarts.
- No model-facing process manager. PTY/background-job infrastructure exists, but the model cannot start/inspect/stop long-running processes as a first-class tool — dev-server workflows degrade to blocking bash or user-side shell mode.
- Message+parts granularity backfired. The v2 schema flattening (message unions with inline content) is implicit admission that the v1 part explosion (12 part types, part-level events, part GC in the client) cost more than it returned.
- Provider-generality tax everywhere. Model-family prompt switching (
anthropic.txt,gpt.txt,beast.txt…), per-provider transforms, patched AI-SDK forks — necessary for their business, but it spreads conditional complexity through the model layer. - Some UI state is fragile by admission — setTimeout-based layout workarounds in the composer, a 50 ms polling interval to anchor the autocomplete popup, dead prompt files (
plan-reminder-anthropic.txtunreferenced). - No virtualized history: the 100-message cap is pragmatic but means long sessions silently drop scrollback from the UI (data remains in SQLite, but the user can't scroll to it).
WHAT KHAELOR SHOULD ADOPT
- The event-sourced spine, simplified. Typed events as the single source of truth; persistence as an atomic projection of durable events; UI and session store fed from the same stream. This directly implements KHAELOR's §7 typed event bus and makes replay/resume free. Use SQLite/WAL with incremental usage counters and ULID-style monotonic IDs.
- State-derived agent loop. Kernel iterations re-derive the next action from persisted session state; compaction and steering are enqueued as persisted items, not in-memory control flow. Keep the stream reducer (
SessionProcessorequivalent) separate from the loop (runLoopequivalent) — that is the small kernel. - The edit replacer cascade wholesale (it is MIT, sourced from Cline/gemini-cli lineage): all nine strategies, the uniqueness and disproportionate-match guards, CRLF/BOM handling, and model-facing repair-prose errors. Add LSP-style diagnostics feedback later.
- Tool design discipline: ≤4 parameters, guidance in descriptions, output caps with spill-to-file + Read/Grep continuation,
<path>/<content>structured outputs, "did you mean" on file miss,workdirparameter instead ofcd. - Bash permission parsing: tree-sitter command extraction + an arity table for "always allow
git push *" suggestions, and external-directory detection on filesystem verbs. This is the difference between a permission system users tolerate and one they like. - Modes as permission policies + reminder injection, not separate agents: KHAELOR's capability policies (§13) can express plan/build exactly as OpenCode does, including deriving tool visibility from deny rules. Agent-as-message-field makes mid-session switching trivial.
- Shadow-git snapshots for baseline protection (§16: "record baseline state before edits") and per-step diffs — including the alternates + copied-index seeding trick.
- Composer techniques: extmark-style structured prompt parts, collapsed paste placeholders expanded at submit, frecency-ranked file mentions, cursor-position-aware history, stash. Also the single command registry powering keys + palette + slash commands, and the keymap mode stack for dialogs.
- Client performance recipe: delta-only part updates, ~16 ms event coalescing into batched store updates, previous-result retention during async filtering, palette prewarm before first paint, deferred destruction of styling resources.
- Interruption semantics: abort marks the assistant message; bounded grace for in-flight tools; interrupted tool calls converted to synthetic error results so the Anthropic conversation never contains dangling
tool_useblocks; rejection-with-feedback (CorrectedError) turning permission denials into steering. - Doom-loop detection (3 identical consecutive tool calls → ask the user) — cheap, effective, honest.
- In-process "server": even Anthropic-only and single-client, keeping the kernel behind an internal typed API/event boundary (as OpenCode does with worker RPC) preserves KHAELOR's clean layering without daemon complexity.
WHAT KHAELOR SHOULD NOT COPY
- The dual v1/v2 architecture. Design the event/message schema once, version it from day one (
{durable, version}on event definitions is worth copying), and never ship two parallel session systems. - The Effect framework dependency. KHAELOR needs structured concurrency, cancellation, and typed services — achievable with plain TypeScript (AbortController trees, small service containers, async iterators) without pinning the whole codebase to a beta ecosystem and a fleet of patched packages. Adopt the patterns (layered services, explicit deps, deferred completion), not the framework.
- Multi-provider scaffolding: model-family prompt files, provider transform matrices, AI-SDK indirection. KHAELOR V1 is Anthropic-only behind a single
ModelClient— one prompt, one streaming protocol, native prompt-caching and thinking support. - The monorepo sprawl (web/desktop/console/enterprise/plugins/slots). KHAELOR is one CLI. Even OpenCode's TUI plugin-slot system, elegant as it is, is premature for V1.
- 12-part message granularity with part-level GC. Follow the direction of OpenCode's own v2 correction: fewer, flatter message shapes; keep tool state as one status-discriminated object.
- The 100-message UI cap as the only scrollback strategy — KHAELOR should either virtualize or page older history into view on demand rather than silently truncating.
- In-memory-only "always allow" — persist scoped approvals (project-scoped, pattern-based) from V1, as OpenCode's v2 tables belatedly do.
- Ten-layer config merging (remote org configs, MDM, well-known endpoints). KHAELOR's hierarchy is four layers (CLI → project → user → env → defaults); keep it that way.
- setTimeout-based layout patching and polling anchors in the composer — budget real fixes for editor/layout interaction rather than accreting workarounds.
- Skipping a model-facing process manager. OpenCode's biggest tool-level gap is KHAELOR's planned differentiator (
process.start/list/read/write/stop) — do not inherit the omission.