# 启动链路

> 从 yargs 入口到 TuiThreadCommand：Worker 线程里的服务端、RPC fetch 隧道与事件通道，以及 attach、serve、web、run、--mini 各自怎么起。

- 作者：David（道雾轩）
- 专栏：OpenCode 源码解读（https://daiw.org/manual/opencode.md）
- 最后更新：2026-09-15
- 原文：https://daiw.org/manual/opencode/startup
- 转载与引用：请注明出处并附原文链接（https://daiw.org/about/copyright）

敲下 `opencode` 到 TUI 画出第一帧之间发生了什么？这一篇沿着进程与线程的边界走一遍。

```mermaid
flowchart LR
    exe["src/index.ts<br/>yargs 与中间件"] --> tui["TuiThreadCommand<br/>主线程"]
    tui -->|new Worker| worker["cli/tui/worker.ts<br/>Worker 线程"]
    tui -->|RPC 调用 fetch 等| worker
    worker -->|RPC 事件 global.event| tui
    worker --> app["Server.Default<br/>进程内 HTTP 处理函数"]
    tui --> ui["@opencode-ai/tui 的 run<br/>OpenTUI 渲染"]
```

## 入口：src/index.ts

`packages/opencode/src/index.ts` 用 yargs 注册全部命令，另有三个全局选项 `--print-logs`、`--log-level`、`--pure`，由中间件翻译成环境变量（`packages/opencode/src/index.ts:66`）：

```ts
.middleware(async (opts) => {
  if (opts.printLogs) process.env.OPENCODE_PRINT_LOGS = "1"
  if (opts.logLevel) process.env.OPENCODE_LOG_LEVEL = opts.logLevel
  if (opts.pure) {
    process.env.OPENCODE_PURE = "1"
  }

  Heap.start()

  process.env.AGENT = "1"
  process.env.OPENCODE = "1"
  process.env.OPENCODE_PID = String(process.pid)
})
```

写进环境变量，Worker 线程、子进程与 `RuntimeFlags` 就都能读到。`--pure` 的效果落在插件加载处：`packages/opencode/src/plugin/index.ts:181` 在 `flags.pure` 为真时把外部插件列表置空，TUI 插件运行时同理。`Heap.start()` 只在设置 `OPENCODE_AUTO_HEAP_SNAPSHOT` 时生效，每分钟检查一次，常驻内存超过 2 GB 就写堆快照。入口的 `finally` 里无论成败都显式 `process.exit()`，注释说有些子进程（典型是没加 `--init` 的 docker 版 MCP 服务端）不理 SIGTERM，不强退就会挂住。

命令有两种包装：普通的 `cmd`，和把处理函数写成 Effect 的 `effectCmd`（`packages/opencode/src/cli/effect-cmd.ts`）。后者的 `instance` 选项决定是否先加载项目实例：`serve`、`web` 传 `false`，注释写明服务端会按请求头 `x-opencode-directory` 逐请求加载实例；`run` 传 `(args) => !args.attach`。

## 默认命令：TuiThreadCommand

默认命令串是 `$0 [project]`，实现在 `packages/opencode/src/cli/cmd/tui.ts`。准备阶段：

1. 带 `--mini` 就转去 `run.ts` 的 `runMini`，并拒绝 `--port`、`--hostname`、`--mdns` 等网络参数；`--fork` 必须配 `--continue` 或 `--session`。
2. 解析项目目录并 `chdir` 过去。注释说明了原因：

   > Resolve relative --project paths from PWD, then use the real cwd after chdir so the thread and worker share the same directory key.

   主线程与 Worker 必须用同一个目录作键，否则会被当成两个实例。
3. 找 Worker 脚本：编译产物用构建期注入的 `OPENCODE_WORKER_PATH`，否则依次试 `dist` 下的 `cli/tui/worker.js` 与源码 `worker.ts`。然后 `new Worker(file, { env })` 把环境变量全部传过去，再用 `Rpc.client` 包一层；收到 `SIGUSR2` 时让 Worker 重载配置。
4. 把管道输入的 stdin 与 `--prompt` 拼成初始提示，加载 TUI 配置。

接着选择传输方式（`tui.ts:233`）：

```ts
const network = resolveNetworkOptionsNoConfig(args)
const external = hasArg("--port") || hasArg("--hostname") || network.mdns === true

const headers = external ? ServerAuth.headers() : undefined

const transport = external
  ? {
      url: (await client.call("server", network)).url,
      fetch: undefined,
      events: undefined,
      headers,
    }
  : {
      url: "http://opencode.internal",
      fetch: createWorkerFetch(client),
      events: createEventSource(client),
    }
```

默认走内部传输：URL 是伪地址 `http://opencode.internal`，`fetch` 换成 RPC 隧道，事件来自 RPC 事件通道，全程不开端口。只有显式给了 `--port` 或 `--hostname`、或开了 mDNS，才让 Worker 真正监听，TUI 改用真实 URL 与 Basic 鉴权头——此时别的客户端也能 `attach` 进来。

