启动链路
从 yargs 入口到 TuiThreadCommand:Worker 线程里的服务端、RPC fetch 隧道与事件通道,以及 attach、serve、web、run、--mini 各自怎么起。
敲下 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。准备阶段:
-
带
--mini就转去run.ts的runMini,并拒绝--port、--hostname、--mdns等网络参数;--fork必须配--continue或--session。 -
解析项目目录并
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 必须用同一个目录作键,否则会被当成两个实例。
-
找 Worker 脚本:编译产物用构建期注入的
OPENCODE_WORKER_PATH,否则依次试dist下的cli/tui/worker.js与源码worker.ts。然后new Worker(file, { env })把环境变量全部传过去,再用Rpc.client包一层;收到SIGUSR2时让 Worker 重载配置。 -
把管道输入的 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 | 释放全部实例、停止监听 |
其他入口
| 入口 | 服务端在哪 | 传输 |
|---|---|---|
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):
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、隧道、主循环与工具,再以事件的形式回到屏幕上。