CLI / 启动入口
eec0843ce422
Agent 生成档案
Section titled “Agent 生成档案”- 章节 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
主要源码路径
Section titled “主要源码路径”packages/opencode/package.jsonpackages/opencode/src/index.tspackages/opencode/src/cli/cmd/run.tspackages/opencode/src/cli/effect-cmd.ts
本章以 OpenCode 源码版本
eec0843ce422为证据基线。我们追踪一个典型场景:用户在项目目录执行opencode run "解释这个项目" --file package.json,输入怎样越过 CLI,进入统一的 session API?这是由源码拼出的典型路径,不是一次真实运行录屏。
0. 本章学习目标
Section titled “0. 本章学习目标”学完这一章,你应该能够:
- 画出
bin -> index.ts -> RunCommand -> SDK -> session.prompt的启动地图。 - 区分 CLI 入口、项目 instance、session runtime 和输出渲染各自的职责。
- 沿源码解释 message、stdin、
--file与 session 选择怎样组成一次请求。 - 说明本地模式为什么也通过 SDK/API,而不是绕过 server handler。
- 判断远程
--attach、权限请求和 session 结束时分别走哪条分支。 - 把“薄入口、统一内核”的方法迁移到自己的 mini agent。
1. 一句话讲明白
Section titled “1. 一句话讲明白”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 | vpackages/opencode/bin/opencode | vsrc/index.ts 根命令、全局初始化、错误出口 | veffectCmd(RunCommand) 加载并最终释放项目 instance | vrun.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 等产品能力。
4. 最小机制:先看 11 行骨架
Section titled “4. 最小机制:先看 11 行骨架”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.ts、effect-cmd.ts 和 run.ts 抽出的教学骨架。真实实现还要处理 command 模式、交互模式、认证、分享、JSON 输出和权限响应。
5. 读源码前,只分清三个概念
Section titled “5. 读源码前,只分清三个概念”5.1 command 注册不等于 command 执行
Section titled “5.1 command 注册不等于 command 执行”index.ts 用 yargs 注册 RunCommand、ServeCommand 等命令;真正的 run handler 在 run.ts。可以把它类比为 Picocli 的根命令与子命令 handler,但 OpenCode 后面用 Effect 组合依赖和资源生命周期。来源:packages/opencode/src/index.ts:70-93、packages/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 子命令。
5.2 project instance 不等于 session
Section titled “5.2 project instance 不等于 session”- instance 绑定一个项目目录,承载配置、插件、LSP 等项目级服务;
- session 是一次可继续、可 fork 的对话/任务档案。
effectCmd 管 instance 生命周期;run.ts 通过 SDK 选择或创建 session。把两者混成“上下文”会看不清资源何时释放、会话为何还能恢复。
5.3 事件订阅不等于 agent loop
Section titled “5.3 事件订阅不等于 agent loop”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.json 的 bin 字段把命令名映射到 ./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-110、packages/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-53、packages/opencode/src/cli/cmd/run.ts:251-267、packages/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 在确定目录后:
- 把相对路径解析为绝对路径;
- 检查目标存在;
- 区分目录与普通文本;
- 生成
{ 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 则允许从旧历史分出新路线,而不覆盖原会话。
6.6 第六站:先订阅,再发 prompt
Section titled “6.6 第六站:先订阅,再发 prompt”非交互路径先调用 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 事件持续渲染。
6.7 第七站:本地也走 SDK/API
Section titled “6.7 第七站:本地也走 SDK/API”若未指定 --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-332、packages/opencode/src/cli/cmd/run.ts:760-766、packages/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 的状态作为完成信号,而不是依赖“最后一段文本出现了”这种脆弱判断。
7. 三条容易走错的分支
Section titled “7. 三条容易走错的分支”7.1 --command 不走普通 prompt payload
Section titled “7.1 --command 不走普通 prompt payload”若传入 --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返回给上一层。
7.2 --attach 的目录属于远程语义
Section titled “7.2 --attach 的目录属于远程语义”本地 --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 handler | CLI 直接调用内部 service | 本地/远程路径一致;多一层 API 抽象 |
| 项目资源谁清理 | effectCmd 统一 finally dispose | 每个命令自行清理 | 生命周期可靠;wrapper 依赖更重 |
| 输出怎样到 CLI | 订阅事件流 | 等 prompt 返回完整结果 | 可展示工具与流式状态;渲染逻辑要处理更多事件 |
| 会话怎样选择 | 新建、继续、指定、fork 明确分支 | 每次创建临时上下文 | 可恢复、可分叉;CLI 参数和错误分支更多 |
| 权限无人响应怎么办 | 非交互默认拒绝 | 永久等待或默认允许 | 自动化能收口且更安全;部分任务会提前失败 |
表中的选择由源码直接证明;收益与代价是基于结构的设计解释。
9. TypeScript / Effect:只补会挡路的三点
Section titled “9. TypeScript / Effect:只补会挡路的三点”9.1 instance: (args) => !args.attach
Section titled “9.1 instance: (args) => !args.attach”这是一个按本次参数计算的资源策略,不是静态配置。函数返回 false 时,handler 不能依赖本地 InstanceRef。
9.2 object spread 与 parts
Section titled “9.2 object spread 与 parts”1parts: [...files, { type: "text", text: message }]
...files 把所有附件 part 展开,再追加一个 text part。顺序也是请求数据的一部分。
9.3 Effect.fn(...)(function* () {})
Section titled “9.3 Effect.fn(...)(function* () {})”它让 handler 以接近顺序代码的方式组合 Effect,并附加命名 tracing span。可以暂时类比带依赖注入与错误通道的异步 service 方法,但不要把 yield* 理解成普通 generator 在产出数据。
10. 可以带走的方法
Section titled “10. 可以带走的方法”方法一:让入口只做不可避免的适配
Section titled “方法一:让入口只做不可避免的适配”CLI 解析 argv、stdin、路径和输出格式;业务状态仍从统一 API 进入。
验证问题:增加 Web 或 IDE 客户端时,你是否需要复制 session 业务?
方法二:把资源生命周期放在命令外壳
Section titled “方法二:把资源生命周期放在命令外壳”需要项目状态的命令统一加载/释放,不需要的命令显式跳过。
验证问题:handler 抛错或被中断时,项目资源仍会清理吗?
方法三:用领域状态结束命令
Section titled “方法三:用领域状态结束命令”CLI 以 session.status: idle 收口,而不是猜测某条输出是否是最终答案。
验证问题:模型只调用工具、没有文本时,你的 CLI 会不会误判已经结束?
11. 费曼复述与练习阶梯
Section titled “11. 费曼复述与练习阶梯”11.1 60 秒复述
Section titled “11.1 60 秒复述”不要看上文,补全这段话:
操作系统先从 ______ 找到
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返回给上一层。
11.2 读源码练习
Section titled “11.2 读源码练习”- 入门:从
packages/opencode/src/index.ts:162跳到RunCommand,列出run最少需要的三个输入。
packages/opencode/src/index.ts
packages/opencode/src/index.ts:162
162 .command(RunCommand)注册 CLI 子命令。
- 进阶:画出
--session、--continue、--fork的决策树。 - 辨析:解释 instance 与 session 的生命周期为什么不同。
- 失败路径:说明不存在的
--file、不存在的 session、权限请求分别在哪里收口。
11.3 小实现
Section titled “11.3 小实现”写一个 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?为什么文件附件不能只保留一条路径?接下来进入“用户输入与会话”。