然后：给了 `--session` 就先用 SDK 调 `session.get` 校验（`cli/tui/validate-session.ts`）；1 秒后在后台让 Worker 检查升级；动态 import `cli/tui/layer.ts`，调用 `@opencode-ai/tui` 的 `run`，把 URL、`fetch`、事件源、配置与参数交给它（`--auto`、`--yolo`、`--dangerously-skip-permissions` 合并为 `auto`）。TUI 退出后，发 `shutdown` RPC（最多等 5 秒）、`worker.terminate()`，最后 `process.exit(0)`。

## RPC 隧道与 Worker

隧道只有 66 行（`packages/opencode/src/util/rpc.ts`）：请求、结果、事件三种 JSON 消息经 `postMessage` 往返。`createWorkerFetch` 把 `Request` 拆成 `url`、`method`、`headers` 与文本 `body` 发过去，Worker 重建请求后直接交给 HTTP 处理函数（`packages/opencode/src/cli/tui/worker.ts:23`，删减后）：

```ts
// Subscribe to global events and forward them via RPC
GlobalBus.on("event", (event) => {
  Rpc.emit("global.event", event)
})

// ...

export const rpc = {
  async fetch(input: { url: string; method: string; headers: Record<string, string>; body?: string }) {
    // ...
    const response = await Server.Default().app.fetch(request)
    const body = await response.text()
    // ...
  },
  // ...
}
```

`Server.Default()` 是惰性构建的 Web 处理函数，与对外监听时用的是同一棵路由树，只是不经网络。响应体被 `response.text()` 整个读完才返回，隧道承载不了 SSE 这类长流，所以事件另走一路：Worker 订阅进程内的 `GlobalBus`，逐条推回主线程；TUI 的 SDK 上下文拿到 `events` 参数时就不建 SSE 连接（`packages/tui/src/context/sdk.tsx:119`）。

Worker 一共暴露六个 RPC 方法：

| 方法 | 作用 |
| --- | --- |
| `fetch` | 把一次 HTTP 请求交给进程内处理函数，设了密码时补上鉴权头 |
| `server` | 调 `Server.listen` 真正监听（已在监听就先停掉），返回 URL |
| `checkUpgrade` | 加载实例后检查新版本，按配置提示或自动升级补丁版本 |
| `reload` | 让配置失效并释放全部实例 |
| `snapshot` | 写服务端堆快照 |
| `shutdown` | 释放全部实例、停止监听 |

## 其他入口

| 入口 | 服务端在哪 | 传输 |
| --- | --- | --- |
| `opencode` | Worker 线程 | RPC 隧道与 `global.event`；带 `--port` 等参数时改为真实 HTTP |
| `opencode --mini` | 主线程进程内 | `fetch` 直接调 `Server.Default().app.fetch`，不经 Worker |
| `opencode run` | 进程内（`--attach` 时在远端） | 同上，事件用 SDK 的 `event.subscribe()` 订阅 |
| `opencode attach <url>` | 远端 | 真实 HTTP 加 Basic 鉴权，启动同一个 TUI |
| `opencode serve`、`web` | 当前进程 | `Server.listen` 对外监听；`web` 还会打开浏览器 |
| `opencode acp` | 当前进程 | 先 `Server.listen`，再经 stdin 与 stdout 讲 ACP |

`run` 的进程内 `fetch` 原样返回处理函数给出的 `Response`，流式响应体可用，所以非交互模式能照常订阅事件流，看到会话状态变为 `idle` 就退出（`packages/opencode/src/cli/cmd/run.ts:793`）。`serve` 与 `web` 在没设 `OPENCODE_SERVER_PASSWORD` 时会警告服务端没有鉴权保护。

## Server.listen：端口与 mDNS

对外监听由 `packages/opencode/src/server/server.ts` 的 `listen` 完成，底层是 `@effect/platform-node` 的 `NodeHttpServer`。端口回退规则（`server.ts:117`）：

```ts
function startWithPortFallback(opts: ListenOptions) {
  if (opts.port !== 0) return startListener(opts, opts.port)
  // Match the legacy listener port-resolution behavior: explicit `0` prefers
  // 4096 first, then any free port.
  return startListener(opts, 4096).pipe(Effect.catch(() => startListener(opts, 0)))
}
```

网络参数默认 `--port 0`、`--hostname 127.0.0.1`，所以 `serve` 先抢 4096，被占用再交给系统随机分配。`serve`、`web`、`acp` 用 `resolveNetworkOptions`：命令行参数优先，其次全局配置的 `server` 字段，最后是默认值；开了 `--mdns` 又没指定主机名时，主机名改为 `0.0.0.0`（`packages/opencode/src/cli/network.ts:70`）。默认的 TUI 命令则用不读配置的 `resolveNetworkOptionsNoConfig`。mDNS 由 `bonjour-service` 发布名为 `opencode-<port>` 的 http 服务（默认域名 `opencode.local`）；主机名是回环地址时只打警告、不发布。

新 CLI `lildax` 则改用常驻守护进程：没有可用的服务端就拉起 `serve --register` 子进程，TUI 还是同一个包（`packages/cli/src/services/daemon.ts:110`），见[新 CLI 与守护进程](https://daiw.org/manual/opencode/cli-daemon)。

下一篇：[一条消息的生命周期](https://daiw.org/manual/opencode/message-lifecycle)——在默认 TUI 里敲下回车，这条消息怎么穿过 SDK、隧道、主循环与工具，再以事件的形式回到屏幕上。
