跳转到内容

CLI / 启动入口

旧版 eec0843c 源码提交 eec0843ce422
状态已完成
难度入门
预计阅读35 分钟
  • 章节 ID:01-cli-startup
  • 章节摘要:从一次带文件的 opencode run 出发,理解 bin、yargs、effectCmd、session 选择、事件订阅,以及本地与远程如何复用统一 SDK/server 边界。
  • 教程版本:eec0843c
  • 源码基线:eec0843ce42298080569ca31a6455bc3f699d213
  • 章节元数据:/versions/eec0843c/data/chapters.json
  • 源码映射:/versions/eec0843c/data/source-map.json
  • packages/opencode/package.json
  • packages/opencode/src/index.ts
  • packages/opencode/src/cli/cmd/run.ts
  • packages/opencode/src/cli/effect-cmd.ts

本章以 OpenCode 源码版本 eec0843ce422 为证据基线。我们追踪一个典型场景:用户在项目目录执行 opencode run "解释这个项目" --file package.json,输入怎样越过 CLI,进入统一的 session API?这是由源码拼出的典型路径,不是一次真实运行录屏。

学完这一章,你应该能够:

  1. 画出 bin -> index.ts -> RunCommand -> SDK -> session.prompt 的启动地图。
  2. 区分 CLI 入口、项目 instance、session runtime 和输出渲染各自的职责。
  3. 沿源码解释 message、stdin、--file 与 session 选择怎样组成一次请求。
  4. 说明本地模式为什么也通过 SDK/API,而不是绕过 server handler。
  5. 判断远程 --attach、权限请求和 session 结束时分别走哪条分支。
  6. 把“薄入口、统一内核”的方法迁移到自己的 mini agent。

OpenCode CLI 是一个输入与运行方式适配器:它解析命令行、确定项目和 session、订阅事件,再把标准化的 parts 通过 SDK 交给同一套 session runtime。

本章只回答一个问题:

从敲下 opencode run ... 到 agent 开始工作,中间到底是谁在搬运和转换输入?

答案不是“CLI 直接调用模型”。CLI 的终点是 client.session.prompt(...);模型、工具与循环都在更深的 session 层。证据见 packages/opencode/src/cli/cmd/run.ts:768-803

