English lecture · source-traced

opencode source analysis: how an open-source AI coding agent is built

**Subject**: opencode (`anomalyco/opencode`), version 1.18.4 **Based on commit**: `62e4641235d7847dadc60da37cca8a023dd54fc1` (2026-07-23, message `chore: generate`) **Analysis date**: 2026-07-24 **Size**: monorepo, about 1755 `.ts` + 588 `.tsx` files (under `packages/`, excluding tests and node_modules) **Audience**: a computer-science beginner. This lecture uses many analogies and gives real source locations (`file:

EN
lecture
source
file:line
no
embellishment
中文
sibling

Subject: opencode (anomalyco/opencode), version 1.18.4 Based on commit: 62e4641235d7847dadc60da37cca8a023dd54fc1 (2026-07-23, message chore: generate) Analysis date: 2026-07-24 Size: monorepo, about 1755 .ts + 588 .tsx files (under packages/, excluding tests and node_modules) Audience: a computer-science beginner. This lecture uses many analogies and gives real source locations (file:line) so you can follow the map.


🗺️ Companion architecture diagram: this project has a dedicated figure in the seven-agent architecture gallery—star topology (why many clients come for free). Every gallery figure first states “what question it answers” and “the load-bearing claim,” then goes through three review and render-acceptance rounds.

Part 1

Chapter 1 · Project overview

What is opencode? One sentence from the README: "The open source AI coding agent". You type opencode in the terminal, enter a polished terminal UI (TUI), and in natural language ask it to read code, change code, run commands, look things up.

Who builds it? GitHub org anomalyco (Anomaly, the SST/Serverless Stack team—you can see sst.config.ts in the repo). The project is very active, community-driven, MIT (LICENSE); the README is translated into 25 languages.

Positioning and selling points:

  • Provider-agnostic (model freedom): unlike Claude Code bound to Anthropic, opencode supports dozens of model providers through the models.dev catalog (OpenAI, Anthropic, Google, xAI, GitHub Copilot, Bedrock, Vertex, Kimi, DeepSeek……), and you can even log in with your ChatGPT Plus / Claude Pro / Copilot subscription.
  • Client/server architecture: opencode itself is an HTTP server; the TUI is only one of many clients. There is also a desktop App (BETA), a Web UI, Zed (ACP protocol), GitHub Action, Slack bot.
  • Terminal experience first: the TUI is extremely polished (themes, keybindings, command palette, sub-agent navigation)—the part that went viral.
  • 100% open source: server, TUI, SDK, plugin system, all open.

Stack / runtime: TypeScript, running on Bun (see the style guide in AGENTS.md: "Use Bun APIs when possible, like Bun.file()"). Monorepo with bun workspaces + turbo. Two dependencies especially worth noticing:

  • Effect (the effect library): a “functional, with dependency injection and structured concurrency” TypeScript programming framework; the entire server is Effect.gen, Layer.effect style. This is the project’s hardest selection—high beginner barrier, in exchange for testability and concurrency safety.
  • Vercel AI SDK (ai package): talks to each vendor’s model API; and for OpenAI/Anthropic/opencode the three have a home-grown “native runtime” (session/llm/native-runtime.ts:55-59).

The TUI uses OpenTUI (@opentui/core) + SolidJS rendering (packages/tui/package.json:55-66). Storage is SQLite + Drizzle ORM. Bash command parsing uses tree-sitter (a real syntax tree, not regex).

Vs Claude Code (one-sentence edition): both are “an agent loop + tool use in the terminal,” but Claude Code is Anthropic’s official closed-source single-model product; opencode is the open-source, model-switchable, multi-client community substitute, and it is compatible with reading Claude Code’s CLAUDE.md and .claude/skills.

flowchart LR
    subgraph 用户感知
        A["opencode
开源 AI 编程 agent"] end A --> B["模型自由
几十家 provider"] A --> C["多客户端
TUI/桌面/Web/Zed/GitHub"] A --> D["生态开放
插件/MCP/技能/自定义工具"] E["Claude Code"] -.对比.-> A E -.-> F["官方、单家模型、闭源"]

Part 2

Chapter 2 · Panorama

If opencode is a restaurant: the TUI is the front-of-house waiter (receives you), the HTTP server is the kitchen (actually cooks), the agent loop is the head chef (decides what dish is next), tools are the cookware, the model API is the “decision brain” that arrived as takeout—every step the head chef asks the brain “what next?”, the brain says “use the read tool on that file,” the kitchen does it and sends the result back to the brain.

Key directories (all under packages/):

Layer Directory Duty
UI tui/ terminal UI (SolidJS + OpenTUI)
UI desktop/, app/, web/ desktop App, mobile/other frontends
Client SDK sdk/, sdk-next/, client/ generated API clients
Server opencode/src/server/ HTTP API (Effect HttpApi)
Core loop opencode/src/session/ sessions, agent loop, compaction, prompts
Tools opencode/src/tool/ all built-in tools
Model comms opencode/src/session/llm/, llm/ AI SDK adaptation + native runtime
Ecosystem opencode/src/mcp/, plugin/, skill/, command/ MCP, plugins, skills, commands
Infrastructure core/, schema/, protocol/ shared core (including the next-gen V2 session kernel)
flowchart TB
    subgraph 客户端层
        TUI["packages/tui
终端界面"] WEB["packages/web"] APP["packages/desktop, app"] ACP["acp/
Zed 编辑器"] end subgraph SDK层 SDK["@opencode-ai/sdk
HTTP 客户端"] end subgraph 服务器["opencode 服务器(Bun 进程/Worker)"] HTTP["server/routes/instance/httpapi
REST + SSE 事件"] LOOP["session/prompt.ts
Agent 主循环"] PROC["session/processor.ts
流式事件处理"] TOOLS["tool/registry.ts
工具注册表"] PERM["permission/index.ts
权限仲裁"] MCP["mcp/index.ts"] STORE["SQLite (Drizzle)
会话持久化"] end subgraph 外部 LLM["模型 API
OpenAI/Anthropic/..."] MCPS["MCP 服务器"] end TUI --> SDK --> HTTP --> LOOP --> PROC PROC --> TOOLS TOOLS --> PERM TOOLS --> MCP --> MCPS PROC -->|"llm.stream"| LLM LOOP --> STORE

Note one fact: TUI and server are not in the same logical layer. By default the server runs in a Worker thread of the same Bun process (Chapter 3 details this), but the communication protocol is identical to remote mode—that is why opencode natively supports many clients.


Part 3

Chapter 3 · Startup

What happens after you type opencode in the terminal?

Step one: CLI entry. packages/opencode/src/index.ts uses yargs to register a pile of subcommands (run, serve, web, acp, github, mcp, models, providers, upgrade, stats……). Note the TuiThreadCommand imported on line 22:

// packages/opencode/src/cli/cmd/tui.ts:73
export const TuiThreadCommand = cmd({
  command: "$0 [project]",
  describe: "start opencode tui",

$0 is yargs’s “default command”—with no subcommand, start the TUI. Middleware also sets three environment variables (index.ts:75-77): AGENT=1, OPENCODE=1, OPENCODE_PID—so shell commands the agent runs can sense “I am inside opencode.”

Step two: routing. --mini takes the lightweight interactive UI; --port/--hostname enter “external server” mode; the default path is the important one (cli/cmd/tui.ts:210-247):

// packages/opencode/src/cli/cmd/tui.ts:210
const worker = new Worker(file, { env: ... })
const client = Rpc.client<typeof rpc>(worker)

The server is not a separate process; it is a Bun Worker thread. The TUI thread calls the worker over RPC, then wraps RPC as a fake fetch (createWorkerFetch, tui.ts:27-43), with URL written http://opencode.internal (tui.ts:244). That is: even “local direct connect” walks the identical HTTP protocol as remote—a very elegant architectural decision; the client never needs to care where the server is.

Step three: session restore and render. Validate that the --session exists (validateSession), asynchronously check for upgrades 1 second later (tui.ts:265-267), then Effect.runPromise(run({...})) starts the TUI’s SolidJS render tree (tui.ts:271-291), passing --continue, --agent, --model, --prompt, etc. to the UI.

Trust / authorization: unlike Claude Code’s “workspace trust popup,” no directory-trust confirmation at startup was found. opencode’s trust model is “authorize one by one at runtime”: dangerous tool calls (write files, run commands, access directories outside the workspace) pop a permission ask at execution time (Chapter 7). Startup flags --auto / --yolo / --dangerously-skip-permissions (tui.ts:104-119; note the last two are hidden: true) are “I want everything” mode, auto-approving permissions not explicitly denied.

sequenceDiagram
    participant U as 用户 (终端)
    participant CLI as src/index.ts (yargs)
    participant TC as cli/cmd/tui.ts
    participant W as Worker 线程 (HTTP 服务器)
    participant T as TUI (SolidJS)
    U->>CLI: opencode
    CLI->>TC: 默认命令 $0
    TC->>W: new Worker(服务器)
    TC->>TC: RPC 包装成 fetch("http://opencode.internal")
    TC->>W: validateSession / 检查升级
    TC->>T: Effect.runPromise 启动渲染
    T->>W: HTTP 请求(经内存 fetch 桥)
    W-->>T: SSE 事件流(消息/权限询问)

Part 4

Chapter 4 · Input capture and routing

How does the terminal UI take input? The TUI input box is OpenTUI’s TextareaRenderable (imports in packages/tui/src/component/prompt/index.tsx:1-9), a rich-text box that supports multiline, pasted images, undo history, autocomplete. Keybindings are concentrated in packages/tui/src/config/keybind.ts (e.g. session_interrupt: keybind("escape", ...) at keybind.ts:97).

How is Enter routed? The core is submitInner() (prompt/index.tsx:946). It is a “parcel sorter,” splitting input into three packages:

// packages/tui/src/component/prompt/index.tsx:1058-1120(精简)
if (store.mode === "shell") {
  void sdk.client.session.shell({ ..., command: inputText })   // ① Shell 模式
} else if (inputText.startsWith("/") && sync.data.command.some(...)) {
  void sdk.client.session.command({ ..., command: command.slice(1), arguments: args })  // ② 斜杠命令
} else {
  sdk.client.session.prompt({ ..., parts: [ { type: "text", text: inputText }, ...] })  // ③ 普通提示词
}

① Shell mode: press ! on an empty input box to enter shell mode (keybind prompt/index.tsx:831-835); the box changes color; on Enter the command does not go through the large model, it calls session.shell directly. The server implementation shellImpl (opencode/src/session/prompt.ts:451) forges a user message (text fixed as "The following tool was executed by the user", prompt.ts:483) plus an assistant shell tool-call record—the point is so the model can later see what commands you ran by hand and their output, keeping context consistent. ESC, or backspace on empty input, exits shell mode (prompt/index.tsx:846,857).

② Slash commands: first character must be / and the command name must exist in the command table (prompt/index.tsx:1071-1072 validates), then parse name and arguments and send to session.command. Server handling in Chapter 10.

③ Ordinary prompt: packed as parts (text + file attachments + editor selections) to session.prompt. A small easter egg: typing exit/quit/:q exits the program (prompt/index.tsx:963).

Three examples covering different cases, so you feel what the routing is for:

  • You type help me see how many ts files are under src → path ③; the model will likely call the glob tool;
  • You press ! then type find src -name "*.ts" | wc -l → path ①; the command runs directly, zero model cost, but the execution record stays in the session, so later you can ask “how many did we just count?” and the model can answer;
  • You type /commit go ahead and commit the changes → path ②; the server concatenates the commit.md template with the argument “go ahead and commit the changes” into a prompt, then walks path ③ to the model.

You can see: three entries eventually converge on “become part of session history”; the difference is only who touches this input first: the terminal executing directly, the template engine expanding, or handing it to the model as-is.

Queuing (QUEUED): if the agent is busy, you can still send a message. The server promptAsync handler (server/routes/instance/httpapi/handlers/session.ts:311-328) forks it to the background: the user message is persisted immediately; the agent loop will naturally read it at the current turn’s safe boundary (mechanism in Chapter 6). On the TUI these messages show a QUEUED badge (routes/session/index.tsx:1373,1436: a message whose id is greater than “the assistant message currently being processed” is queued).

flowchart TD
    A["用户在 Textarea 输入并回车"] --> B{"mode 或首字符?"}
    B -->|"shell 模式 (! 进入)"| C["session.shell
直接执行命令,不问模型"] B -->|"'/' + 已注册命令"| D["session.command
模板展开后转提示词"] B -->|其他| E["session.prompt
正常走 agent 循环"] C --> F["伪造 user 消息 + shell 工具记录
让模型后续可见"] D --> G["$ARGUMENTS/$1 替换、!`cmd` 预执行、@file 展开"] E --> H{"agent 忙?"} H -->|忙| I["消息落库,显示 QUEUED
下一轮循环自动接管"] H -->|闲| J["立即启动主循环"]

Part 5

Chapter 5 · Context assembly

Before sending a request to the model, opencode has to “plate a dish”—the system prompt. The dish is assembled in two layers:

Layer one: session-layer ingredients (session/prompt.ts:1257-1269):

const [skills, env, instructions, mcpInstructions, modelMsgs] = yield* Effect.all([
  sys.skills(agent),
  sys.environment(model),
  instruction.system().pipe(Effect.orDie),
  sys.mcp(agent, session.permission),
  MessageV2.toModelMessagesEffect(msgs, model),
])
const system = [
  ...env,
  ...instructions,
  ...(mcpInstructions ? [mcpInstructions] : []),
  ...(skills ? [skills] : []),
]

Layer two: request-layer “base stock,” then into the pot (session/llm/request.ts:58-66): in front of the ingredients above, add “personality stock”—if the agent brings its own prompt use it, otherwise pick a .txt prompt file by model family:

const system = [
  [
    ...(input.agent.prompt ? [input.agent.prompt] : SystemPrompt.provider(input.model)),
    ...input.system,
    ...(input.user.system ? [input.user.system] : []),
  ].filter((x) => x).join("\n"),
]

Base stock (personality prompts customized by model): session/system.ts:27-42’s provider() is a “look at the dish, pick the plate” dispatcher—model id containing claude uses anthropic.txt, containing gemini- uses gemini.txt, containing gpt uses gpt.txt (containing codex uses codex.txt), containing kimi uses kimi.txt, gpt-4/o1/o3 uses beast.txt…… none match uses default.txt. default.txt opens: "You are opencode, an interactive CLI tool that helps users with software engineering tasks", and inside it specifies tone (minimal, fewer than 4 lines), initiative, tool-use norms.

Environment info: system.ts:60-96 generates an <env> block—working directory, workspace root, whether it is a git repo (ctx.project.vcs === "git"), OS platform, today’s date. Note it does not inject git branch, recent commits, and other details—very restrained.

Collecting AGENTS.md / memory files (session/instruction.ts), this is where the project is compatible with Claude Code habits:

// packages/opencode/src/session/instruction.ts:60-68
const globalFiles = [
  path.join(global.config, "AGENTS.md"),
  ...(!flags.disableClaudeCodePrompt ? [path.join(global.home, ".claude", "CLAUDE.md")] : []),
]
const instructionFiles = [
  "AGENTS.md",
  ...(!flags.disableClaudeCodePrompt ? ["CLAUDE.md"] : []),
  "CONTEXT.md", // deprecated
]

Collection order and priority (systemPaths(), instruction.ts:110-153):

  1. Global: ~/.config/opencode/AGENTS.md, otherwise ~/.claude/CLAUDE.md (take only the first that exists, break);
  2. Project-level: from the current directory upward to the workspace root (findUp), in order AGENTS.md → CLAUDE.md → CONTEXT.md, stop at the first matching filename (comment at instruction.ts:122-133: "The first project-level match wins so we don't stack AGENTS.md/CLAUDE.md from every ancestor");
  3. Config append: the instructions field in opencode.json, supporting glob and http(s) URLs (remote fetch with a 5-second timeout, instruction.ts:95-103).

There is also a clever design: discover nearby on demand. When the agent uses the read tool on a file, Instruction.resolve() (instruction.ts:179-221) walks up from that file’s directory looking for nearby AGENTS.md, and attaches the content to the read result—once per message. This simulates “you only see the rules on the wall once you walk into the room.”

MCP instructions and skill catalog: MCP servers’ instructions packed into <mcp_instructions> (system.ts:112-128); the available-skill list is written into the system prompt in a “detailed edition” (system.ts:98-110; the comment explains: models absorb the detailed edition better in the system prompt; the tool description gets the short edition).

String it all together and a system text sent to the model looks roughly like this (schematic): it opens with the “you are opencode……” personality stock, then the <env> environment block, then several Instructions from: /path/to/AGENTS.md plus file body, then <mcp_instructions>, finally the skill catalog. Only then the conversation history (user messages, assistant messages, tool calls and results alternating). For a beginner, grasping the formula “system prompt = stock + environment + project rules + ecosystem notes” is the commonality of all coding-agent context engineering—every product (Claude Code, Cursor, opencode) plates these same things; the difference is only how widely they collect and when they refresh. opencode’s choice is “few and precise”: restrained environment info, layered rule collection, nearby-on-demand supplements, leaving the precious context window for the conversation itself.

flowchart TB
    subgraph 底料
        P["agent.prompt 或
SystemPrompt.provider(model)
anthropic/gpt/kimi/default.txt"] end subgraph 原料 E["env: 目录/git/平台/日期"] I["instructions:
全局 AGENTS.md → 项目 AGENTS.md
→ config.instructions/URL"] M["MCP instructions"] S["Skills 清单"] end P --> J["join('\n') 拼成一段 system 文本"] E --> J I --> J M --> J S --> J J --> K{"OpenAI OAuth?"} K -->|是| L["放进 Responses API 的 instructions 字段"] K -->|否| N["作为 role:system 消息
放在消息列表最前"] R["read 工具触发
就近 AGENTS.md 补充"] -.-> I

Part 6

Chapter 6 · The agent loop (the heart)

This is the most important chapter in the lecture. The agent’s “heart” is packages/opencode/src/session/prompt.ts’s runLoop, line 1081, and the loop body is the while (true) on line 1088.

// packages/opencode/src/session/prompt.ts:1081-1092
const runLoop: (sessionID: SessionID) => Effect.Effect<SessionV1.WithParts> = Effect.fn("SessionPrompt.run")(
  function* (sessionID: SessionID) {
    const ctx = yield* InstanceState.context
    let structured: unknown
    let step = 0
    const session = yield* sessions.get(sessionID).pipe(Effect.orDie)

    while (true) {
      yield* status.set(sessionID, { type: "busy" })
      yield* Effect.logInfo("loop", { "session.id": sessionID, step })

      let msgs = yield* MessageV2.filterCompactedEffect(sessionID).pipe(...)

Is it the textbook “request model → tool_use → execute → append → request again”? Yes, but the implementation is a bit special. opencode uses Vercel AI SDK’s streamText (session/llm.ts:280), and does not pass stopWhen—i.e. default single-step (one provider turn per stream). Multi-turn progress is opencode’s own outer while (true); and tool execution is nested inside the stream (execute as you receive, see below). The repo AGENTS.md states this discipline explicitly: "Preserve one explicit llm.stream(request) call per provider turn".

Each loop iteration does these things:

  1. Re-read history (line 1092): every round re-reads messages from the database—that is why “queued messages” are naturally taken over, and the source of multi-client concurrency safety.
  2. Decide whether to end (lines 1111-1130): the last assistant message’s finish is not "tool-calls", there are no unfinished tool calls, and it is newer than the last user message → break. Note the defense at lines 1103-1109: some providers return "stop" even though they brought tool calls; then continue the loop and feed tool results back.
  3. Handle two kinds of “task”: subtask (sub-agent tasks produced by slash commands, 1144-1147) and compaction (compaction tasks, 1149-1159).
  4. Auto-compaction check (1161-1168): previous-round token usage overflowed → create a compaction task, handle next round.
  5. Assemble the request: create the assistant message shell (1186-1201) → SessionTools.resolve resolves this round’s available tools (1226) → assemble the system prompt (1257-1269, see Chapter 5) → if this is the last step, append MAX_STEPS_PROMPT as an assistant prefill (line 1281; isLastStep is decided by the agent’s steps config, default Infinity).
  6. Hand to the processor to run one provider turn: handle.process({...}) (line 1272).

How is streaming handled? In session/processor.ts:640-646:

const stream = llm.stream(streamInput)
yield* stream.pipe(
  Stream.tap((event) => handleEvent(event)),
  Stream.takeUntil(() => ctx.needsCompaction),
  Stream.runDrain,
)

handleEvent (processor.ts:278) is a large switch: text-start/delta/end incrementally write a text part; reasoning-* write a thinking part; tool-input-* stream in tool arguments; tool-call register a tool call; tool-result complete a tool; step-start/step-finish record a git snapshot and token usage. All parts are persisted in real time; the TUI renders in real time via SSE events.

Is there “execute as you receive”? Yes. Tools do not wait for the whole stream to end: once AI SDK has collected a tool_call’s arguments during the stream, it immediately calls that tool’s execute (wrapped in session/tools.ts:102-133); the result returns as a tool-result event on the same stream; processor.ts:383-413 marks it completed. So in the TUI you see “the model is still emitting text, and the tool is already running.”

Complete set of termination conditions:

  • Normal completion: finish is not tool-calls and there are no unfinished tool calls (prompt.ts:1111-1130);
  • handle.process returns "stop" (processor.ts:680: permission denied and configured not to continue, or the message errored);
  • Returns "compact": mid-stream context overflow (processor.ts:644’s takeUntil + processor.ts:477-482), compact first then continue;
  • Structured output complete (prompt.ts:1288-1293);
  • Content filter (content-filter, prompt.ts:1301-1308);
  • Hit agent.steps cap (MAX_STEPS_PROMPT forces the model to wrap up);
  • Error/interrupt: retry policy exhausted (SessionRetry.policy, processor.ts:660-674) or interrupted by the user.

Interrupt (ESC) mechanism: in the TUI, ESC is double-tap confirm—first press shows "again to interrupt", second press actually calls sdk.client.session.abort (prompt/index.tsx:407-418). Server SessionPrompt.cancel → run-state.ts:77-86 triggers Effect structured interrupt → processor’s Effect.onInterrupt marks the message AbortError (processor.ts:648-655), cleanup() (processor.ts:539-597) marks every still-running tool error: "Tool execution aborted" and metadata.interrupted = true—the next loop round can recognize these “orphans” (prompt.ts:96-100’s isOrphanedInterruptedTool) and not stall.

Easter egg: anti-“ghost wall.” If three consecutive tool calls have identical names and arguments (DOOM_LOOP_THRESHOLD = 3, processor.ts:29,356-380), the system pops a permission ask named doom_loop, letting the user decide whether to let it keep spinning in place.

Walk a complete example of the heart beating: you say “change the install command in the README to brew.” Round 1: the loop finds the last user message has no corresponding assistant reply, so it plates the system prompt, registers tools, starts a streaming request; the model emits text while saying “I will call the read tool”; the instant arguments are complete, read executes (execute as you receive), file content lands as tool-result; the model then outputs “I will call edit.” Round 1 ends with finish "tool-calls", the loop does not exit. Round 2: re-read history (now including the read result), request the model again; after confirming the replace succeeded the model outputs a summary, finish becomes "stop". Round 3: the loop finds “the last assistant is complete and there are no pending tools,” exit condition met, break, session goes idle. Every step’s text, tool calls, and usage are written to SQLite in real time and pushed to the TUI via SSE, so what you see on screen is a typewriter effect and tool-card state flips, not a spinner.

flowchart TD
    S["while (true) 每轮开始"] --> A["重读会话消息
filterCompactedEffect"] A --> B{"最后 assistant 已完成
且无工具调用?"} B -->|是| Z["break,会话转 idle"] B -->|否| C{"有待处理任务?"} C -->|subtask| D["派生子代理执行"] C -->|compaction| E["执行上下文压缩"] C -->|无| F{"上一轮 token 溢出?"} F -->|是| E F -->|否| G["解析工具 + 拼系统提示词
创建 assistant 消息壳"] D --> S E --> S G --> H["processor.process:
llm.stream 流式请求模型"] H --> I{"流式事件"} I -->|text/reasoning delta| J["实时落库 + SSE 推给 TUI"] I -->|tool-call 参数收齐| K["立即执行工具(边收边跑)
权限询问在此发生"] K --> L["tool-result 落库"] I -->|step-finish| M{"token 溢出?"} M -->|是| N["needsCompaction → takeUntil 截断流
返回 compact"] M -->|否| O{"finish 原因"} O -->|tool-calls| S O -->|stop/error| Z N --> S

Also worth knowing: the repo contains a next-generation V2 session kernel (packages/core/src/session/, with the design document CONTEXT.md), introducing a more rigorous model of “persistent inbox + steer/queue two delivery semantics + Context Epoch,” currently evolving in parallel with V1. That shows the team has thought about “the boundary between loop and input” more deeply than most peers.


Part 7

Chapter 7 · Tools and permissions

Tool definition structure (tool/tool.ts:55-65): each tool is a Tool.Def—id, description (read from a same-named .txt file, e.g. read.txt), parameters (Effect Schema), execute(args, ctx) returning { title, metadata, output, attachments }. Tool.define does two unifying things: on parameter-decode failure throw a “please rewrite the parameters” error for the model to see (tool.ts:24-34), and output truncation (truncate.ts:15-16: 2000 lines / 50KB, excess written to a temp file and the path told to the model).

All built-in tools (registration order in tool/registry.ts:226-244):

Group Tool id Function Key parameters Example
File read read read file/directory, default 2000 lines, line-number prefix filePath, offset, limit read src/index.ts from line 500
File read glob find files by glob pattern, path? src/**/*.tsx
File read grep regex search content (ripgrep) pattern, path?, include? log.*Error
File edit edit exact string replace, must Read first filePath, oldString, newString, replaceAll? rename a function
File edit write whole-file overwrite; old file must be Read first filePath, content create a component
File edit apply_patch patch-format edit (GPT-family models only, registry.ts:292-295) patch text *** Begin Patch
Exec shell (bash) run a terminal command command, timeout?, workdir? npm test
Exec task spawn a sub-agent description, prompt, subagent_type, task_id?, background? dispatch explore to look at code
Exec execute (experimental) code-mode, experimental switch code batch-call MCP
Plan todowrite maintain a todo list todos[] list implementation steps
Plan plan_exit (experimental) exit plan mode and ask the user whether to start none plan agent wrap-up
User interaction question ask the user mid-execution (only app/cli/desktop clients, registry.ts:202) questions[] “which plan?”
Network webfetch fetch a URL to markdown/text/html url, format?, timeout? read a docs page
Network search (websearch) web search (opencode official provider only, or exa/parallel experimental switches, registry.ts:58-60) query etc. look up latest news
Network list_mcp_resources and 2 more dynamically registered when an MCP server declares resources (tools.ts:27-31) server?, uri read an MCP resource
Meta skill load a skill manual into context by name name load the effect skill
Meta lsp (experimental) call a language server: definition/references/hover/symbols, 9 operations operation etc. find a function definition
Backstop invalid receive unrepairable tool calls (llm.ts:296-312’s experimental_repairToolCall rewrites failed calls to it) tool, error when the model calls a tool wildly

Command parsing for bash-class tools is a highlight: not regex, but a tree-sitter syntax tree (tool/shell.ts:9,91-117). parts() walks the AST, extracts each command’s name and arguments, used for permission matching (so git status and git push can be treated differently), and can recognize the “touches files” command set such as cd/rm/cp (shell.ts:28-50’s CWD/FILES constants). Prompts are customized by shell type (pwsh/PowerShell/cmd each have a notes file, shell/prompt.ts:40+).

Permission mechanism: ask / allow / deny three-state + wildcard rules. The arbitrator is in permission/index.ts:

// packages/opencode/src/permission/index.ts:28-38
export function evaluate(permission: string, pattern: string, ...rulesets: PermissionV1.Ruleset[]): PermissionV1.Rule {
  return (
    rulesets.flat().findLast((rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern))
    ?? { action: "ask", permission, pattern: "*" }   // 没匹配到?默认 ask
  )
}

Rules look like { permission: "edit", pattern: "src/**", action: "allow" }; later-written rules win (findLast). Default ruleset (agent/agent.ts:119-136): everything allow, but doom_loop, external_directory (outside the workspace), reading *.env are ask. The plan agent has edit: deny (only allowed to write plan files).

How is the user asked? Mid-tool-execution call ctx.ask(...) (e.g. task tool task.ts:120-128) → Permission.ask evaluates rules: deny throws DeniedError directly; if it needs to ask, emit Permission.Event.Asked and hang on a Deferred (promise) (permission/index.ts:98-106)—like a phone call transferred to hold. The TUI pops a dialog; the user picks one of three (routes/session/permission.tsx):

  • once (this time only): Deferred proceeds, no rule recorded;
  • always: append the always patterns into approved rules, and incidentally release other pending requests in the same session that the new rule now covers (permission/index.ts:145-166);
  • reject: can attach a sentence of feedback (CorrectedError, permission/index.ts:121-127); that feedback goes back to the model as a tool error—“don’t run rm -rf, please use a safer way”; the model can understand and change the plan. Other pending requests in the same session are rejected in solidarity.

Another live permission-rule example. Suppose you write in opencode.json: { "permission": { "edit": { "*": "allow", "docs/**": "ask" }, "bash": { "*": "allow", "git push *": "ask", "rm *": "deny" } } }. Then: editing src/a.ts is allowed immediately; editing docs/guide.md pops a question; npm test runs immediately; git push origin dev pops; rm -rf build/ is refused without even asking, and after the model receives the “denied” tool error it can only change the plan. Combined with agent-level rules (e.g. plan agent globally forbids edits), opencode’s permission system is actually a multi-layer filter of “user config ⊃ session rules ⊃ agent rules ⊃ built-in defaults”; the more specific the rule, the later it is written, the sooner it hits.

flowchart TD
    A["工具 execute 中调 ctx.ask(permission, patterns)"] --> B["evaluate: 合并 agent 规则 + 会话规则 + 已批准规则"]
    B --> C{"findLast 匹配结果"}
    C -->|allow| D["直接执行"]
    C -->|deny| E["抛 DeniedError
工具失败,错误回给模型"] C -->|ask / 无匹配| F["发 Asked 事件
Deferred 挂起等待"] F --> G["TUI 弹窗:once / always / reject"] G -->|once| D G -->|always| H["追加 approved 规则
同会话可覆盖的挂起请求一并放行"] --> D G -->|reject| I["RejectedError 或带反馈的 CorrectedError
反馈文本回给模型"]

Part 8

Chapter 8 · Context compaction and memory

Is there a compact/summary mechanism? Yes, and it is three layers.

Layer one: auto compaction. Trigger threshold in session/overflow.ts:

// packages/opencode/src/session/overflow.ts:22-37(精简)
export function isOverflow(input) {
  if (input.cfg.compaction?.auto === false) return false
  const count = input.tokens.total || (input + output + cache.read + cache.write)
  return count >= usable(input)   // usable = 上下文上限 - 预留(约 20k 或最大输出)
}

If usage at the end of a round ≥ “usable cap” (COMPACTION_BUFFER = 20_000, overflow.ts:8), the agent loop creates a compaction task (prompt.ts:1161-1168). Even better is in-stream circuit break: on step-finish, overflow sets needsCompaction, Stream.takeUntil (processor.ts:644) immediately cuts this stream, compact first then continue—no waiting for the model to finish talking.

How is compaction implemented? session/compaction.ts: a hidden compaction agent (agent.ts:219-233, permission *: deny, allowed only to talk, not to use tools) generates a summary of the “head” of history. The “tail” keep policy is fine-grained: default keep the most recent 2 user turns (DEFAULT_TAIL_TURNS = 2, compaction.ts:32), and the kept amount is constrained by a token budget (25% of usable context, clamped between 2k–8k, compaction.ts:80-85); if the budget is insufficient it will also split a turn in the middle (splitTurn, compaction.ts:105-128). If it is “overflow compaction” (the model was cut off mid-sentence), after compaction the last real user message is replayed so the agent continues (input.overflow branch in compaction.ts). After compaction a new Context Epoch opens; the summary replaces old history.

Layer two: tool-output prune. compaction.ts’s prune(): scan from newest to oldest, protect the most recent 2 turns and the most recent 40k tokens of tool output (PRUNE_PROTECT = 40_000); older completed tool outputs, if the cumulative releasable exceeds 20k (PRUNE_MINIMUM = 20_000), get a compacted timestamp and are wiped from the history projection (still in the database, just no longer fed to the model). skill tool output is protected (PRUNE_PROTECTED_TOOLS).

Layer three: single-output truncation. Any tool’s single output over 2000 lines/50KB is truncated and landed in a temp file (Chapter 7).

Manual compaction: the TUI’s /compact command (routes/session/index.tsx:559) can trigger at any time.

Long-term memory? Not found—no cross-session persistent memory system (nothing like Claude Code “memory” automatic cross-session learning). Cross-session “memory” relies on session persistence (SQLite, restorable with --continue) and human-maintained files such as AGENTS.md.

flowchart TD
    A["每轮 step-finish 统计 token"] --> B{"用量 ≥ 上下文上限-预留?"}
    B -->|否| C["继续"]
    B -->|是| D["needsCompaction
takeUntil 熔断当前流"] D --> E["compaction agent 总结头部历史
(无工具权限的隐藏 agent)"] E --> F["保留尾部: 最近 2 回合
且 ≤ 25% 上下文 (2k~8k token)"] F --> G{"溢出型?"} G -->|是| H["重放最后一条真实用户消息"] G -->|否| I["从摘要后继续"] H --> I J["后台 prune"] --> K{"老工具输出 > 40k 保护线
且可释放 > 20k?"} K -->|是| L["标记 compacted
不再注入模型"]

Part 9

Chapter 9 · Sub-agents and multi-agent

Is there a sub-agent system? Yes, and it is a core mechanism. opencode’s “agent” is a first-class config (agent/agent.ts’s Info schema: name, description, mode: primary/subagent/all, permissions, model, temperature, prompt, steps cap). 7 built-in (agent.ts:140-265):

agent mode Use
build primary (default) full execution, default permissions
plan primary plan mode, forbid edits, forbid general sub-agent
general subagent general multi-step tasks, can parallelize
explore subagent read-only explore the codebase (only grep/glob/read/bash etc. allowed)
compaction / title / summary hidden internal roles: compact, title, generate summary

In the TUI, press Tab to cycle primary agents (keybind.ts: agent_cycle: keybind("tab", "Next agent")).

How are they spawned? Via the task tool (tool/task.ts). Key designs:

  • Sub-agent = sub-session: the task tool creates a new session whose parentID points at the current session, independently running its own agent loop; the parent session’s task tool call waits for it to finish, wraps the last text in <task id="..." state="..."> and returns (task.ts:64-79).
  • Depth limit: count depth along the parentID chain, default at most 1 layer (task.ts:104-117, cfg.subagent_depth ?? 1)—a sub-agent by default cannot spawn a grandchild, preventing nest-uncontrolled.
  • Permission inheritance: sub-session permissions are derived from the parent by deriveSubagentSessionPermission.
  • Ask the user first: before spawn there is ctx.ask({ permission: "task", patterns: [subagent_type] }) (task.ts:120-128).
  • Resumable: pass task_id to return to that previous sub-agent session and continue (task.ts:136-138).

Foreground / background: background: true can start a sub-agent asynchronously and return immediately—but currently an experimental feature, must set env OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true (task.ts:97-102); when complete the system auto-notifies the main agent. TUI side has companions: left/right arrows shuttle between child sessions, up key back to parent (keybind.ts: session_child_cycle etc.), ctrl+b can move a sync sub-agent to background (session_background).

Why so carefully limit depth and background? Because a sub-agent is “recursion that spends its own tokens.” Without a depth limit, a sub-agent that misunderstood the task might spawn ten more, each opening a full context window and burning API quota; without permission inheritance and a spawn ask, sub-agents become a backdoor around user authorization. opencode’s choice is to make the safest shape (foreground, one layer, needs approval) the default, and hide the dangerous shapes (background concurrency, multi-layer nesting) behind experimental switches—consistent with its overall permission philosophy: capability can exist, but the default must be conservative.

A typical scene: you ask the main agent “write unit tests for this project.” It judges it needs to understand the code structure first, so it dispatches an explore sub-agent (read-only, fast, independent context) to scan directories and key files; after the sub-agent returns a summary, the main agent starts writing test files. The sub-agent’s dozens of grep/read do not pollute the main session’s context—that is both a performance optimization and careful accounting of the context window.

Additionally, slash commands can also spawn sub-agents: when a command declares subtask: true (e.g. built-in /review), arguments become a subtask part handed to the agent loop’s handleSubtask (prompt.ts:1144-1147, 1439-1451).

sequenceDiagram
    participant M as 主 agent (build)
    participant T as task 工具
    participant P as 权限系统
    participant C as 子会话 (explore)
    M->>T: task{subagent_type: "explore", prompt: "找认证逻辑"}
    T->>T: 沿 parentID 链检查深度 ≤ 1
    T->>P: ask(task, explore)
    P-->>T: 用户批准
    T->>C: 创建 parentID=主会话 的新 session
    C->>C: 独立主循环 (只读工具)
    C-->>T: 完成,返回最后文本
    T-->>M: 结果
    Note over M,C: background=true 时立即返回,
完成后再通知(实验特性)

Part 10

Chapter 10 · Ecosystem

Slash-command inventory (full table). opencode’s commands are two layers: TUI application-layer commands (UI operations) and server-side template commands (expanded into prompts and sent to the model).

TUI layer (app.tsx and routes/session/index.tsx’s slashName):

Category Commands
Session /new, /sessions, /rename, /timeline, /fork, /share, /unshare, /undo, /redo, /compact, /copy, /export, /move
Model and agent /models, /agents, /variants, /connect, /mcps
UI /themes, /status, /debug, /help, /editor, /skills, /timestamps, /thinking, /org, /warp
Exit /exit

Server-side template commands: built-in /init (guide generating AGENTS.md) and /review (review changes, subtask mode) (command/index.ts:47-76); custom commands live in .opencode/command/*.md (YAML frontmatter can specify model, agent, subtask); prompts provided by MCP servers also become commands (source: "mcp" in command/index.ts).

Three magics of command templates (session/prompt.ts:command function, 1372-1408):

  1. $ARGUMENTS, $1, $2…… positional argument substitution (also supports "quotes" and [Image N] placeholders, regex at 1592-1596);
  2. !`cmd` — run a shell command locally first before sending and nest the output into the template (1397-1408); e.g. writing !`git diff --staged` in the template feeds the staged diff to the model;
  3. @filepath — resolvePromptParts (prompt.ts:157-191) expands the reference into a file attachment; if the name happens to be an agent, it becomes an agent reference (@explore look this up……).

This repo itself uses them (dogfooding): .opencode/command/commit.md (commit and push), .opencode/agent/triage.md (issue-triage agent).

Custom skills: discover SKILL.md at .opencode/{skill,skills}/**, ~/.claude/skills, .agents, etc. (pattern constants in skill/index.ts:23-27). The system prompt lists the catalog; the model uses the skill tool to load the body on demand—“open the manual only when you use it,” saving tokens.

MCP support: three transports—stdio, StreamableHTTP, SSE (mcp/index.ts:7-9; remote servers try StreamableHTTP first, fall back to SSE, mcp/index.ts:269-290), with an OAuth authorization flow. Tool naming rule: sanitize(server name)_sanitize(tool name) (mcp/catalog.ts:117-119, illegal characters replaced with underscores)—e.g. github_get_issue. Every MCP tool call goes through a permission ask first; the permission name is the tool key (session/tools.ts:407); config can batch-allow with patterns like mcp:github:*. If an MCP server declares resources, three more tools such as list_mcp_resources appear dynamically; MCP instructions are injected into the system prompt.

A concrete config example: in opencode.json’s mcp field declare a local stdio server ("github": { "type": "local", "command": ["npx", "-y", "@modelcontextprotocol/server-github"], "environment": { "GITHUB_TOKEN": "..." } }) or a remote server ("type": "remote", "url": "https://..."). After start, that server’s tools appear in the model’s tool list as github_xxx, standing equal with built-in tools; the model cannot even tell which are built-in and which come from MCP—that is the value of the MCP protocol: a standardized socket for the tool ecosystem.

Plugin system: opencode.json’s plugin field declares an npm package or a local file (plugin/loader.ts, plugin/meta.ts maintain install records plugin-meta.json). A plugin is a function returning Hooks (packages/plugin/src/index.ts:222); hooks are very complete: config, event, tool (register new tools), auth, provider, chat.message, chat.params (change temperature etc.), chat.headers, permission.ask, tool.execute.before/after, command.execute.before, shell.env, and the experimental.* series. Built-in plugins (plugin/index.ts:65-79) are all auth-class: Codex (ChatGPT subscription), GitHub Copilot, GitLab, Poe, Cloudflare ×2, Azure, DigitalOcean, Snowflake, xAI. There is also a lighter custom-tool directory: {tool,tools}/*.ts is auto-scanned and registered (registry.ts:178-192).

LSP: the lsp/ directory implements language-server management (auto-spawn the corresponding language server); after editing files, collect diagnostics and feed them back to the model; the lsp tool is currently an experimental switch (registry.ts:242).

flowchart TB
    subgraph 命令来源
        A["内置 init/review"]
        B[".opencode/command/*.md"]
        C["MCP prompts"]
        D["TUI 应用命令"]
    end
    subgraph 技能
        E[".opencode/skills/**/SKILL.md
~/.claude/skills"] end subgraph 工具扩展 F[".opencode/tool/*.ts"] G["npm/文件插件 Hooks.tool"] H["MCP 服务器工具
server_tool 命名"] end A --> CMD["Command.Service
$ARGUMENTS / !`cmd` / @file"] B --> CMD C --> CMD CMD --> LOOP["Agent 主循环"] E --> SKILL["skill 工具按需加载"] F --> REG["ToolRegistry"] G --> REG H --> REG REG --> LOOP SKILL --> LOOP D --> TUIACT["界面动作(不发模型)"]

Part 11

Chapter 11 · Features

Auth: credentials live in the data directory’s auth.json (auth/index.ts:11), three shapes: api (API key), oauth (refresh/access/expires, auto-refresh), wellknown (auth/index.ts:15-35). opencode providers login (cli/cmd/providers.ts) follows the auth methods plugins declare: OAuth splits into auto (open a browser + local callback) and code (copy-paste a code) two sub-flows (providers.ts:95-150); API key is interactive input. That is, you can log in with ChatGPT Plus (Codex plugin), GitHub Copilot subscription, Claude subscription, etc., or take the env-var / custom-baseURL “BYOK” route. Additionally the OPENCODE_AUTH_CONTENT env var can inject credentials as a whole (auth/index.ts), convenient for CI.

Model selection and switching: --model provider/model (e.g. anthropic/claude-sonnet-4-5); in the TUI /models popup, F2 cycles recently used models (keybind.ts: model_cycle_recent), can favorite. The model catalog comes from models.dev (provider/provider.ts:13); each model’s context cap, price, capabilities come from here; you can also custom-define a provider in opencode.json (npm package + baseURL).

Multi-model (is it provider-agnostic)? Yes. Different agents in the same session can use different models (e.g. .opencode/agent/triage.md writes model: opencode/gpt-5.4-mini); different commands can also specify a model. For OpenAI/Anthropic/opencode the three there is also a home-grown native runtime (native-runtime.ts:55-59), bypassing AI SDK to speak “hometown dialect”; remaining providers go through the Vercel AI SDK adapter layer. opencode itself also has an official aggregation provider (opencode/... prefix, providing websearch and other value-adds).

effort/thinking config: implemented via “model variants.” provider/transform.ts computes reasoning_effort grades by model family: OpenAI family from none/minimal to xhigh (whether to expose the none grade depends on the model’s release date, transform.ts:567-572); new Anthropic models have low–max adaptive grades (transform.ts:640+). The TUI uses /variants popup to switch (dialog-variant.tsx); /thinking toggles thinking display. Agent config can also pin a variant.

Permission modes: three-state rules (Chapter 7) + two “personalities”: default build (ask then do) and plan (read-only planning). opencode run --auto/--yolo/--dangerously-skip-permissions auto-allow. Config file permission field can be as fine as { "bash": { "git push *": "ask", "*": "allow" } }.

Other distinctive features:

  • Session share: /share syncs the session to opencode.ai and generates a link (share/share-next.ts; OPENCODE_DISABLE_SHARE can disable);
  • Many clients: opencode serve (HTTP server), opencode web, opencode attach (attach to an existing server), opencode acp (Zed’s Agent Client Protocol), desktop App (Electron structure: packages/desktop has main/preload/renderer), opencode github (GitHub Action, summon with @opencode in a PR/Issue), Slack package;
  • Undo/redo: each step records a git snapshot (processor’s step-start/step-finish call snapshot.track()/patch()), /undo, /redo roll back file changes;
  • TUI experience: theme system, configurable keybindings, command palette (leader key), input history and frecency (frequency + recency ranking), draft stash, paste images, external editor (ctrl+o-class opens $EDITOR), child-session navigation, pin sessions and 1-9 quick switch;
  • Structured output: when session.prompt carries format: json_schema it injects a StructuredOutput tool forcing the model to hand in by schema (prompt.ts:74-82,1243-1250)—the foundation of opencode run scriptability.
flowchart LR
    subgraph 认证
        A["auth.json
api / oauth / wellknown"] B["providers login
OAuth auto/code"] C["环境变量
OPENCODE_AUTH_CONTENT"] end subgraph 模型 D["models.dev 目录"] E["原生运行时
OpenAI/Anthropic/opencode"] F["Vercel AI SDK
其余 provider"] G["variants: reasoning effort 档位"] end subgraph 客户端 H["TUI"] I["serve + web/desktop"] J["ACP (Zed)"] K["GitHub Action / Slack"] end A --> E B --> E C --> F D --> E D --> F E --> H F --> H G --> H

Part 12

Chapter 12 · Summary: design philosophy and trade-offs

After reading the source, opencode feels like: an open-source Claude Code that puts “engineering orthodoxy” first. Its distinctive ideas and highlights:

① The server is the body; the protocol is the product. In default mode TUI and server are in the same process, yet still walk a complete HTTP protocol via Worker + RPC + in-memory fetch (tui.ts:210-247). That means “local single machine” and “remote team” are the same code path—desktop App, Zed, GitHub Action, third-party SDK all plug in at zero cost. Against Claude Code’s “the CLI is everything,” opencode treated “being integrated” as a core scene from day one.

② Betting on Effect for maintainability. The whole server rewritten in Effect (DI Layers, structured concurrency, Deferred permission hang, streaming Stream), in exchange for: extremely clean interrupt semantics (ESC interrupt cleanup path processor.ts:539-597), concurrency safety (re-read the database every round, queued messages naturally taken over), testability (almost no mocks; AGENTS.md orders "Avoid mocks"). The cost is also obvious: the barrier for ordinary contributors is high, yield* flying across the screen. This is a “long-termism” high-stakes bet.

③ Obsession with “boundaries.” What impresses most about this project is the density of boundary-case handling: provider lied stop but has tool calls? Continue the loop (prompt.ts:1103-1109). Three identical tool calls in a row? doom loop ask (processor.ts:356-380). Tool arguments unrepairable? Stuff into the invalid tool as a backstop (llm.ts:296-312). Compaction cut the model off mid-sentence? Replay the last user message. Rejecting a permission can attach a note that “teaches” the model to correct (CorrectedError). The 30,000-word CONTEXT.md at the repo root simply defines a formal vocabulary for the entire session runtime (Context Epoch, Safe Provider-Turn Boundary……), and a V2 kernel is landing—this attitude of “say the semantics clearly first, then write code” is rare among open-source self-built agents.

Obvious trade-offs:

  • Compatible with the Claude Code ecosystem (reads CLAUDE.md, .claude/skills) lowers migration cost, but also carries someone else’s historical baggage (the code still has a disableClaudeCodePrompt switch and a CONTEXT.md // deprecated comment).
  • Model freedom buys adapter complexity: provider/transform.ts two-thousand-plus lines are all vendor quirks (which family supports which effort grade, Copilot’s billing fields, Azure URL patterns……)—the inevitable tax of being provider-agnostic.
  • Aggressive rewrite: the project is in a high-speed refactor period (V1/V2 session kernels coexist, legacy bridges everywhere); reading source you often meet "TODO: remove this hack" (e.g. tool.ts:15). Features are strong, but stability yields to evolution speed.
  • No cross-session long-term memory: compared with Claude Code’s memory mechanism, opencode chooses only in-session compaction + human AGENTS.md, handing “remember the user” to config rather than algorithm—restrained, but it also means personalization is something the user has to run themselves.

One-sentence summary: Claude Code is “the best terminal agent built for Claude,” and opencode is trying to become “an open agent platform built for every model, every client”—it is sprinting toward that goal with Effect’s rigor, C/S architecture’s openness, and a pit-and-valley of boundary handling.

mindmap
  root((opencode
设计哲学)) 开放 服务器即本体,协议先行 模型自由(models.dev+原生运行时) 插件/MCP/技能/命令全开放 兼容 Claude Code 生态 严谨 Effect 依赖注入+结构化并发 每轮重读数据库保证一致 doom_loop/orphan/overflow 全防御 CONTEXT.md 形式化语义 取舍 贡献门槛高(Effect+Bun) 适配税(transform.ts 两千行) V1/V2 并存的高速重构 无跨会话记忆,克制个性化

(End of lecture. Every file:line citation in the text can be checked directly in commit 62e46412 source; features marked “experimental” need the corresponding env var or config enabled.)

↑