# 新 CLI 与守护进程

> packages/cli 的 lildax 如何用 Effect CLI 声明命令并按需加载处理器，如何在后台拉起、认证并复用 V2 服务，以及它与旧 opencode CLI 共用同一套 TUI。

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

旧的 `opencode` 命令在 `packages/opencode`，由 yargs 驱动，内嵌 worker 与服务端，跑的是 V1 会话主循环（见[启动链路](https://daiw.org/manual/opencode/startup)）。`packages/cli` 是它的继任者雏形：包名 `@opencode-ai/cli`，`package.json` 里的可执行文件叫 `lildax`，根命令描述是 “OpenCode 2.0 preview command line interface”。整个包只有约 700 行，因为它几乎什么都不做：服务在 `packages/server`，内核在 `packages/core`，界面在 `packages/tui`。

## 命令一览

命令树在 `packages/cli/src/commands/commands.ts`。根命令名取编译期常量 `OPENCODE_CLI_NAME`，构建脚本注入 `lildax`，直接跑源码时回退成 `opencode`。

| 命令 | 作用 | 实现要点 |
| --- | --- | --- |
| （无子命令） | 启动 TUI | 向守护进程要连接信息，再调用共享 TUI |
| `serve` | 启动 V2 API 服务 | `--hostname` 默认 `127.0.0.1`；不给 `--port` 就从 4096 往上找空闲端口；`--register` 写注册文件 |
| `service start`、`stop`、`status`、`restart` | 管理后台服务 | 见下文 |
| `service password [value]` | 读取或设置服务密码 | 设置新值前先停服务 |
| `api` | 直接调用服务端接口 | 参数可以是 OpenAPI 的 operationId，也可以是方法加路径；支持 `-d`、`-H`、`--param` |
| `debug agents` | 列出所有 agent | 用 SDK 调 `v2.agent.list` |
| `migrate` | v1 数据迁移到 v2 | 目前只打印 `No migrations to run.` |

`api` 的做法值得一提：给出 operationId 时，它先从服务端拉 `/openapi.json` 找到对应路径，把 `--param` 填进路径参数，剩下的放进查询串。协议即文档，CLI 不必为每个接口写子命令。

## 命令框架：声明与懒加载

`framework/spec.ts` 薄薄包了一层 `effect/unstable/cli` 的 `Command`；`framework/runtime.ts` 把命令树和处理器表逐个对上（`$` 表示节点本身），处理器一律动态 import：

```ts
const Handlers = Runtime.handlers(Commands, {
  $: () => import("./commands/handlers/default"),
  api: () => import("./commands/handlers/api"),
  // ...
  serve: () => import("./commands/handlers/serve"),
})

Runtime.run(Commands, Handlers, { version: "local" }).pipe(
  Effect.provide(Daemon.layer),
  Effect.provide(NodeServices.layer),
  Effect.scoped,
  NodeRuntime.runMain,
)
```

`packages/cli/src/index.ts:10`（删减后）。启动时只加载真正要跑的那个处理器。TUI 抽包规格（`specs/tui-package.md`）要求具名的非 TUI 子命令不得启动或急切初始化 TUI，懒加载就是实现手段。

## 守护进程：在后台拉起并复用服务

`services/daemon.ts` 是这个包的核心。它在 `Global.Path.state` 下维护两个文件：`server.json`（记录服务的 `id`、`version`、`url`、`pid`）和 `password`，都先写临时文件再 rename，权限 `0o600`。密码第一次用 32 字节随机数生成，之后一直复用，注释说明了原因：

> Keep one private credential across server restarts so discovered clients can reconnect without exposing a password flag or environment variable.

`start` 的流程：

```ts
      const existing = yield* healthy().pipe(Effect.option)
      const found = Option.getOrUndefined(existing)
      const compiled = path.basename(process.execPath).replace(/\.exe$/, "") !== "bun"
      if (found?.version === InstallationVersion && compiled) return found.url
      if (found) yield* stopProcess(found).pipe(Effect.ignore)
      // ...
          spawn(process.execPath, [...(entrypoint ? [entrypoint] : []), "serve", "--register"], {
            detached: true,
            stdio: "ignore",
          }).unref()
      // ...
      return yield* compatible().pipe(
        Effect.retry(Schedule.spaced("50 millis").pipe(Schedule.both(Schedule.recurs(100)))),
```

`packages/cli/src/services/daemon.ts:111`（删减后）。健康检查用 SDK 调 `v2.health.get`，2 秒超时，请求带 HTTP Basic 认证（默认用户名 `opencode`）。编译产物遇到同版本的健康服务就直接复用；直接用 bun 跑源码时则每次重启服务，方便开发。新服务以 detached 方式启动，CLI 每 50 毫秒探测一次、最多 100 次，直到服务健康且版本一致。

```mermaid
sequenceDiagram
  participant C as lildax
  participant F as server.json
  participant S as 后台服务
  C->>F: 读取注册信息
  C->>S: 健康检查（Basic 认证）
  alt 健康且版本一致
    S-->>C: 复用现有 url
  else 不存在或不兼容
    C->>S: spawn serve --register
    S->>F: 原子写入 id、version、url、pid
    C->>S: 每 50ms 轮询直到健康
  end
  C->>S: TUI 经 SDK 连接
```

停服务同样谨慎：

> A stale registration may point at a PID that has since been reused by another process. Only signal the PID after authenticating the server.

`stop` 先确认注册的服务能通过认证的健康检查才发 SIGTERM，约 5 秒不退出再发 SIGKILL。服务一侧，`serve --register` 写完注册文件后每 10 秒核对一次：文件已指向别的实例（被新服务取代）就给自己发 SIGTERM；退出时只在文件仍属于自己时才删除它。

`serve` 本身把 `packages/server` 的 `createRoutes(password)` 挂到 Node HTTP 服务器上，再补上凭据与保存的权限两个节点；路由内部如何组装 V2 服务见 [Effect 运行时与 Location](https://daiw.org/manual/opencode/effect-runtime)。设了密码时，服务端的授权中间件强制校验 Basic 认证，也接受 `auth_token` 查询参数。

## 同一个 TUI，两个宿主

`packages/cli/src/tui.ts` 只有 37 行：拿到守护进程给的 `url` 与 `headers`，调用 `@opencode-ai/tui` 导出的 `run`，插件宿主是空实现。关键在它自带的 `gracefulFetch`：新服务端只实现 `/api/...` 这套 V2 协议，TUI 仍会请求的几个旧接口（`/config/providers`、`/provider`、`/agent`、`/config`）返回 404 时，就用空的默认值兜底。这对应 TUI 抽包规格第 9 节的描述：缺失的旧 provider、config 接口暂时退化到共享的“连接 provider”界面。

旧 CLI 这边，`opencode [project]` 与 `attach <url>` 通过 `packages/opencode/src/cli/tui/layer.ts` 调用同一个 `run`，只是传入完整的旧版插件宿主、堆快照回调等。规格第 9、10 节均已标记完成：TUI 是独立包，两个 CLI 都是薄适配器，后端数据走 SDK（详见[终端界面](https://daiw.org/manual/opencode/tui)）。不过规格要求 TUI 不引入 `@opencode-ai/core`，而 v1.18.30 的 `packages/tui` 仍从 core 引入 `Global`、`Flag`、安装版本等少量工具模块，所以两个宿主调用 `run` 时都要提供 `Global.node`。

## 分发

`bin/lildax.cjs` 是一个 Node 启动脚本：按平台、架构、是否支持 AVX2、是否 musl，挑出 `@opencode-ai/cli-<平台>-<架构>` 这一族包里的二进制（也可用 `OPENCODE_BIN_PATH` 指定），并转发 SIGINT、SIGTERM、SIGHUP。二进制由 `script/build.ts` 调用 `Bun.build` 的 compile 模式产出，覆盖 Linux、macOS、Windows 共 12 个目标。

## 与旧 CLI 的关系

| | `opencode`（旧） | `lildax`（新） |
| --- | --- | --- |
| 命令解析 | yargs | `effect/unstable/cli` |
| 服务端 | 进程内 worker，或 `attach` 远程 | 常驻后台守护进程，按版本复用 |
| 会话内核 | V1 主循环 | V2（`/api/session`） |
| TUI | `@opencode-ai/tui` | 同一个包 |

从代码能读出迁移方向：新 CLI 只做“连接 + 管理守护进程”，业务全部经由 HTTP 协议与 SDK；默认命令已能用同一个 TUI 连上 V2 服务端；而 `migrate` 还是空实现，旧数据迁入 V2 尚未落地，新 CLI 仍然是 preview。

下一篇：[收尾复盘](https://daiw.org/manual/opencode/recap)——回看两代架构并存时的全局取舍。
