跳到主要内容

数据截至 (上游 commit e55b2a12c9a5)

Kortix (Suna) — 架构与原理

30 秒导读: Kortix 是一个开源的「AI 公司命令中心」。它把一家公司的组织方式写成代码——agent、技能、连接器、定时任务、机器规格全都躺在一个 git 仓库里的 kortix.yaml(v1 时代是 kortix.toml,两种格式并存、YAML 优先)。你发一句话,平台开一台一次性云沙箱、在一条独立分支上让 agent 真的干活、提交、推送,产出以 change request 的形式等你审、你合并才进 main


1. 这是什么(零基础也能懂)

一句话定义: Kortix 是一套「把公司当代码库来运营」的平台——项目 = git 仓库 + 一份 kortix.yaml,session = 一条独立分支上的一次性云沙箱,agent 在沙箱里用 OpenCode 干活、提交、推送,产出通过 change request 由人审入 main

1.1 它解决谁的什么问题

假设你想让 AI 帮公司做真事:每天早上汇总昨天的提交、收到 GitHub PR 就自动 review、Slack 里 @ 一下就出一份报告。

用聊天框做不到三件事:

做不到为什么Kortix 的回答
让它真的动手聊天框没有一台能装软件、跑命令的机器每个 session 一台完整 Linux 沙箱
让它的配置可审计prompt 和集成藏在某个 SaaS 后台里全部写进仓库里的 kortix.yaml,可 diff 可回滚
让它不闯祸agent 直接改生产、直接拿到你的 API key产出走 change request;凭据只在服务端铸造

1.2 用起来什么样

README 给的是三条命令(README.md:58-67):

# 1 · 装 CLI
curl -fsSL https://kortix.com/install | bash

# 2 · 生成项目骨架 —— 产出 kortix.yaml + agents / skills / 运行时配置
kortix init

# 3 · 发上去 —— 推仓库,并把整套东西在云上拉起来
kortix ship

拉起来之后的日常循环(README.md:71-75):

kortix sessions new --prompt "汇总本周提交,开一个 change request"
kortix cr ls # 看 agent 提了什么 —— 合并才算数
kortix chat # 在终端里跟某个 session 的 agent 对话

这三条命令在代码里对应 runInit(apps/cli/src/commands/init.ts:236)、runShip(apps/cli/src/commands/ship.ts:127)、runSessions(apps/cli/src/commands/sessions.ts:149)与 runCr(apps/cli/src/commands/cr.ts:43)。

1.3 那份声明长什么样

manifest 的开头就把自己定了性:「项目级配置的唯一真源,跟代码一起躺在 git 里」,并用 kortix_version 钉住 schema 版本(packages/starter/templates/base/kortix.yaml:1-15)。v2 是 YAML-only,agents 从 v1 的 [[agents]] 数组变成了一个 agents: map,而且只管治理(授权/沙箱/技能),agent 的行为(prompt/model)全部搬进各 agent 自己的 .kortix/opencode/agents/<name>.md。脚手架模板里能看到几类声明:

声明块白话例子(脚手架模板自带)
sandbox: + sandbox.templates[]这个项目的机器长什么样4 vCPU / 16 GiB / 50 GiB,从 .kortix/Dockerfile.ml 构建(注释示例,packages/starter/templates/base/kortix.yaml:44-51)
opencode:agent 运行时的配置目录在哪.kortix/opencode(packages/starter/templates/base/kortix.yaml:73-74)
agents:每个 agent 被授予哪些连接器、哪些 Kortix 自身动作kortix agent connectors: all(:87-92);session-reviewer 只读(:103-107)
triggers:什么事件自动开一个 session每日 03:00 的 harness-reflector cron(packages/starter/templates/base/kortix.yaml:121-147)

v2 把 channels 从 manifest 里拿掉了——频道 ↔ agent 的路由改在仪表盘里现场管理;连接过的频道仍会以一条 provider: channel 的 connector 条目回写进文件(packages/starter/templates/base/kortix.yaml:178-183)。老项目里的 kortix.toml(v1)继续可用:读取时优先找同名 .yaml/.yml,找不到再回退 .toml(packages/manifest-schema/src/format.ts:37-46)。

1.4 一句话直觉

