基于 OpenCode(sst/opencode)真实 clone 下来的 TypeScript 源码,逐个拆解设计模式的具体实现。本文是系列第一篇,覆盖「进程生命周期」领域的 3 个模式。
源码地址:github.com/sst/opencode · TypeScript monorepo · Effect 框架
没接触过 JavaScript/Effect? 本文会在每段 TS 代码旁边直接给出 Python 等价写法,不需要提前学语法。
为什么 Agent 需要特殊的进程架构?
如果你写过一个简单的 AI 对话脚本,它大概长这样:
while True:
user_input = input("You: ")
response = llm.chat(user_input)
print(f"AI: {response}")
这能跑,但只适合 demo。真正的 Agent 面临一个致命问题:LLM 调用又慢又贵。
一次复杂的工具调用链(读 5 个文件 → 分析 → 写 3 个文件 → 跑测试)可能耗时 5-10 分钟。如果这个过程跑在前端进程里,SSH 断了就全丢了。更关键的是:AI 在跑任务时,TUI 界面不能卡死——用户得能看到实时进度。
OpenCode 的 Server 架构解决三个层级的问题:
| 层级 | 问题 | 模式 |
|---|---|---|
| 任务不丢 + UI 不卡 | AI 跑长任务时 UI 要响应,任务状态要可观测 | 模式 1:进程内隔离 + 事件驱动 |
| 多项目共存 | 同时打开多个项目目录,端口不冲突 | 模式 2:端口透明 + 按需暴露 |
| 代码怎么组织 | 几十个模块,每个项目独立实例,切换不串 | 模式 3:per-directory 依赖注入 |
下面逐一拆解。
模式 1:进程内隔离 + 事件驱动通信
源码位置: packages/opencode/src/cli/cmd/tui.ts · packages/opencode/src/cli/tui/worker.ts · packages/opencode/src/server/server.ts
出乎意料的事实
你可能以为 OpenCode 跑了两个进程:一个 TUI 客户端,一个 Daemon 服务端。但默认模式不是这样。
默认模式下,TUI 主进程启动一个 Worker 线程,Server 代码跑在 Worker 里。两者通过 postMessage RPC 通信,不走真正的 HTTP。URL 是假的 http://opencode.internal——只是一个标识符,没有真正的网络请求。
为什么用 Worker 而不是直接跑?
如果 AI 逻辑和 TUI 渲染在同一个线程里,LLM 的长时间计算会阻塞 UI 渲染——界面完全卡死。Worker 线程把计算移到另一个线程,主线程只负责渲染。
// packages/opencode/src/cli/cmd/tui.ts
const worker = new Worker(file, { env: ... })
const client = Rpc.client<typeof rpc>(worker) // postMessage RPC
const transport = {
url: "http://opencode.internal", // 虚拟 URL,不发起真正的网络请求
fetch: createWorkerFetch(client), // fetch 通过 RPC 代理到 Worker 内的 Server
events: createEventSource(client), // 事件通过 RPC emit 转发
}
翻译:
# Python 等价:用 multiprocessing 或 threading import multiprocessing def run_tui(): worker = multiprocessing.Process(target=run_server) # 对应 new Worker() worker.start() # 主进程通过 Queue/Pipe 和子进程通信(对应 postMessage RPC) # url 是假的,真正的通信走进程间消息 transport_url = "http://opencode.internal" # 只是标识符关键语法:
TS 写法 含义 new Worker(file, { env })启动一个 Worker 线程,执行 file 指定的代码。Python 类似 multiprocessing.Process(target=file)Rpc.client(rpc)(worker)基于 Worker 创建一个 RPC 客户端。Python 类似通过 multiprocessing.Queue包装出的远程调用代理createWorkerFetch(client)把 fetch(url)拦截,转发成 RPC 消息发给 Worker 内的 Server。相当于「假的 HTTP 层,真正走进程间通信」
外部模式:真正启动 HTTP Server
当你传 --port 参数时,才走真正的 HTTP:
// packages/opencode/src/cli/tui/worker.ts
const external = hasArg("--port") || hasArg("--hostname")
if (external) {
server = await Server.listen({ port, hostname, mdns })
return { url: server.url.toString() } // 真正的 HTTP URL
}
这个模式下,Server 是独立的 HTTP 进程,TUI 通过真正的网络请求连接。适合远程访问(比如从手机查看进度)。
任务完成通知:SSE 推送 + 轮询兜底
Worker 内的 Server 跑完一个长任务后,怎么通知 TUI?用 SSE(Server-Sent Events) 推送事件流:
TUI 主进程 ←──SSE 事件流── Worker 内的 Server
↓
session.status 变为 "idle" 时 = 任务完成
但 SSE 有可能丢事件。OpenCode 加了一道保险——同时轮询:
// packages/opencode/src/cli/cmd/run/stream.transport.ts
// 主通道:SSE 事件流
const stream = await sdk.global.event() // GET /global/event
// 兜底通道:每 250ms 轮询一次状态
// 注释原文:"some transports may lose status events but still deliver message events"
setInterval(() => sdk.session.status(), 250)
翻译:
# Python 等价 # 主通道:监听事件流(像监听一个消息队列) for event in server.event_stream(): handle(event) # 兜底:每 250ms 主动查一次状态 # 万一 SSE 丢了 "任务完成" 事件,轮询还能发现 session.status == "idle" while True: status = server.get_status() if status == "idle": on_task_complete() break time.sleep(0.25)
SSE 断线重连:指数退避 + Last-Event-ID
网络模式下 SSE 断了怎么办?SDK 客户端内置了自动重连:
// packages/sdk/js/src/v2/gen/core/serverSentEvents.gen.ts
while (true) {
// 记录 lastEventId,重连时通过 Last-Event-ID 头恢复
const backoff = Math.min(retryDelay * 2 ** (attempt - 1), 30000)
await sleep(backoff)
attempt += 1
}
# Python 等价:指数退避
attempt = 1
while True:
try:
stream = connect_sse(url, headers={"Last-Event-ID": last_event_id})
for event in stream:
last_event_id = event.id # 记住位置
handle(event)
except ConnectionError:
backoff = min(3000 * 2 ** (attempt - 1), 30000) # 3s → 6s → 12s → ... → 30s 封顶
time.sleep(backoff / 1000)
attempt += 1
起始 3 秒,每次翻倍,30 秒封顶。重连时带上 Last-Event-ID 头,Server 就知道从哪里续传。Python 开发者可以理解为:断点续传的事件队列。
怎么停:两阶段关闭握手
讲完了「怎么启动」和「怎么通信」,最后一个关键问题:Server 怎么关闭?
一个长运行 Server 管理着 HTTP 连接、WebSocket、LSP 客户端等资源。关闭时有两种需求:
- 优雅关闭:等正在处理的请求完成,再关闭(不丢失用户正在进行的操作)
- 强制关闭:立刻断开所有连接,马上退出(用户要紧急退出,或 Worker 要被回收)
OpenCode 用一个 close?: boolean 参数控制两种模式。但精妙之处不在于参数本身,而在于两个 flag 的两阶段握手——保证「无论谁先发起关闭,强制模式一定能杀掉所有连接」:
// packages/opencode/src/server/server.ts · serverLayer()
const server = createServer()
const serverRef = { closeStarted: false, forceStop: false } // 两个 flag
const close = server.close.bind(server) // 保存原始的 close 方法
// 第一步:猴子补丁(monkey-patch)server.close
server.close = ((callback) => {
serverRef.closeStarted = true // 标记「关闭已启动」
const result = close(callback) // 调用原始的 close
if (serverRef.forceStop) { // 如果此时 forceStop 已经被设为 true
server.closeAllConnections() // 立刻杀掉所有 TCP 连接
}
return result
}) as typeof server.close
// 第二步:ListenerServerService 的 closeAll 方法
ListenerServerService.of({
closeAll: Effect.sync(() => {
serverRef.forceStop = true // 标记「需要强制关闭」
if (serverRef.closeStarted) { // 如果此时 close 已经启动了
server.closeAllConnections() // 立刻杀掉所有连接
}
}),
})
翻译:
# Python 等价:两阶段握手 class ServerShutdown: def __init__(self): self.close_started = False # 「close() 被调用过了吗?」 self.force_stop = False # 「有人要求强制关闭吗?」 # 路径 A:Effect 的 Scope 关闭时触发(优雅模式) def graceful_close(self): self.close_started = True server.close() # 等连接自然结束 if self.force_stop: # 检查:有人要求过强制吗? server.kill_all_connections() # 如果有,补一刀 # 路径 B:listener.stop(close=True) 时触发(强制模式) def force_close_all(self): self.force_stop = True if self.close_started: # 检查:close 已经开始了吗? server.kill_all_connections() # 如果开始了,立刻杀 # 如果 close 还没开始,不急——等它开始时自己会检查 force_stop
为什么需要两阶段握手? 因为关闭操作有两个触发源:
- Effect 的 Scope 关闭(优雅路径)——当 Worker 线程退出或
serve命令结束时,Scope 自动调用server.close() - 用户的
stop(true)调用(强制路径)——当用户要紧急退出时
这两个操作可能以任意顺序发生。如果只有一个 flag,比如只检查 forceStop:
- 用户先调
stop(true)(设forceStop=true),但此时server.close()还没被调用——连接杀不掉 - 等 Scope 关闭调用
server.close()时,才知道要强制杀——但此时可能已经等了一段时间
两阶段握手机制确保:无论谁先执行,只要两个条件都满足过,closeAllConnections() 一定会被调用。 这在 Python 里相当于一个双线程安全的「一次性触发器」。
makeStop 把这整套逻辑包成了一个可缓存(Effect.cached)的关闭函数——多次调用 stop() 不会重复执行清理:
// packages/opencode/src/server/server.ts · makeStop()
function makeStop(state, unpublishMdns, listenerUrl) {
return Effect.gen(function* () {
// cached 保证只执行一次
const forceCloseOnce = yield* Effect.cached(forceClose(state).pipe(Effect.ignore))
const closeScopeOnce = yield* Effect.cached(
Scope.close(state.scope, Exit.void).pipe(Effect.ignore, Effect.ensuring(...))
)
// 返回真正的 stop 函数
return (close?: boolean) =>
Effect.gen(function* () {
yield* unpublishMdns // 1. 先取消 mDNS 广播
if (close) yield* forceCloseOnce // 2. 如果强制模式,杀连接
yield* closeScopeOnce // 3. 关闭 Scope(触发上面的两阶段握手)
})
})
}
理解:
Effect.cached(X)的效果是「第一次执行 X,之后调用直接返回缓存结果」。Python 类似functools.lru_cache装饰一个无参函数——第一次真正计算,之后直接拿结果。这里用来保证forceCloseOnce和closeScopeOnce无论被调多少次,底层只执行一次。
模式 2:端口透明 + 按需暴露
源码位置: packages/opencode/src/server/server.ts · startWithPortFallback · setupMdns
出乎意料的事实
你可能以为端口选定后会存文件或写环境变量,客户端读文件来发现端口。但 OpenCode 不持久化端口。
默认模式(Worker 线程)下,端口完全对用户透明——Worker 内部拿到端口,主线程通过 RPC 直连,根本不需要知道端口号。只有 --port 外部模式才暴露端口,且由用户显式指定。
端口回退:4096 优先,失败则随机
当走外部模式时,OpenCode 先试默认端口 4096,被占了就让操作系统分配随机端口:
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))
)
}
Python 等价:
def start_with_port_fallback(opts): if opts.port != 0: return start_listener(opts, opts.port) try: return start_listener(opts, 4096) # 先试默认端口 except PortInUseError: return start_listener(opts, 0) # 失败了,让 OS 分配随机端口
当 port = 0 传给 http.Server.listen(0) 时,操作系统会分配一个可用端口。这是 OS 行为,不是 OpenCode 的逻辑。
为什么是 4096? 源码注释写的是 Match the legacy listener port-resolution behavior——为了兼容旧版本的行为。4096 是一个约定俗成的端口(不在 Well-Known 0-1023 范围内,也不在常见服务的 Registered 1024-49151 冲突热点区),选它纯粹是历史惯例。
mDNS:只广播,供外部设备发现
mDNS 解决的是「端口选定后,局域网内其他设备怎么找到我」。OpenCode 用 bonjour-service 库广播自己:
// packages/opencode/src/server/mdns.ts
bonjour.publish({
name: `opencode-${port}`,
type: "http",
host,
port,
txt: { path: "/" }
})
翻译:
# Python 等价 from zeroconf import Zeroconf, ServiceInfo info = ServiceInfo( type_="_http._tcp.local.", name=f"opencode-{port}._http._tcp.local.", addresses=[socket.inet_aton(host)], port=port, ) zeroconf.register_service(info) # 局域网广播
但注意:OpenCode 代码库内没有消费 mDNS 的逻辑。消费方是外部设备——比如你用手机浏览器打开 opencode.local:端口。mDNS 是给手机用的,不是给自己的客户端用的。
源码里还有一个细节:mDNS 只在非 loopback 地址时才广播:
const publish =
opts.mdns && port &&
opts.hostname !== "127.0.0.1" &&
opts.hostname !== "localhost" &&
opts.hostname !== "::1"
if (!publish && opts.mdns) {
yield* Effect.logWarning("mDNS enabled but hostname is loopback; skipping mDNS publish")
}
也就是说,如果你绑定的是 localhost,即使传了 --mdns 参数也不会广播——因为局域网其他设备根本访问不到你。
精妙之处:默认不需要端口
回到整体设计——OpenCode 的默认模式根本不暴露端口(Worker 线程直接 RPC)。端口回退和 mDNS 只在用户主动选择外部模式时才启用。这比「先选端口再连」简单得多:
| 模式 | 端口可见性 | 连接方式 | 适用场景 |
|---|---|---|---|
| 默认(Worker) | 完全透明 | 进程内 RPC | 本地开发 |
--port 外部 | 用户指定或回退 | HTTP + SSE | 远程访问、手机查看 |
attach <url> | 手动传入 | HTTP + SSE | 连接已运行的远程 Server |
模式 3:per-directory 依赖注入(InstanceStore 模式)
源码位置: packages/opencode/src/project/instance-store.ts · packages/opencode/src/effect/instance-state.ts
标准依赖注入的问题
你可能见过 Spring 风格的 DI:全局一个容器,所有 Service 注册在里面。但 OpenCode 有个特殊需求——用户可能同时打开多个项目目录。
如果只有一个全局容器,项目 A 的 Snapshot 服务和项目 B 的会共享同一个实例。但它们操作的 Git 仓库不同、配置不同、LSP 客户端不同。必须每个目录独立一套实例。
InstanceStore:以目录为 key 的实例缓存
OpenCode 的解法是 InstanceStore——一个以项目目录路径为 key 的缓存 Map:
// packages/opencode/src/project/instance-store.ts
// 简化版核心逻辑
const entries = new Map<string, Entry>() // key = 目录路径
function load(directory: string) {
if (entries.has(directory)) {
return entries.get(directory).result // 已有实例,直接复用
}
// 新目录:boot 一个全新的实例上下文
const entry = { result: Deferred(), ctx: null }
entries.set(directory, entry)
// fork 一个 boot 流程,构建该目录专属的 Service 实例树
Effect.runFork(boot(directory, entry))
return entry.result
}
翻译:
# Python 等价 class InstanceStore: def __init__(self): self._cache = {} # key: 目录路径 → value: 该目录的 Service 实例集合 def load(self, directory: str): if directory in self._cache: return self._cache[directory] # 同一目录复用实例 # 新目录:创建全新的 Service 实例树 ctx = InstanceContext(directory=directory, worktree=..., project=...) services = self._boot(ctx) # 构建 Snapshot、LSP、Config... 全部新建 self._cache[directory] = services return services
InstanceContext:每个目录的身份标识
每个项目目录有独立的上下文:
export interface InstanceContext {
directory: string // 项目工作目录(如 /home/user/my-project)
worktree: string // git worktree 根
project: Project.Info
}
切换项目 = 销毁 + 重建
切换项目目录时,不是「修改现有实例」,而是彻底销毁旧实例 + 创建全新实例:
// reload 逻辑(简化)
function reload(directory: string) {
// 1. 等旧实例完成当前操作
await entries.get(directory).result
// 2. 销毁旧实例的全部资源(关闭 LSP、释放 Git 锁、清理文件监听器)
runDisposers(directory)
// 3. 广播销毁事件
emit("server.instance.disposed")
// 4. 重新 boot 新实例
boot(directory)
}
翻译:
# Python 等价 def reload(self, directory: str): old = self._cache[directory] # 销毁旧实例的全部资源 for disposer in old.disposers: disposer() # 关闭 LSP 客户端、释放 Git 锁、停止文件监听... # 重新构建 ctx = InstanceContext(directory=directory, ...) self._cache[directory] = self._boot(ctx)
ScopedCache:per-directory 资源隔离的实现
InstanceStore 底层用 Effect 的 ScopedCache 实现资源隔离。每个目录的实例集有自己的 Scope——目录销毁时,该 Scope 关闭,里面所有资源(LSP 连接、Git 进程、文件监听器)自动逆序清理。
// packages/opencode/src/effect/instance-state.ts
export interface InstanceState<A> {
readonly cache: ScopedCache<string, A> // key = directory
}
理解:
ScopedCache<string, A>就是「以字符串为 key 的缓存,每个 key 对应一个独立的资源 Scope」。你可以理解为 Python 的dict[str, ExitStack]——每个 key 有自己的资源清理栈,删掉 key 时自动关闭该栈。
精妙之处:全局单例 + per-directory 上下文分离
OpenCode 的 DI 系统分两层:
| 层 | 管理者 | 生命周期 | 类比 |
|---|---|---|---|
| 全局服务 | Layer / LayerNode | 进程级单例 | Java Spring 的 @Singleton |
| 目录级服务 | InstanceStore + ScopedCache | 切换目录时销毁重建 | Spring 的 @RequestScope(但以目录而非 HTTP 请求为 scope) |
全局服务(如 InstanceStore 本身、全局 Config)整个进程只有一份。目录级服务(如 Snapshot、LSP Client、Git)每个项目目录独立。这比标准 Spring DI 多了「按业务上下文隔离实例」的能力。
三个模式怎么组合工作
讲完了三个独立模式,最后看它们怎么在一次真实的 opencode 启动中串联起来。
当你在终端敲下 opencode 时,发生的完整流程:
用户运行 opencode
│
├─ 1. TUI 主进程启动
│ └─ 判断:有 --port 参数吗?
│ │
│ ├─ 没有(默认模式):
│ │ └─ 创建 Worker 线程 ──────────────────── 模式 1
│ │ ├─ Worker 内调用 Server.listen()
│ │ │ └─ 端口对主线程透明(RPC 直连)── 模式 2
│ │ └─ 主线程通过 fake URL 通信
│ │
│ └─ 有 --port(外部模式):
│ └─ Worker 内 Server.listen({ port })
│ ├─ 端口回退: 4096 → 随机 ──────────── 模式 2
│ └─ mDNS 广播(非 loopback 时)──────── 模式 2
│
├─ 2. Server 启动后,加载当前目录
│ └─ InstanceStore.load(当前目录) ──────────────── 模式 3
│ ├─ 首次加载:boot 全新实例树(LSP/Git/Config...)
│ └─ 已缓存:直接复用
│
├─ 3. 运行中
│ ├─ 用户切换项目目录 → InstanceStore.reload() ──── 模式 3
│ └─ LLM 跑长任务 → SSE 推送 + 250ms 轮询兜底 ──── 模式 1
│
└─ 4. 关闭
└─ 调用 stop(close?)
├─ close=false: Scope 关闭 → 两阶段握手优雅关闭 ── 模式 1
└─ close=true: forceStop + closeAllConnections 强制杀 ── 模式 1
关键洞察:三个模式不是平行关系,而是嵌套关系。 模式 2(端口策略)是模式 1(进程隔离)在「外部模式」下的一个子行为;模式 3(实例隔离)发生在模式 1 的 Worker 线程内部。它们共同构成一次完整的 Agent 进程生命周期。
小结
| 模式 | 解决的问题 | 核心机制 | 你熟悉的概念 |
|---|---|---|---|
| 进程内隔离 + 事件驱动 | 长任务不卡 UI + 任务状态可观测 | Worker 线程 + SSE 推送 + 轮询兜底 + 两阶段关闭 | Python multiprocessing + 消息队列 |
| 端口透明 + 按需暴露 | 多项目端口冲突 + 远程访问 | 默认不走 HTTP,--port 时才回退 | 进程内直接调用 vs HTTP 微服务 |
| per-directory 依赖注入 | 多项目实例隔离 | InstanceStore + ScopedCache | Spring @RequestScope 以目录为 scope |
这三个模式共同回答一个问题:一个 Agent 进程怎么同时服务多个项目、保持 UI 响应、且切换不串。 这不是简单的「前后端分离」,而是从线程隔离到端口策略到实例隔离的一整套基础设施。