# 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:line`) so you can follow the map.

---

> 🗺️ **Companion architecture diagram**: this project has a dedicated figure in the [**seven-agent architecture gallery**](../架构图库.html#ch4)—**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.

## 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`.

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

---

## 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) |

```mermaid
flowchart TB
    subgraph 客户端层
        TUI["packages/tui<br/>终端界面"]
        WEB["packages/web"]
        APP["packages/desktop, app"]
        ACP["acp/<br/>Zed 编辑器"]
    end
    subgraph SDK层
        SDK["@opencode-ai/sdk<br/>HTTP 客户端"]
    end
    subgraph 服务器["opencode 服务器（Bun 进程/Worker）"]
        HTTP["server/routes/instance/httpapi<br/>REST + SSE 事件"]
        LOOP["session/prompt.ts<br/>Agent 主循环"]
        PROC["session/processor.ts<br/>流式事件处理"]
        TOOLS["tool/registry.ts<br/>工具注册表"]
        PERM["permission/index.ts<br/>权限仲裁"]
        MCP["mcp/index.ts"]
        STORE["SQLite (Drizzle)<br/>会话持久化"]
    end
    subgraph 外部
        LLM["模型 API<br/>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.

---

## 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:

```ts
// 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`):

```ts
// 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.

```mermaid
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 事件流（消息/权限询问）
```

---

## 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:

```ts
// 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).

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

---

## 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`):

```ts
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:

```ts
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:

```ts
// 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.

```mermaid
flowchart TB
    subgraph 底料
        P["agent.prompt 或<br/>SystemPrompt.provider(model)<br/>anthropic/gpt/kimi/default.txt"]
    end
    subgraph 原料
        E["env: 目录/git/平台/日期"]
        I["instructions:<br/>全局 AGENTS.md → 项目 AGENTS.md<br/>→ 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 消息<br/>放在消息列表最前"]
    R["read 工具触发<br/>就近 AGENTS.md 补充"] -.-> I
```

---

## 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**.

```ts
// 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`:

```ts
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.

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

---

## 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`:

```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.

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

---

## 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`:

```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`.

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

---

## 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`).

```mermaid
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: <task id state="completed">结果</task>
    Note over M,C: background=true 时立即返回，<br/>完成后再通知（实验特性）
```

---

## 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`).

```mermaid
flowchart TB
    subgraph 命令来源
        A["内置 init/review"]
        B[".opencode/command/*.md"]
        C["MCP prompts"]
        D["TUI 应用命令"]
    end
    subgraph 技能
        E[".opencode/skills/**/SKILL.md<br/>~/.claude/skills"]
    end
    subgraph 工具扩展
        F[".opencode/tool/*.ts"]
        G["npm/文件插件 Hooks.tool"]
        H["MCP 服务器工具<br/>server_tool 命名"]
    end
    A --> CMD["Command.Service<br/>$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["界面动作（不发模型）"]
```

---

## 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.

```mermaid
flowchart LR
    subgraph 认证
        A["auth.json<br/>api / oauth / wellknown"]
        B["providers login<br/>OAuth auto/code"]
        C["环境变量<br/>OPENCODE_AUTH_CONTENT"]
    end
    subgraph 模型
        D["models.dev 目录"]
        E["原生运行时<br/>OpenAI/Anthropic/opencode"]
        F["Vercel AI SDK<br/>其余 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
```

---

## 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.

```mermaid
mindmap
  root((opencode<br/>设计哲学))
    开放
      服务器即本体,协议先行
      模型自由(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.)*
