启动链路

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

作者 David更新于 第 4 篇(共 38 篇)

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

图表加载中…

入口:src/index.ts

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

.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):

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,删减后):

// 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释放全部实例、停止监听

其他入口

入口服务端在哪传输
opencodeWorker 线程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):

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 与守护进程。

下一篇:一条消息的生命周期——在默认 TUI 里敲下回车,这条消息怎么穿过 SDK、隧道、主循环与工具,再以事件的形式回到屏幕上。

本页目录