把 Kubernetes 的心智搬到「公司」上: kortix.yaml 是你的 manifest(期望状态),apps/api 是 control plane(把期望状态调和成真实资源),沙箱是 pod(一次性、可随时重建),而 main 分支是那份只能通过审核才能改的期望状态。

平台版本统一:根 VERSION 文件(当前 0.12.9)一次性钉住 API、前端、CLI、桌面端。


2. 顶层全景(它大概怎么转)

2.1 三层结构图

怎么读这张图: 从上往下是「谁发起 → 谁决策 → 谁干活」,每个方框是一层进程边界。要点只有一条——所有客户端只跟控制面说话,没有任何客户端直连沙箱

┌─ 客户端(四个壳,同一套 HTTP API)──────────────────┐
│ apps/web(Next.js 仪表盘) apps/cli(kortix) │
│ apps/mobile(Expo) apps/desktop-electron │
└───────────────────────┬───────────────────────────┘
│ HTTPS /v1/*
┌───────────────────────▼───────────────────────────┐
│ 控制面 = apps/api 单体进程(Bun + Hono) │
│ 子服务全部挂在一个进程上,见 index.ts:758-969 │
│ 路由/模型 · 计费 · 平台 · 项目 · 沙箱代理 │
│ git 代理 · 连接器网关 · LLM 网关 · 隧道 · 频道 │
│ 市场 · 权限(IAM,库而非路由) │
└───────────────────────┬───────────────────────────┘
│ 供给 + 反向代理
┌───────────────────────▼───────────────────────────┐
│ 数据面 = 一次性云沙箱(每 session 一台) │
│ kortix-sandbox-agent-server(守护进程) │
│ └─ opencode(真正写代码/跑命令的 agent) │
└───────────────────────────────────────────────────┘

2.2 控制面挂了哪些子服务

apps/api/src/index.ts 是一个刻意的单体:package.json 自称 “Kortix API - Unified monolith combining router, billing, platform, cron, and daytona-proxy”(apps/api/package.json:4)。挂载清单集中在 index.ts:758-969,每个子服务一条 app.route:

挂载路径子服务一句话讲透它的章节
/v1/routerrouter(index.ts:800)搜索 / LLM 兼容层 / 第三方转发06
/v1/generation/v1/usage生成取证与用量汇总(index.ts:812-813)单次网关调用 forensics / 账户用量 rollup06
/v1/llm/internal/gateway/v1/llm-gatewaymountLlmGateway(wire.ts:381)三种 LLM 网关形态一次挂全06
/v1/billingbillingApp(index.ts:815)订阅、额度、Stripe webhook06
/v1/platformplatformApp(index.ts:829)API key、沙箱版本、供给商03
/v1/projectsprojectsApp(index.ts:837)项目、session、触发器、CR、密钥01 02 04
/v1/marketplacemarketplaceApp(index.ts:838)浏览 registry 目录01
/v1/skills/v1/runtime-assets技能与运行时资产(index.ts:848:857)平台技能目录 / CLI 与托管技能分发01
/v1/gitgitProxyApp(index.ts:864)全平台唯一的 git 客户端源04
/v1/connectorsconnectorApp(index.ts:874)所有工具调用的收口(原 /v1/executor)05
/v1/webhooks/*/v1/channels/*频道与触发器(index.ts:877-896)Slack / Teams / Telegram / 邮件入口02
/v1/tunneltunnelApp(index.ts:962)云 agent 反向连回你本机03
/v1/psandboxProxyApp(index.ts:969)沙箱反向代理(通配,必须最后挂)03

注意一个例外: IAM 不是一条挂载路由,而是被各路由 import 的——公共入口是 authorize / assertAuthorized(apps/api/src/iam/index.ts:10-58),真正判定在 authorize.ts 这一个规范引擎里(apps/api/src/iam/authorize.ts:1-18 的文件头自述:它取代并删除engine-v2.ts、V1 策略引擎和 projects/access.ts 三份并行实现,现在是唯一授权路径)。

2.3 数据面里有什么

沙箱不是一个裸容器,里面有一层自己的运行时:

  • entrypoint.sh 以 PID 1 起,先确保 /workspace 真实存在再交棒给守护进程——因为供给商的 init 可能在容器起来之后删掉原目录(apps/sandbox/entrypoint.sh:1-12)。
  • 守护进程 kortix-sandbox-agent-server,自述是「OpenCode supervisor + Kortix API surface」(apps/kortix-sandbox-agent-server/package.json:4),入口 main()(apps/kortix-sandbox-agent-server/src/main.ts:78)负责配 git 凭据助手、物化仓库、拉起 OpenCode、开端口代理。
  • OpenCode 才是那个真正读写文件、跑命令的 agent 进程,由 createOpencodeSupervisor 托管(apps/kortix-sandbox-agent-server/src/opencode.ts:1573)。

细节在 03 沙箱内部


3. 部件一句话职责表

3.1 apps/* —— 十一个目录

工作区定义在 pnpm-workspace.yaml:1-4(apps/* + packages/* + tests)。

目录干什么由哪章讲透
apps/api控制面单体:所有 /v1/* 子服务、后台 worker、领导者选举02 04 05 06
apps/webNext.js 15 仪表盘 + 官网 + 文档源(apps/web/package.json:2)本页(不下钻)
apps/clikortix 命令行——「在终端做到仪表盘能做的一切」(apps/cli/DESIGN.md:7-24)01
apps/mobileExpo/React Native 客户端(apps/mobile/package.json:1-3)本页(不下钻)
apps/desktop-electronElectron 桌面壳,包住远程 web app本页(不下钻)
apps/sandbox沙箱基础镜像:Dockerfile + entrypoint.sh03
apps/kortix-sandbox-agent-server沙箱内守护进程(OpenCode 监管 + 沙箱侧 API)03
apps/llm-gateway独立部署的网关进程(与 API 内嵌形态跑同一份管线)06
apps/kortix-app-runtime应用部署的 Go 运行时(Caddy + 构建脚本)本页(不下钻)
apps/voice-agent语音会话的 LiveKit agent(livekit.toml)本页(不下钻)
apps/whitelabel-demo白标演示壳本页(不下钻)

3.2 packages/* —— 十一个共享包

目录干什么(取自各自 package.json 的 description)由哪章讲透
packages/manifest-schemakortix.yaml 的规范 schema + 校验器,CLI 和后端共用一份01
packages/dbDrizzle ORM schema 与客户端02
packages/shared常量、沙箱渲染层/运行时指纹、运行时版本表03 06
packages/llm-catalogLLM 模型目录(models.dev 生成):网关支持的模型、托管模型清单与默认06
packages/llm-gatewayLLM 管线本体:多传输、失败转移、熔断、预算、trace06
packages/sdk官方 TypeScript SDK:项目/会话生命周期 + agent 流式,统一在一个 Session 句柄后面05
packages/executor-sdk已废弃——@kortix/sdk 连接器 API 的兼容适配层05
packages/agent-tunnel云 agent ↔ 本机的隧道:relay、本地 agent、JSON-RPC、HMAC 签名03(隧道本体)05(它作为连接器的那一面)
packages/registry市场引擎,shadcn 兼容的 registry 格式 + 构建/解析/安装原语01
packages/starter项目脚手架模板 + 加载器,kortix init 和后端建仓路径共用01
packages/api-contractAPI 线格式契约:Zod schema + 推导类型,SDK/web/mobile 共用一份真源本页(不下钻)

apps/api 通过 workspace:* 依赖其中八个(apps/api/package.json:25-32):api-contractdbllm-catalogllm-gatewaymanifest-schemaregistrysharedstarter;隧道包写法不同,单列在 :37("agent-tunnel": "workspace:@kortix/agent-tunnel@*")。executor-sdk 不被控制面依赖——它是给沙箱侧用的兼容层,新代码用 packages/sdk共享包被两侧同时使用,是这个仓库最重要的一致性手段——见第 5 节第 5 条。


4. 主线走一遍(高层,不进代码)

一句 prompt 从进来到闭环,经过七站。怎么读: 从上往下,每一站右侧标了在哪一章讲透。

① 入口 仪表盘 / CLI / Slack / cron / webhook
│ 统一落到 projectsApp

② 生命周期 排队 → 背压检查 → 建 session 记录 → 建分支 ── 第 02 章


③ 沙箱供给 算镜像内容哈希 → 命中缓存或构建 → 开沙箱 ── 第 03 章


④ agent 干活 守护进程克隆仓库 → 拉起 OpenCode → 执行提示词 ── 第 03 章
├── 要调外部 API?→ 走连接器网关 ── 第 05 章
└── 要调模型? → 走 LLM 网关(顺手计费) ── 第 06 章


⑤ 提交推送 commit → 经 /v1/git 代理 push 到会话分支 ── 第 04 章


⑥ 人审闸门 开 change request → 你 review → merge 进 main ── 第 04 章


⑦ 配置重读 下一次读 manifest 时从 main 拿到新声明,回到 ① ── 第 01 章

四个必须说清的交接点

① → ②:入口再多,收口只有一处。 无论是 UI 点一下、kortix sessions new、Slack 里 @ 一句,还是 cron 到点,最终都变成同一批生命周期命令:createSession(apps/api/src/projects/session-lifecycle/engine.ts:96)与 startSession(同文件 :307)。入口种类被建模成一个联合类型 SessionInvocationSource(apps/api/src/projects/session-lifecycle/types.ts:6-22),覆盖 ui/cli/slack/trigger:cron 等 17 种。

③:镜像是「算」出来的,不是「记」下来的。 供给一台沙箱前先算一个内容哈希——Dockerfile 字节 + 构建上下文的 tree OID + 运行时指纹 + 硬件规格,四段拼起来做 SHA-256(apps/api/src/snapshots/hash.ts:71 computeSnapshotHash)。相同输入 → 相同哈希 → 直接命中缓存,跳过重建。四项里哪一项今天真正在起作用,见 5.1。

⑤:沙箱不知道 GitHub 的密码。 沙箱、CLI、你本机的 git,统统 clone/push 到 https://<KORTIX_URL>/v1/git/<projectId>.git,拿的是 Kortix token;API 认完 token 才用服务端铸造的短期宿主凭据把 git 协议流转给真实上游(apps/api/src/git-proxy/index.ts:1-19)。

⑦:闭环靠「重新读一遍」而不是「同步一份」。 平台不维护配置的第二副本——需要触发器/连接器/agent 声明时,现场从默认分支读 manifest(优先 kortix.yaml,回退 kortix.toml)再解析(apps/api/src/projects/triggers.ts:352 readManifest)。所以 CR 一合并,新配置自然生效。


5. 巧妙之处(六条,每条指向具体章节)

5.1 镜像身份用「内容」定义,而不是编版本号

不给镜像编版本号,而是把所有影响构建结果的东西摁进一个 SHA-256:Dockerfile 字节、构建上下文的 tree OID、运行时指纹、硬件规格,四段带长度前缀拼起来(apps/api/src/snapshots/hash.ts:71 computeSnapshotHash)。相同输入 → 相同哈希 → 直接命中缓存。

这里有一条落差必须说在前面。 hash.ts:1-29 的文件头把第二项写成「用 git 自己的 tree OID,COPY ./scripts/setup.sh 的失效因此免费得到」——那是这套哈希的设计意图,不是今天的接线。全仓唯一给这个字段赋值的地方是一个逐模板常量:

// apps/api/src/snapshots/templates.ts:601
contextTreeOid: template.isShared ? 'platform-default' : `template:${template.slug}`,

今天真正承担「内容变了就换名字」的是第三项运行时指纹,由 buildRuntimeArtifactFingerprint(packages/shared/src/sandbox-runtime-artifact.ts:109)对运行时构件逐字节遍历得出;用户 Dockerfile 的内容则由第一项直接覆盖。tree OID 是预留好的、语义上更对的位置。完整论证见 第 03 章 §10.3

还有一处克制是真的:硬件规格只在真的写了的时候才拼进摘要(hash.ts:84-85),否则没声明规格的老项目会因为新增一个字段而全体重建。→ 第 03 章

5.2 两道「凭据只在服务端铸造」的代理,形状一模一样

同一个安全模式用了两次:

代理客户端拿的是服务端换成的是位置
git 代理Kortix token(沙箱 token / API key / CLI PAT)短期宿主凭据(GitHub 等)apps/api/src/git-proxy/index.ts:1-19
连接器网关会话级 connector token项目/成员的真实第三方密钥apps/api/src/connectors/gateway.ts:15-22

连接器网关自称「每次工具调用都要过的收口」:解析连接器与动作 → 校验发起人有权用 → 服务端解出凭据 → 执行 → 落审计,「沙箱从不持有 app secret」。执行入口是 handleCall(apps/api/src/connectors/gateway.ts:426)。→ 第 04 章第 05 章

5.3 agent 权限 = 自己声明的 ∩ 启动它的人的角色

这是全仓最值得抄的一条设计。角色检查(IAM)保持纯粹的只看角色;agent 的限制单独挂在一层旁路:会话 token 携带一个 agentGrant,路由断言自己要做的动作在这个 grant 里。两者叠加的净效果被注释写死为 userRole ∩ agentGrant——

agent 永远不可能超过启动它的人,也不可能超过自己的声明。

apps/api/src/iam/agent-scope.ts:2-14,判定函数 agentMayPerform(:46);grant 从 manifest 的 agents: map(v1 为 [[agents]])解析而来(apps/api/src/projects/agents.ts:326 resolveAgentGrant)。

默认值也选得好: grant 为 null(笔记本上的 CLI PAT、仪表盘会话、还没用 [[agents]] 的老项目)= 不施加任何限制,新机制因此可以零破坏地滚上线。→ 第 05 章

5.4 一套 LLM 管线,两种部署形态,差别只在「钩子怎么绑」

mountLlmGateway 一次挂三个面(apps/api/src/llm-gateway/wire.ts:381):

  • /v1/llm —— 进程内跑完整管线,服务自托管与开发环境;
  • /internal/gateway —— 独立网关 pod 反过来调控制面的 RPC;
  • /v1/llm-gateway/* —— 配了独立网关时的反向代理。

关键在注释里那句(wire.ts:376-379):内嵌形态与独立 pod 是同一份代码,「只有钩子绑定不同」——进程内是直接函数调用,独立服务是 HTTP。管线本体是 createGateway(packages/llm-gateway/src/create-gateway.ts:46),独立进程只是给它套了个 Bun.serve(apps/llm-gateway/src/main.ts:2-26)。→ 第 06 章

5.5 校验器和运行时解析器,被测试强制同源

manifest 有两个读者:CLI 推送前的预检,和后端 CR 合并时的闸门。两边共用 validateManifest(packages/manifest-schema/src/index.ts:208),这是第一层保险。

第二层更硬:枚举曾经两边各写一份、靠测试钉住;如今更进一步——CHANNEL_PLATFORMSRESERVED_SLUG_PROVIDERS 这些平台枚举干脆由运行时解析器直接 import 闸门包(apps/api/src/projects/connectors.ts:40-42),想漂都没得漂。剩下确实没法共享的 provider 列表(运行时多一个平台自留的 computer,用户永远写不了),仍由跨包一致性测试看守:parser and schema agree(apps/api/src/__tests__/unit-connectors-parse.test.ts:871)逐个 provider 断言两边给出同一个 accept/reject;agents: 授权清单也有对应守卫(apps/api/src/__tests__/unit-agents-parse.test.ts:37-38,断言 API 的 GRANTABLE_KORTIX_CLI 与 schema 的 GRANTABLE_KORTIX_CLI_ACTIONS 排序后相等)。

为什么值钱: 校验器比运行时严 → 好用的 manifest 被拒;校验器比运行时松 → 坏 manifest 合进 main 把项目搞挂。能 import 就 import、不能 import 就共享测试,是比「小心维护」可靠得多的办法。→ 第 01 章

5.6 单体是单体,但后台 worker 只在一个副本上跑

API 在生产是多副本(ECS Fargate 上最少 2、最多 10,apps/api/src/shared/leader-election.ts:4)。请求路径上的服务每个副本都起;但 cron 触发器、项目维护、迁移这类单例 worker 必须恰好一个副本跑——否则一条 cron 会开出 N 个付费 session 并产生 N 份外部副作用。

做法是租约式领导者选举:startLeaderElectiononAcquire / onRelease 开关这批 worker(apps/api/src/index.ts:1490-1493),另加一个幂等守卫扛住领导权抖动(index.ts:1359-1360)。还有一处细节值得抄——关掉了 worker 的纯 API pod 根本不参选,免得它拿了租约却什么都不跑,把整个集群的调度饿死(index.ts:1410-1416)。→ 第 02 章


6. 阅读地图

6.1 建议顺序

你在这 ──▶ index(是什么 / 大盘 / 该读哪章)

┌────────────┴────────────┐
▼ ▼
01 manifest 02 session 生命周期
(声明长什么样) (一句话怎么变成一台机器)
│ │
└────────────┬────────────┘

03 沙箱运行时(机器里发生了什么)

┌────────────┼────────────┐
▼ ▼ ▼
04 git/CR 05 连接器网关 06 LLM 网关
(产出怎么回来) (手脚) (脑子与账单)

6.2 按你的目的挑一章

你想搞清楚直接读
「配置到底怎么写、写错了会怎样」01-manifest-control-plane.md
「Slack 里 @ 一句之后,后台发生了什么」02-session-lifecycle.md
「沙箱怎么这么快就起来了 / OpenCode 怎么被托管」03-sandbox-runtime.md
「agent 的代码怎么回到我手上、谁把关」04-git-and-change-requests.md
「agent 怎么调 GitHub/Slack,而我的 token 没泄露」05-executor-connectors.md
「模型请求怎么路由、失败怎么转移、钱怎么算」06-llm-gateway-and-metering.md

6.3 本页刻意不讲的

本页给出的源码坐标只用来锚定「东西在哪」;机制怎么跑、边界在哪、失败时什么样,一律留给各章。以下都在各章里:sandbox 供给商适配与热分叉、session 命令队列与背压、裸仓镜像缓存与合并、连接器策略分层与审批、模型解析与熔断预算,以及镜像哈希那四项输入的实际接线(第 03 章 §10.3)。


7. 顶层代码地图

给人和 agent 的跳转表。符号名比行号抗漂移——上游更新后行号会变,grep 符号通常还在。

主题文件符号 / 锚点
控制面进程入口与子服务挂载apps/api/src/index.ts挂载段 :758-969
多副本下的单例 workerapps/api/src/index.tsstartSingletonWorkers(:1362)、bootServices(:1407)
领导者选举apps/api/src/shared/leader-election.tsstartLeaderElectionrunsSingletonWorkers
项目声明的规范校验packages/manifest-schema/src/index.tsvalidateManifest(:208)、formatIssues(:307)
manifest 双格式解析(yaml/toml)packages/manifest-schema/src/format.tsmanifestCandidatePaths(:37)、parseManifestText(:54)
运行时读 manifestapps/api/src/projects/triggers.tsreadManifest(:352)、parseManifestString(:460)
session 生命周期引擎apps/api/src/projects/session-lifecycle/engine.tscreateSession(:82)、startSession(:307)、continueSession(:353)
镜像内容哈希apps/api/src/snapshots/hash.tscomputeSnapshotHash(:71)
运行时构件指纹packages/shared/src/sandbox-runtime-artifact.tsbuildRuntimeArtifactFingerprint(:109)
镜像构建与预热apps/api/src/snapshots/builder.tsensureSandboxImage(:148)、kickStartupPreBuild(:1479)
git 反向代理apps/api/src/git-proxy/index.tsgitProxyApp(:41)
每项目裸仓镜像apps/api/src/projects/git/mirror.tsmaterializeRepoContext(:607)
change request 数据层apps/api/src/projects/change-requests.tsserializeChangeRequest(:24);路由在 routes/r8.ts:160,211
工具调用收口apps/api/src/connectors/gateway.tshandleCall(:426)
连接器凭据解析apps/api/src/connectors/credentials.tsresolveCredentialValue(:96)
agent 权限交集apps/api/src/iam/agent-scope.tsagentMayPerform(:46)、agentMayUseConnector(:79)
角色授权引擎apps/api/src/iam/authorize.tsapps/api/src/iam/index.ts 导出的 authorize / assertAuthorized
LLM 网关三面挂载apps/api/src/llm-gateway/wire.tsmountLlmGateway(:361)
LLM 管线本体packages/llm-gateway/src/create-gateway.tscreateGateway(:46)
沙箱守护进程apps/kortix-sandbox-agent-server/src/main.tsmain(:74)
OpenCode 监管apps/kortix-sandbox-agent-server/src/opencode.tscreateOpencodeSupervisor(:1214)、waitForOpencodeReady(:1959)
沙箱侧凭据热插拔apps/kortix-sandbox-agent-server/src/llm-proxy.tsstartLlmProxy(:174)
CLI 命令实现apps/cli/src/commands/runInit(init.ts:236)、runShip(ship.ts:127)、runSessions(sessions.ts:149)、runCr(cr.ts:43)
CLI 设计契约apps/cli/DESIGN.md§1 Scope(:7-24)、§3 Command surface(:128)