packages/opencode/src/cli/cmd/run.ts packages/opencode/src/cli/cmd/run.ts:768-803
768        if (!args.interactive) {区分交互与非交互。769          const events = await client.event.subscribe()订阅运行时事件。770          loop(client, events).catch((e) => {771            console.error(e)772            process.exit(1)773          })774775          if (args.command) {处理命令执行。776            const result = await client.session.command({注册 CLI 子命令。777              sessionID,778              agent,779              model: args.model,选择模型或 provider。780              command: args.command,处理命令执行。781              arguments: message,782              variant: args.variant,783            })784            if (result.error) {按条件进入分支。785              if (!emit("error", { error: result.error })) UI.error(formatRunError(result.error))按条件进入分支。786              process.exitCode = 1787            }788            return返回给上一层。789          }790791          const model = pick(args.model)792          const result = await client.session.prompt({把输入交给会话主流程。793            sessionID,794            agent,795            model,796            variant: args.variant,797            parts: [...files, { type: "text", text: message }],798          })799          if (result.error) {按条件进入分支。800            if (!emit("error", { error: result.error })) UI.error(formatRunError(result.error))按条件进入分支。801            process.exitCode = 1802          }803          return返回给上一层。

2. 为什么先读 CLI,而不是先读模型

Section titled “2. 为什么先读 CLI,而不是先读模型”

学习 Agent 项目时,最容易从“模型 API 在哪里”开始搜索。这样会漏掉三个影响真实行为的问题:

  • 当前工作目录是谁决定的?
  • 新建、继续和 fork 会话的规则在哪里?
  • 文本、管道输入和附件怎样变成同一次 prompt?

这些都发生在模型调用之前。CLI 像服务台:它不处理工单本身,但它决定工单属于哪个项目、哪份档案,以及带了哪些附件。

3. 先画地图:入口到 runtime 的边界

Section titled “3. 先画地图:入口到 runtime 的边界”
操作系统启动 opencode
|
v
packages/opencode/bin/opencode
|
v
src/index.ts 根命令、全局初始化、错误出口
|
v
effectCmd(RunCommand) 加载并最终释放项目 instance
|
v
run.ts 输入、目录、session、事件订阅
|
+---- --attach -------> 远程 HTTP server
|
+---- 本地 -----------> Server.Default().app.fetch
|
v
createOpencodeClient
|
v
client.session.prompt
负责什么不负责什么
index.ts根参数、全局中间件、子命令注册、顶层错误处理不理解 prompt 内容
effectCmd为需要项目状态的命令提供 InstanceRef 并清理不决定 session
RunCommand整理输入、选择 session、连接 server、渲染事件不实现 agent loop
SDK/server统一 API 边界不解析 shell argv
session runtime保存消息并运行 agent不关心输入来自 CLI 还是别的客户端

通用命令行程序也需要前三层;OpenCode 特有的是 session、权限事件、attach 与 in-process server 等产品能力。

1async function runCommand(argv: string[]) {定义一段可复用逻辑。2  const input = parse(argv)3  const project = await openProject(input.directory)4  try {开始保护性执行。5    const client = connectOrCreateLocalClient(input.attach)6    const sessionID = await selectOrCreateSession(client, input)7    render(client.subscribe(sessionID))订阅运行时事件。8    await client.session.prompt({把输入交给会话主流程。9      sessionID,10      parts: [...resolveFiles(input.files), textPart(input.message)],11    })12  } finally {13    await project?.dispose()14  }15}

这不是源码逐行翻译,而是从 index.tseffect-cmd.tsrun.ts 抽出的教学骨架。真实实现还要处理 command 模式、交互模式、认证、分享、JSON 输出和权限响应。

5.1 command 注册不等于 command 执行

Section titled “5.1 command 注册不等于 command 执行”

index.ts 用 yargs 注册 RunCommandServeCommand 等命令;真正的 run handler 在 run.ts。可以把它类比为 Picocli 的根命令与子命令 handler,但 OpenCode 后面用 Effect 组合依赖和资源生命周期。来源:packages/opencode/src/index.ts:70-93packages/opencode/src/index.ts:156-180

packages/opencode/src/index.ts packages/opencode/src/index.ts:70-93
70const cli = yargs(args)创建 CLI 参数解析器。71  .parserConfiguration({ "populate--": true })72  .scriptName("opencode")73  .wrap(100)74  .help("help", "show help")75  .alias("help", "h")76  .version("version", "show version number", InstallationVersion)77  .alias("version", "v")78  .option("print-logs", {声明一个 CLI 选项。79    describe: "print logs to stderr",80    type: "boolean",81  })82  .option("log-level", {声明一个 CLI 选项。83    describe: "log level",84    type: "string",85    choices: ["DEBUG", "INFO", "WARN", "ERROR"],86  })87  .option("pure", {声明一个 CLI 选项。88    describe: "run without external plugins",89    type: "boolean",90  })91  .middleware(async (opts) => {执行前先处理中间件。92    if (opts.pure) {按条件进入分支。93      process.env.OPENCODE_PURE = "1"
packages/opencode/src/index.ts packages/opencode/src/index.ts:156-180
156  .usage("")157  .completion("completion", "generate shell completion script")处理命令执行。158  .command(AcpCommand)注册 CLI 子命令。159  .command(McpCommand)注册 CLI 子命令。160  .command(TuiThreadCommand)注册 CLI 子命令。161  .command(AttachCommand)注册 CLI 子命令。162  .command(RunCommand)注册 CLI 子命令。163  .command(GenerateCommand)注册 CLI 子命令。164  .command(DebugCommand)注册 CLI 子命令。165  .command(ConsoleCommand)注册 CLI 子命令。166  .command(ProvidersCommand)注册 CLI 子命令。167  .command(AgentCommand)注册 CLI 子命令。168  .command(UpgradeCommand)注册 CLI 子命令。169  .command(UninstallCommand)注册 CLI 子命令。170  .command(ServeCommand)注册 CLI 子命令。171  .command(WebCommand)注册 CLI 子命令。172  .command(ModelsCommand)注册 CLI 子命令。173  .command(StatsCommand)注册 CLI 子命令。174  .command(ExportCommand)注册 CLI 子命令。175  .command(ImportCommand)注册 CLI 子命令。176  .command(GithubCommand)注册 CLI 子命令。177  .command(PrCommand)注册 CLI 子命令。178  .command(SessionCommand)注册 CLI 子命令。179  .command(PluginCommand)注册 CLI 子命令。180  .command(DbCommand)注册 CLI 子命令。
  • instance 绑定一个项目目录,承载配置、插件、LSP 等项目级服务;
  • session 是一次可继续、可 fork 的对话/任务档案。

effectCmd 管 instance 生命周期;run.ts 通过 SDK 选择或创建 session。把两者混成“上下文”会看不清资源何时释放、会话为何还能恢复。

CLI 的 loop(...) 只是消费 server 发出的事件并显示文本、reasoning、工具结果、错误与权限请求。真正决定 agent 是否继续的是 session runtime。来源:packages/opencode/src/cli/cmd/run.ts:633-759

packages/opencode/src/cli/cmd/run.ts packages/opencode/src/cli/cmd/run.ts:633-759
633        // Consume one subscribed event stream for the active session and mirror it634        // to stdout/UI. `client` is passed explicitly because attach mode may635        // rebind the SDK to the session's directory after the subscription is636        // created, and replies issued from inside the loop must use that client.637        async function loop(client: OpencodeClient, events: Awaited<ReturnType<typeof sdk.event.subscribe>>) {订阅运行时事件。638          const toggles = new Map<string, boolean>()639          let error: string | undefined640641          for await (const event of events.stream) {消费异步流。642            if (按条件进入分支。643              event.type === "message.updated" &&644              event.properties.sessionID === sessionID &&645              event.properties.info.role === "assistant" &&646              args.format !== "json" &&647              toggles.get("start") !== true648            ) {649              UI.empty()650              UI.println(`> ${event.properties.info.agent} · ${event.properties.info.modelID}`)选择模型或 provider。651              UI.empty()652              toggles.set("start", true)653            }654655            if (event.type === "message.part.updated") {按条件进入分支。656              const part = event.properties.part657              if (part.sessionID !== sessionID) continue按条件进入分支。658659              if (part.type === "tool" && (part.state.status === "completed" || part.state.status === "error")) {按条件进入分支。660                if (emit("tool_use", { part })) continue按条件进入分支。661                if (part.state.status === "completed") {按条件进入分支。662                  await tool(part)声明可调用工具。663                  continue664                }665                await toolError(part)666                UI.error(part.state.error)667              }668669              if (按条件进入分支。670                part.type === "tool" &&671                part.tool === "task" &&672                part.state.status === "running" &&673                args.format !== "json"674              ) {675                if (toggles.get(part.id) === true) continue按条件进入分支。676                await tool(part)声明可调用工具。677                toggles.set(part.id, true)678              }679680              if (part.type === "step-start") {按条件进入分支。681                if (emit("step_start", { part })) continue按条件进入分支。682              }683684              if (part.type === "step-finish") {按条件进入分支。685                if (emit("step_finish", { part })) continue按条件进入分支。686              }687688              if (part.type === "text" && part.time?.end) {按条件进入分支。689                if (emit("text", { part })) continue按条件进入分支。690                const text = part.text.trim()691                if (!text) continue按条件进入分支。692                if (!process.stdout.isTTY) {按条件进入分支。693                  process.stdout.write(text + EOL)694                  continue695                }696                UI.empty()697                UI.println(text)698                UI.empty()699              }700701              if (part.type === "reasoning" && part.time?.end && thinking) {按条件进入分支。702                if (emit("reasoning", { part })) continue按条件进入分支。703                const text = part.text.trim()704                if (!text) continue按条件进入分支。705                const line = `Thinking: ${text}`706                if (process.stdout.isTTY) {按条件进入分支。707                  UI.empty()708                  UI.println(`${UI.Style.TEXT_DIM}\u001b[3m${line}\u001b[0m${UI.Style.TEXT_NORMAL}`)709                  UI.empty()710                  continue711                }712                process.stdout.write(line + EOL)713              }714            }715716            if (event.type === "session.error") {按条件进入分支。717              const props = event.properties718              if (props.sessionID !== sessionID || !props.error) continue按条件进入分支。719              let err = String(props.error.name)720              if ("data" in props.error && props.error.data && "message" in props.error.data) {按条件进入分支。721                err = String(props.error.data.message)722              }723              error = error ? error + EOL + err : err724              if (emit("error", { error: props.error })) continue按条件进入分支。725              UI.error(err)726            }727728            if (按条件进入分支。729              event.type === "session.status" &&730              event.properties.sessionID === sessionID &&731              event.properties.status.type === "idle"732            ) {733              break734            }735736            if (event.type === "permission.asked") {进入权限审批。737              const permission = event.properties738              if (permission.sessionID !== sessionID) continue按条件进入分支。739740              if (args["dangerously-skip-permissions"]) {按条件进入分支。741                await client.permission.reply({回写审批结果。742                  requestID: permission.id,743                  reply: "once",744                })745              } else {746                UI.println(747                  UI.Style.TEXT_WARNING_BOLD + "!",748                  UI.Style.TEXT_NORMAL +749                    `permission requested: ${permission.permission} (${permission.patterns.join(", ")}); auto-rejecting`,750                )751                await client.permission.reply({回写审批结果。752                  requestID: permission.id,753                  reply: "reject",754                })755              }756            }757          }758          return error返回给上一层。759        }

6. 追一条典型源码旅程:带 package.json 运行

Section titled “6. 追一条典型源码旅程:带 package.json 运行”

假设用户执行:

1opencode run "解释这个项目" --file package.json

下面只追非交互、本地模式的典型路径。

6.1 第一站:包入口把 opencode 交给启动脚本

Section titled “6.1 第一站:包入口把 opencode 交给启动脚本”

package.jsonbin 字段把命令名映射到 ./bin/opencode

1"bin": {2  "opencode": "./bin/opencode"3}

来源:packages/opencode/package.json:21-23

packages/opencode/package.json packages/opencode/package.json:21-23
21  "bin": {22    "opencode": "./bin/opencode"23  },

这只证明操作系统入口指向启动脚本;本章不展开该脚本的平台分发细节。源码主入口随后落在 src/index.ts

6.2 第二站:index.ts 建根命令并完成全局初始化

Section titled “6.2 第二站:index.ts 建根命令并完成全局初始化”

hideBin(process.argv) 去掉 runtime 与脚本位置,yargs 接管剩余参数。全局 middleware 处理 --pure、日志、Heap 与进程标识,然后命令表注册 RunCommand。来源:packages/opencode/src/index.ts:58-110packages/opencode/src/index.ts:156-180

packages/opencode/src/index.ts packages/opencode/src/index.ts:58-110
58const args = hideBin(process.argv)5960function show(out: string) {定义一段可复用逻辑。61  const text = out.trimStart()62  if (!text.startsWith("opencode ")) {按条件进入分支。63    process.stderr.write(UI.logo() + EOL + EOL)64    process.stderr.write(text)65    return返回给上一层。66  }67  process.stderr.write(out)68}6970const cli = yargs(args)创建 CLI 参数解析器。71  .parserConfiguration({ "populate--": true })72  .scriptName("opencode")73  .wrap(100)74  .help("help", "show help")75  .alias("help", "h")76  .version("version", "show version number", InstallationVersion)77  .alias("version", "v")78  .option("print-logs", {声明一个 CLI 选项。79    describe: "print logs to stderr",80    type: "boolean",81  })82  .option("log-level", {声明一个 CLI 选项。83    describe: "log level",84    type: "string",85    choices: ["DEBUG", "INFO", "WARN", "ERROR"],86  })87  .option("pure", {声明一个 CLI 选项。88    describe: "run without external plugins",89    type: "boolean",90  })91  .middleware(async (opts) => {执行前先处理中间件。92    if (opts.pure) {按条件进入分支。93      process.env.OPENCODE_PURE = "1"94    }9596    await Log.init({97      print: process.argv.includes("--print-logs"),98      dev: Installation.isLocal(),99      level: (() => {100        if (opts.logLevel) return opts.logLevel as Log.Level按条件进入分支。101        if (Installation.isLocal()) return "DEBUG"按条件进入分支。102        return "INFO"返回给上一层。103      })(),104    })105106    Heap.start()107108    process.env.AGENT = "1"109    process.env.OPENCODE = "1"110    process.env.OPENCODE_PID = String(process.pid)
packages/opencode/src/index.ts packages/opencode/src/index.ts:156-180
156  .usage("")157  .completion("completion", "generate shell completion script")处理命令执行。158  .command(AcpCommand)注册 CLI 子命令。159  .command(McpCommand)注册 CLI 子命令。160  .command(TuiThreadCommand)注册 CLI 子命令。161  .command(AttachCommand)注册 CLI 子命令。162  .command(RunCommand)注册 CLI 子命令。163  .command(GenerateCommand)注册 CLI 子命令。164  .command(DebugCommand)注册 CLI 子命令。165  .command(ConsoleCommand)注册 CLI 子命令。166  .command(ProvidersCommand)注册 CLI 子命令。167  .command(AgentCommand)注册 CLI 子命令。168  .command(UpgradeCommand)注册 CLI 子命令。169  .command(UninstallCommand)注册 CLI 子命令。170  .command(ServeCommand)注册 CLI 子命令。171  .command(WebCommand)注册 CLI 子命令。172  .command(ModelsCommand)注册 CLI 子命令。173  .command(StatsCommand)注册 CLI 子命令。174  .command(ExportCommand)注册 CLI 子命令。175  .command(ImportCommand)注册 CLI 子命令。176  .command(GithubCommand)注册 CLI 子命令。177  .command(PrCommand)注册 CLI 子命令。178  .command(SessionCommand)注册 CLI 子命令。179  .command(PluginCommand)注册 CLI 子命令。180  .command(DbCommand)注册 CLI 子命令。
根命令与全局中间件 packages/opencode/src/index.ts:70-110

先看所有子命令共享的启动工作,再进入 run 的业务参数。

70const cli = yargs(args)创建 CLI 参数解析器。71  .parserConfiguration({ "populate--": true })72  .scriptName("opencode")73  .wrap(100)74  .help("help", "show help")75  .alias("help", "h")76  .version("version", "show version number", InstallationVersion)77  .alias("version", "v")78  .option("print-logs", {声明一个 CLI 选项。79    describe: "print logs to stderr",80    type: "boolean",81  })82  .option("log-level", {声明一个 CLI 选项。83    describe: "log level",84    type: "string",85    choices: ["DEBUG", "INFO", "WARN", "ERROR"],86  })87  .option("pure", {声明一个 CLI 选项。88    describe: "run without external plugins",89    type: "boolean",90  })91  .middleware(async (opts) => {执行前先处理中间件。92    if (opts.pure) {按条件进入分支。93      process.env.OPENCODE_PURE = "1"94    }9596    await Log.init({97      print: process.argv.includes("--print-logs"),98      dev: Installation.isLocal(),99      level: (() => {100        if (opts.logLevel) return opts.logLevel as Log.Level按条件进入分支。101        if (Installation.isLocal()) return "DEBUG"按条件进入分支。102        return "INFO"返回给上一层。103      })(),104    })105106    Heap.start()107108    process.env.AGENT = "1"109    process.env.OPENCODE = "1"110    process.env.OPENCODE_PID = String(process.pid)

为什么把这些放在根命令?因为日志和运行标识是所有子命令的共同横切关注点,不应复制到每个 handler。

6.3 第三站:effectCmd 为 handler 装上项目运行时外壳

Section titled “6.3 第三站:effectCmd 为 handler 装上项目运行时外壳”

RunCommand 声明:本地运行需要 instance,--attach 连接远程 server 时不需要本地 instance。

1instance: (args) => !args.attach,2directory: (args) =>3  args.dir && !args.attach4    ? path.resolve(process.cwd(), args.dir)5    : process.cwd(),

节选自 packages/opencode/src/cli/cmd/run.ts:127-135

packages/opencode/src/cli/cmd/run.ts packages/opencode/src/cli/cmd/run.ts:127-135
127export const RunCommand = effectCmd({对外暴露模块成员。128  command: "run [message..]",处理命令执行。129  describe: "run opencode with a message",130  // --attach connects to a remote server (no local instance needed); the131  // default path runs an in-process server and needs the project instance.132  instance: (args) => !args.attach,133  // For --dir without --attach, load instance for the resolved target dir.134  // The handler also chdirs (preserving the legacy order: chdir → file resolution).135  directory: (args) => (args.dir && !args.attach ? path.resolve(process.cwd(), args.dir) : process.cwd()),

effectCmd 根据这个决定加载 InstanceStore,向 handler 提供 InstanceRef,并在 finally 中调用 store.dispose(ctx)。来源:packages/opencode/src/cli/effect-cmd.ts:70-93

packages/opencode/src/cli/effect-cmd.ts packages/opencode/src/cli/effect-cmd.ts:70-93
70export const effectCmd = <Args, A>(opts: EffectCmdOpts<Args, A>) =>对外暴露模块成员。71  cmd<{}, Args>({72    command: opts.command,处理命令执行。73    aliases: opts.aliases,74    describe: opts.describe,75    builder: opts.builder as never,76    async handler(rawArgs) {77      // yargs typing wraps Args in ArgumentsCamelCase<WithDoubleDash<...>>; cast at the boundary.78      const args = rawArgs as unknown as WithDoubleDash<Args>79      const useInstance = typeof opts.instance === "function" ? opts.instance(args) : opts.instance !== false80      if (!useInstance) {按条件进入分支。81        await AppRuntime.runPromise(opts.handler(args))82        return返回给上一层。83      }84      const directory = opts.directory?.(args) ?? process.cwd()85      const { store, ctx } = await AppRuntime.runPromise(86        InstanceStore.Service.use((store) => store.load({ directory }).pipe(Effect.map((ctx) => ({ store, ctx })))),Effect 异步工作流。87      )88      try {开始保护性执行。89        await AppRuntime.runPromise(opts.handler(args).pipe(Effect.provideService(InstanceRef, ctx)))Effect 异步工作流。90      } finally {91        await AppRuntime.runPromise(store.dispose(ctx))92      }93    },
项目 instance 的加载与释放 packages/opencode/src/cli/effect-cmd.ts:70-93

关注 useInstance 分支和 finally;它们共同定义资源边界。

70export const effectCmd = <Args, A>(opts: EffectCmdOpts<Args, A>) =>对外暴露模块成员。71  cmd<{}, Args>({72    command: opts.command,处理命令执行。73    aliases: opts.aliases,74    describe: opts.describe,75    builder: opts.builder as never,76    async handler(rawArgs) {77      // yargs typing wraps Args in ArgumentsCamelCase<WithDoubleDash<...>>; cast at the boundary.78      const args = rawArgs as unknown as WithDoubleDash<Args>79      const useInstance = typeof opts.instance === "function" ? opts.instance(args) : opts.instance !== false80      if (!useInstance) {按条件进入分支。81        await AppRuntime.runPromise(opts.handler(args))82        return返回给上一层。83      }84      const directory = opts.directory?.(args) ?? process.cwd()85      const { store, ctx } = await AppRuntime.runPromise(86        InstanceStore.Service.use((store) => store.load({ directory }).pipe(Effect.map((ctx) => ({ store, ctx })))),Effect 异步工作流。87      )88      try {开始保护性执行。89        await AppRuntime.runPromise(opts.handler(args).pipe(Effect.provideService(InstanceRef, ctx)))Effect 异步工作流。90      } finally {91        await AppRuntime.runPromise(store.dispose(ctx))92      }93    },

Java 可以把它类比为命令拦截器加 try-with-resources。类比的边界是:Effect 还编码服务依赖与错误通道,不只是一个普通 AOP wrapper。

6.4 第四站:message、stdin 和文件被整理成 parts

Section titled “6.4 第四站:message、stdin 和文件被整理成 parts”

run.ts 先把位置参数与 -- 后的参数拼成 message。若 stdin 不是 TTY,还会读取管道输入;两者同时存在时用换行连接。来源:packages/opencode/src/cli/cmd/run.ts:43-53packages/opencode/src/cli/cmd/run.ts:251-267packages/opencode/src/cli/cmd/run.ts:356-363

packages/opencode/src/cli/cmd/run.ts packages/opencode/src/cli/cmd/run.ts:43-53
43function resolveRunInput(value?: string, piped?: string): string | undefined {定义一段可复用逻辑。44  if (!value) {按条件进入分支。45    return piped返回给上一层。46  }4748  if (!piped) {按条件进入分支。49    return value返回给上一层。50  }5152  return value + "\n" + piped返回给上一层。53}
packages/opencode/src/cli/cmd/run.ts packages/opencode/src/cli/cmd/run.ts:251-267
251      const rawMessage = [...args.message, ...(args["--"] || [])].join(" ")252      const thinking = args.interactive ? (args.thinking ?? true) : (args.thinking ?? false)区分交互与非交互。253      const die = (message: string): never => {254        UI.error(message)255        process.exit(1)256      }257      const dieInteractive = (error: unknown): never => {258        if (error instanceof Error && error.message === INTERACTIVE_INPUT_ERROR) {按条件进入分支。259          die(error.message)260        }261262        throw error失败时抛出错误。263      }264265      let message = [...args.message, ...(args["--"] || [])]266        .map((arg) => (arg.includes(" ") ? `"${arg.replace(/"/g, '\\"')}"` : arg))267        .join(" ")
packages/opencode/src/cli/cmd/run.ts packages/opencode/src/cli/cmd/run.ts:356-363
356      const piped = process.stdin.isTTY ? undefined : await Bun.stdin.text()357      message = resolveRunInput(message, piped) ?? ""358      const initialInput = resolveRunInput(rawMessage, piped)359360      if (message.trim().length === 0 && !args.command && !args.interactive) {区分交互与非交互。361        UI.error("You must provide a message or a command")处理命令执行。362        process.exit(1)363      }

--file package.json,CLI 在确定目录后:

  1. 把相对路径解析为绝对路径;
  2. 检查目标存在;
  3. 区分目录与普通文本;
  4. 生成 { type: "file", url, filename, mime }

来源:packages/opencode/src/cli/cmd/run.ts:310-354

packages/opencode/src/cli/cmd/run.ts packages/opencode/src/cli/cmd/run.ts:310-354
310      const root = Filesystem.resolve(process.env.PWD ?? process.cwd())311      const directory = (() => {312        if (!args.dir) return args.attach ? undefined : root按条件进入分支。313        if (args.attach) return args.dir按条件进入分支。314315        try {开始保护性执行。316          process.chdir(path.isAbsolute(args.dir) ? args.dir : path.join(root, args.dir))317          return process.cwd()返回给上一层。318        } catch {319          UI.error("Failed to change directory to " + args.dir)320          process.exit(1)321        }322      })()323      const attachHeaders = args.attach324        ? ServerAuth.headers({ password: args.password, username: args.username })325        : undefined326      const attachSDK = (dir?: string) => {327        return createOpencodeClient({创建 SDK 客户端。328          baseUrl: args.attach!,329          directory: dir,330          headers: attachHeaders,331        })332      }333334      const files: FilePart[] = []335      if (args.file) {按条件进入分支。336        const list = Array.isArray(args.file) ? args.file : [args.file]337338        for (const filePath of list) {遍历集合。339          const resolvedPath = path.resolve(args.attach ? root : (directory ?? root), filePath)340          if (!(await Filesystem.exists(resolvedPath))) {按条件进入分支。341            UI.error(`File not found: ${filePath}`)342            process.exit(1)343          }344345          const mime = (await Filesystem.isDir(resolvedPath)) ? "application/x-directory" : "text/plain"346347          files.push({348            type: "file",349            url: pathToFileURL(resolvedPath).href,350            filename: path.basename(resolvedPath),351            mime,352          })353        }354      }

注意边界:这里没有读取文件正文,只构造 file part。文件怎样成为模型上下文是下一章 SessionPrompt.createUserMessage 的职责。

6.5 第五站:先确定档案,再发送委托

Section titled “6.5 第五站:先确定档案,再发送委托”

session(sdk) 的决策顺序是:

显式 --session -> 读取指定 session -> 可选 fork
否则 --continue -> 找最近的根 session -> 可选 fork
否则 -> 创建新 session,并写入 CLI 权限规则

证据见 packages/opencode/src/cli/cmd/run.ts:396-473

packages/opencode/src/cli/cmd/run.ts packages/opencode/src/cli/cmd/run.ts:396-473
396      async function session(sdk: OpencodeClient): Promise<SessionInfo | undefined> {定义一段可复用逻辑。397        if (args.session) {按条件进入分支。398          const current = await sdk.session399            .get({400              sessionID: args.session,401            })402            .catch(() => undefined)403404          if (!current?.data) {按条件进入分支。405            UI.error("Session not found")406            process.exit(1)407          }408409          if (args.fork) {按条件进入分支。410            const forked = await sdk.session.fork({411              sessionID: args.session,412            })413            const id = forked.data?.id414            if (!id) {按条件进入分支。415              return返回给上一层。416            }417418            return {返回给上一层。419              id,420              title: forked.data?.title ?? current.data.title,421              directory: forked.data?.directory ?? current.data.directory,422            }423          }424425          return {返回给上一层。426            id: current.data.id,427            title: current.data.title,428            directory: current.data.directory,429          }430        }431432        const base = args.continue ? (await sdk.session.list()).data?.find((item) => !item.parentID) : undefined433434        if (base && args.fork) {按条件进入分支。435          const forked = await sdk.session.fork({436            sessionID: base.id,437          })438          const id = forked.data?.id439          if (!id) {按条件进入分支。440            return返回给上一层。441          }442443          return {返回给上一层。444            id,445            title: forked.data?.title ?? base.title,446            directory: forked.data?.directory ?? base.directory,447          }448        }449450        if (base) {按条件进入分支。451          return {返回给上一层。452            id: base.id,453            title: base.title,454            directory: base.directory,455          }456        }457458        const name = title()459        const result = await sdk.session.create({460          title: name,461          permission: rules,462        })463        const id = result.data?.id464        if (!id) {按条件进入分支。465          return返回给上一层。466        }467468        return {返回给上一层。469          id,470          title: result.data?.title ?? name,471          directory: result.data?.directory,472        }473      }
创建、继续或 fork session packages/opencode/src/cli/cmd/run.ts:396-473

把它读成决策树,不要逐行背 SDK 调用。

396      async function session(sdk: OpencodeClient): Promise<SessionInfo | undefined> {定义一段可复用逻辑。397        if (args.session) {按条件进入分支。398          const current = await sdk.session399            .get({400              sessionID: args.session,401            })402            .catch(() => undefined)403404          if (!current?.data) {按条件进入分支。405            UI.error("Session not found")406            process.exit(1)407          }408409          if (args.fork) {按条件进入分支。410            const forked = await sdk.session.fork({411              sessionID: args.session,412            })413            const id = forked.data?.id414            if (!id) {按条件进入分支。415              return返回给上一层。416            }417418            return {返回给上一层。419              id,420              title: forked.data?.title ?? current.data.title,421              directory: forked.data?.directory ?? current.data.directory,422            }423          }424425          return {返回给上一层。426            id: current.data.id,427            title: current.data.title,428            directory: current.data.directory,429          }430        }431432        const base = args.continue ? (await sdk.session.list()).data?.find((item) => !item.parentID) : undefined433434        if (base && args.fork) {按条件进入分支。435          const forked = await sdk.session.fork({436            sessionID: base.id,437          })438          const id = forked.data?.id439          if (!id) {按条件进入分支。440            return返回给上一层。441          }442443          return {返回给上一层。444            id,445            title: forked.data?.title ?? base.title,446            directory: forked.data?.directory ?? base.directory,447          }448        }449450        if (base) {按条件进入分支。451          return {返回给上一层。452            id: base.id,453            title: base.title,454            directory: base.directory,455          }456        }457458        const name = title()459        const result = await sdk.session.create({460          title: name,461          permission: rules,462        })463        const id = result.data?.id464        if (!id) {按条件进入分支。465          return返回给上一层。466        }467468        return {返回给上一层。469          id,470          title: result.data?.title ?? name,471          directory: result.data?.directory,472        }473      }

session-aware 的意义是:CLI 进程可以退出,但任务档案仍能被继续;fork 则允许从旧历史分出新路线,而不覆盖原会话。

非交互路径先调用 client.event.subscribe(),启动 CLI 渲染循环,然后才调用 client.session.prompt(...)

1const events = await client.event.subscribe()订阅运行时事件。2loop(client, events).catch(/* ... */)34const result = await client.session.prompt({把输入交给会话主流程。5  sessionID,6  agent,7  model,8  variant: args.variant,9  parts: [...files, { type: "text", text: message }],10})

节选自 packages/opencode/src/cli/cmd/run.ts:768-803

packages/opencode/src/cli/cmd/run.ts packages/opencode/src/cli/cmd/run.ts:768-803
768        if (!args.interactive) {区分交互与非交互。769          const events = await client.event.subscribe()订阅运行时事件。770          loop(client, events).catch((e) => {771            console.error(e)772            process.exit(1)773          })774775          if (args.command) {处理命令执行。776            const result = await client.session.command({注册 CLI 子命令。777              sessionID,778              agent,779              model: args.model,选择模型或 provider。780              command: args.command,处理命令执行。781              arguments: message,782              variant: args.variant,783            })784            if (result.error) {按条件进入分支。785              if (!emit("error", { error: result.error })) UI.error(formatRunError(result.error))按条件进入分支。786              process.exitCode = 1787            }788            return返回给上一层。789          }790791          const model = pick(args.model)792          const result = await client.session.prompt({把输入交给会话主流程。793            sessionID,794            agent,795            model,796            variant: args.variant,797            parts: [...files, { type: "text", text: message }],798          })799          if (result.error) {按条件进入分支。800            if (!emit("error", { error: result.error })) UI.error(formatRunError(result.error))按条件进入分支。801            process.exitCode = 1802          }803          return返回给上一层。

先订阅可减少漏掉早期事件的风险。之后 agent 输出并不是由 prompt 返回值逐字打印,而是通过 session 事件持续渲染。

若未指定 --attach,CLI 构造一个 in-process fetch

1const fetchFn = async (input, init) => {2  const { Server } = await import("@/server/server")按需加载模块。3  return Server.Default().app.fetch(new Request(input, init))复用后端请求入口。4}56const sdk = createOpencodeClient({创建 SDK 客户端。7  baseUrl: "http://opencode.internal",8  fetch: fetchFn,9  directory,10})

节选自 packages/opencode/src/cli/cmd/run.ts:869-879

packages/opencode/src/cli/cmd/run.ts packages/opencode/src/cli/cmd/run.ts:869-879
869      const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => {870        const { Server } = await import("@/server/server")按需加载模块。871        const request = new Request(input, init)872        return Server.Default().app.fetch(request)复用后端请求入口。873      }) as typeof globalThis.fetch874      const sdk = createOpencodeClient({创建 SDK 客户端。875        baseUrl: "http://opencode.internal",876        fetch: fetchFn,877        directory,878      })879      await execute(sdk)进入 CLI 主执行路径。

这里没有网络回环的必然开销,却复用了 SDK 的请求形状和 server handler。--attach 则让同一个 execute(sdk) 使用远程 base URL。来源:packages/opencode/src/cli/cmd/run.ts:323-332packages/opencode/src/cli/cmd/run.ts:760-766packages/opencode/src/cli/cmd/run.ts:864-879

packages/opencode/src/cli/cmd/run.ts packages/opencode/src/cli/cmd/run.ts:323-332
323      const attachHeaders = args.attach324        ? ServerAuth.headers({ password: args.password, username: args.username })325        : undefined326      const attachSDK = (dir?: string) => {327        return createOpencodeClient({创建 SDK 客户端。328          baseUrl: args.attach!,329          directory: dir,330          headers: attachHeaders,331        })332      }
packages/opencode/src/cli/cmd/run.ts packages/opencode/src/cli/cmd/run.ts:760-766
760        const cwd = args.attach ? (directory ?? sess.directory ?? (await current(sdk))) : (directory ?? root)761        const client = args.attach ? attachSDK(cwd) : sdk762763        // Validate agent if specified764        const agent = await pickAgent(client)765766        await share(client, sessionID)
packages/opencode/src/cli/cmd/run.ts packages/opencode/src/cli/cmd/run.ts:864-879
864      if (args.attach) {按条件进入分支。865        const sdk = attachSDK(directory)866        return await execute(sdk)进入 CLI 主执行路径。867      }868869      const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => {870        const { Server } = await import("@/server/server")按需加载模块。871        const request = new Request(input, init)872        return Server.Default().app.fetch(request)复用后端请求入口。873      }) as typeof globalThis.fetch874      const sdk = createOpencodeClient({创建 SDK 客户端。875        baseUrl: "http://opencode.internal",876        fetch: fetchFn,877        directory,878      })879      await execute(sdk)进入 CLI 主执行路径。

这条边界带来一个重要性质:本地 CLI 与远程客户端不必各实现一套 session 业务。

6.8 第八站:CLI 等到 session idle,而不是猜回答结束

Section titled “6.8 第八站:CLI 等到 session idle,而不是猜回答结束”

事件循环按当前 sessionID 过滤事件:

  • message.updated 显示本轮 agent/model;
  • message.part.updated 显示工具、文本、reasoning 与 step;
  • session.error 记录错误;
  • session.status 变为 idle 时退出;
  • permission.asked 在非交互默认拒绝,除非显式使用危险的跳过权限选项。

来源:packages/opencode/src/cli/cmd/run.ts:641-757

packages/opencode/src/cli/cmd/run.ts packages/opencode/src/cli/cmd/run.ts:641-757
641          for await (const event of events.stream) {消费异步流。642            if (按条件进入分支。643              event.type === "message.updated" &&644              event.properties.sessionID === sessionID &&645              event.properties.info.role === "assistant" &&646              args.format !== "json" &&647              toggles.get("start") !== true648            ) {649              UI.empty()650              UI.println(`> ${event.properties.info.agent} · ${event.properties.info.modelID}`)选择模型或 provider。651              UI.empty()652              toggles.set("start", true)653            }654655            if (event.type === "message.part.updated") {按条件进入分支。656              const part = event.properties.part657              if (part.sessionID !== sessionID) continue按条件进入分支。658659              if (part.type === "tool" && (part.state.status === "completed" || part.state.status === "error")) {按条件进入分支。660                if (emit("tool_use", { part })) continue按条件进入分支。661                if (part.state.status === "completed") {按条件进入分支。662                  await tool(part)声明可调用工具。663                  continue664                }665                await toolError(part)666                UI.error(part.state.error)667              }668669              if (按条件进入分支。670                part.type === "tool" &&671                part.tool === "task" &&672                part.state.status === "running" &&673                args.format !== "json"674              ) {675                if (toggles.get(part.id) === true) continue按条件进入分支。676                await tool(part)声明可调用工具。677                toggles.set(part.id, true)678              }679680              if (part.type === "step-start") {按条件进入分支。681                if (emit("step_start", { part })) continue按条件进入分支。682              }683684              if (part.type === "step-finish") {按条件进入分支。685                if (emit("step_finish", { part })) continue按条件进入分支。686              }687688              if (part.type === "text" && part.time?.end) {按条件进入分支。689                if (emit("text", { part })) continue按条件进入分支。690                const text = part.text.trim()691                if (!text) continue按条件进入分支。692                if (!process.stdout.isTTY) {按条件进入分支。693                  process.stdout.write(text + EOL)694                  continue695                }696                UI.empty()697                UI.println(text)698                UI.empty()699              }700701              if (part.type === "reasoning" && part.time?.end && thinking) {按条件进入分支。702                if (emit("reasoning", { part })) continue按条件进入分支。703                const text = part.text.trim()704                if (!text) continue按条件进入分支。705                const line = `Thinking: ${text}`706                if (process.stdout.isTTY) {按条件进入分支。707                  UI.empty()708                  UI.println(`${UI.Style.TEXT_DIM}\u001b[3m${line}\u001b[0m${UI.Style.TEXT_NORMAL}`)709                  UI.empty()710                  continue711                }712                process.stdout.write(line + EOL)713              }714            }715716            if (event.type === "session.error") {按条件进入分支。717              const props = event.properties718              if (props.sessionID !== sessionID || !props.error) continue按条件进入分支。719              let err = String(props.error.name)720              if ("data" in props.error && props.error.data && "message" in props.error.data) {按条件进入分支。721                err = String(props.error.data.message)722              }723              error = error ? error + EOL + err : err724              if (emit("error", { error: props.error })) continue按条件进入分支。725              UI.error(err)726            }727728            if (按条件进入分支。729              event.type === "session.status" &&730              event.properties.sessionID === sessionID &&731              event.properties.status.type === "idle"732            ) {733              break734            }735736            if (event.type === "permission.asked") {进入权限审批。737              const permission = event.properties738              if (permission.sessionID !== sessionID) continue按条件进入分支。739740              if (args["dangerously-skip-permissions"]) {按条件进入分支。741                await client.permission.reply({回写审批结果。742                  requestID: permission.id,743                  reply: "once",744                })745              } else {746                UI.println(747                  UI.Style.TEXT_WARNING_BOLD + "!",748                  UI.Style.TEXT_NORMAL +749                    `permission requested: ${permission.permission} (${permission.patterns.join(", ")}); auto-rejecting`,750                )751                await client.permission.reply({回写审批结果。752                  requestID: permission.id,753                  reply: "reject",754                })755              }756            }757          }

这说明 CLI 用 runtime 的状态作为完成信号,而不是依赖“最后一段文本出现了”这种脆弱判断。

若传入 --command,CLI 调用 client.session.command(...);否则才构造 text/file parts 调用 session.prompt(...)。来源:packages/opencode/src/cli/cmd/run.ts:775-803。因此不能把所有 opencode run 都描述成 session.prompt

packages/opencode/src/cli/cmd/run.ts packages/opencode/src/cli/cmd/run.ts:775-803
775          if (args.command) {处理命令执行。776            const result = await client.session.command({注册 CLI 子命令。777              sessionID,778              agent,779              model: args.model,选择模型或 provider。780              command: args.command,处理命令执行。781              arguments: message,782              variant: args.variant,783            })784            if (result.error) {按条件进入分支。785              if (!emit("error", { error: result.error })) UI.error(formatRunError(result.error))按条件进入分支。786              process.exitCode = 1787            }788            return返回给上一层。789          }790791          const model = pick(args.model)792          const result = await client.session.prompt({把输入交给会话主流程。793            sessionID,794            agent,795            model,796            variant: args.variant,797            parts: [...files, { type: "text", text: message }],798          })799          if (result.error) {按条件进入分支。800            if (!emit("error", { error: result.error })) UI.error(formatRunError(result.error))按条件进入分支。801            process.exitCode = 1802          }803          return返回给上一层。

本地 --dir 会改变进程目录;attach 模式下 --dir 直接作为远程 server 的目录字符串传给 SDK。来源:packages/opencode/src/cli/cmd/run.ts:310-332。两种模式都叫 directory,但解析权不同。

packages/opencode/src/cli/cmd/run.ts packages/opencode/src/cli/cmd/run.ts:310-332
310      const root = Filesystem.resolve(process.env.PWD ?? process.cwd())311      const directory = (() => {312        if (!args.dir) return args.attach ? undefined : root按条件进入分支。313        if (args.attach) return args.dir按条件进入分支。314315        try {开始保护性执行。316          process.chdir(path.isAbsolute(args.dir) ? args.dir : path.join(root, args.dir))317          return process.cwd()返回给上一层。318        } catch {319          UI.error("Failed to change directory to " + args.dir)320          process.exit(1)321        }322      })()323      const attachHeaders = args.attach324        ? ServerAuth.headers({ password: args.password, username: args.username })325        : undefined326      const attachSDK = (dir?: string) => {327        return createOpencodeClient({创建 SDK 客户端。328          baseUrl: args.attach!,329          directory: dir,330          headers: attachHeaders,331        })332      }

7.3 非交互模式不能停在无人回答的权限框

Section titled “7.3 非交互模式不能停在无人回答的权限框”

CLI 收到 permission.asked 时默认回复 reject;只有 --dangerously-skip-permissions 才回复 once。来源:packages/opencode/src/cli/cmd/run.ts:736-755。这是非交互可终止性的保护,也是危险开关名字如此直白的原因。

packages/opencode/src/cli/cmd/run.ts packages/opencode/src/cli/cmd/run.ts:736-755
736            if (event.type === "permission.asked") {进入权限审批。737              const permission = event.properties738              if (permission.sessionID !== sessionID) continue按条件进入分支。739740              if (args["dangerously-skip-permissions"]) {按条件进入分支。741                await client.permission.reply({回写审批结果。742                  requestID: permission.id,743                  reply: "once",744                })745              } else {746                UI.println(747                  UI.Style.TEXT_WARNING_BOLD + "!",748                  UI.Style.TEXT_NORMAL +749                    `permission requested: ${permission.permission} (${permission.patterns.join(", ")}); auto-rejecting`,750                )751                await client.permission.reply({回写审批结果。752                  requestID: permission.id,753                  reply: "reject",754                })755              }

8. OpenCode 的选择:替代方案与代价

Section titled “8. OpenCode 的选择:替代方案与代价”
设计问题OpenCode 的选择可选做法收益与代价
本地入口如何调用业务in-process fetch + SDK + server handlerCLI 直接调用内部 service本地/远程路径一致;多一层 API 抽象
项目资源谁清理effectCmd 统一 finally dispose每个命令自行清理生命周期可靠;wrapper 依赖更重
输出怎样到 CLI订阅事件流等 prompt 返回完整结果可展示工具与流式状态;渲染逻辑要处理更多事件
会话怎样选择新建、继续、指定、fork 明确分支每次创建临时上下文可恢复、可分叉;CLI 参数和错误分支更多
权限无人响应怎么办非交互默认拒绝永久等待或默认允许自动化能收口且更安全;部分任务会提前失败

表中的选择由源码直接证明;收益与代价是基于结构的设计解释。

9. TypeScript / Effect:只补会挡路的三点

Section titled “9. TypeScript / Effect:只补会挡路的三点”

这是一个按本次参数计算的资源策略,不是静态配置。函数返回 false 时,handler 不能依赖本地 InstanceRef

1parts: [...files, { type: "text", text: message }]

...files 把所有附件 part 展开,再追加一个 text part。顺序也是请求数据的一部分。

它让 handler 以接近顺序代码的方式组合 Effect,并附加命名 tracing span。可以暂时类比带依赖注入与错误通道的异步 service 方法,但不要把 yield* 理解成普通 generator 在产出数据。

方法一:让入口只做不可避免的适配

Section titled “方法一:让入口只做不可避免的适配”

CLI 解析 argv、stdin、路径和输出格式;业务状态仍从统一 API 进入。

验证问题:增加 Web 或 IDE 客户端时,你是否需要复制 session 业务?

方法二:把资源生命周期放在命令外壳

Section titled “方法二:把资源生命周期放在命令外壳”

需要项目状态的命令统一加载/释放,不需要的命令显式跳过。

验证问题:handler 抛错或被中断时,项目资源仍会清理吗?

CLI 以 session.status: idle 收口,而不是猜测某条输出是否是最终答案。

验证问题:模型只调用工具、没有文本时,你的 CLI 会不会误判已经结束?

不要看上文,补全这段话:

操作系统先从 ______ 找到 opencode 入口。index.ts 负责 ______,effectCmd 负责 ______。RunCommand 把文本、stdin 和文件变成 ______,确定 ______ 后,通过 ______ 调用统一的 session API。CLI 最终根据 ______ 退出。

如果你把“SDK 调用”说成“直接调用模型”,请回看 packages/opencode/src/cli/cmd/run.ts:768-803

packages/opencode/src/cli/cmd/run.ts packages/opencode/src/cli/cmd/run.ts:768-803
768        if (!args.interactive) {区分交互与非交互。769          const events = await client.event.subscribe()订阅运行时事件。770          loop(client, events).catch((e) => {771            console.error(e)772            process.exit(1)773          })774775          if (args.command) {处理命令执行。776            const result = await client.session.command({注册 CLI 子命令。777              sessionID,778              agent,779              model: args.model,选择模型或 provider。780              command: args.command,处理命令执行。781              arguments: message,782              variant: args.variant,783            })784            if (result.error) {按条件进入分支。785              if (!emit("error", { error: result.error })) UI.error(formatRunError(result.error))按条件进入分支。786              process.exitCode = 1787            }788            return返回给上一层。789          }790791          const model = pick(args.model)792          const result = await client.session.prompt({把输入交给会话主流程。793            sessionID,794            agent,795            model,796            variant: args.variant,797            parts: [...files, { type: "text", text: message }],798          })799          if (result.error) {按条件进入分支。800            if (!emit("error", { error: result.error })) UI.error(formatRunError(result.error))按条件进入分支。801            process.exitCode = 1802          }803          return返回给上一层。
  1. 入门:从 packages/opencode/src/index.ts:162 跳到 RunCommand,列出 run 最少需要的三个输入。
packages/opencode/src/index.ts packages/opencode/src/index.ts:162
162  .command(RunCommand)注册 CLI 子命令。
  1. 进阶:画出 --session--continue--fork 的决策树。
  2. 辨析:解释 instance 与 session 的生命周期为什么不同。
  3. 失败路径:说明不存在的 --file、不存在的 session、权限请求分别在哪里收口。

写一个 mini-agent run:支持 message、stdin、--file--session,并让本地与远程模式共享同一个 client 接口。验收标准是入口代码不包含任何模型 provider 分支。

12. 最后复盘:门口已经把工单送进去了

Section titled “12. 最后复盘:门口已经把工单送进去了”

本章的主链只有一条:

argv -> yargs -> effectCmd -> RunCommand -> SDK/server -> session.prompt

你现在应该能解释:CLI 决定“从哪里来、属于哪个项目和 session、带什么 parts、怎样展示结果”,但不决定模型怎样推理或工具怎样执行。

下一章的问题因此很自然:session.prompt 收到的 text/file drafts,怎样变成可持久化的 user message 与 parts?为什么文件附件不能只保留一条路径?接下来进入“用户输入与会话”。