02 · Monorepo 与边界
从 workspace 结构看协议、运行时、平台壳和基础设施如何解耦。
顶层地图
T3 Code 使用 pnpm workspace 与 Vite+ 管理一个跨 Web、Electron、React Native、Node/Bun、Rust 和云基础设施的仓库。 目录多并不等于边界清楚;真正的架构信号是哪些包可以被谁依赖。
t3code/
├── apps/
│ ├── server/ # 权威执行边界、RPC、编排、Provider、Git、PTY
│ ├── web/ # React 19 + TanStack Router 控制面
│ ├── desktop/ # Electron 主进程、内嵌 Server、IPC、更新、SSH/WSL
│ ├── mobile/ # Expo / React Native 客户端与原生性能模块
│ └── marketing/ # 营销站点,不进入核心运行链路
├── packages/
│ ├── contracts/ # Schema、RPC、事件、跨进程共享类型
│ ├── client-runtime/ # 环境目录、连接监督、快照和缓存所有权
│ ├── shared/ # 跨端纯工具与通用模型
│ ├── effect-codex-app-server/ # Codex JSON-RPC 客户端
│ ├── effect-acp/ # Agent Client Protocol 的 Effect 封装
│ ├── ssh/ # SSH transport / process 辅助
│ └── tailscale/ # Tailscale 集成
├── infra/relay/ # T3 Connect 云控制面与边缘入口
├── native/resource-monitor/ # Rust 资源采集器
├── docs/ # 用户与维护者文档
└── scripts/ # 构建、发布、截图、仓库同步依赖方向比目录名重要
contracts 是协议,不是共享杂物箱
它承载跨进程必须一致的 Schema:RPC 输入/输出、错误、领域命令与事件、Provider 配置、执行环境和权限范围。 Server 和客户端都依赖它,但它不应反向依赖 React、SQLite 或某个 Provider SDK。
rpc.ts 集中定义约 95 个 RPC,
WsRpcGroup 把它们组合成 Effect RPC group。统一定义让 handler、客户端代理、序列化和错误 Schema 使用同一来源,
代价是这个文件成为协议变更的高冲突区域。
client-runtime 共享的是连接语义
Web 与 Mobile 的 UI 栈不同,但它们面对相同的执行环境:目录发现、租约、WebSocket 建立、快照装载、断线重试、
缓存清理。把这些逻辑放进 client-runtime,可以避免两个产品在“何时算在线”上产生分叉。
重要的是,它没有把 UI 状态也全部抽走。布局、导航、编辑器和原生页面仍留在应用层,共享包只拥有跨端必须一致的运行语义。
协议适配被拆成独立包
Codex 与 ACP 都是足够复杂的双向协议。将它们放进独立 Effect 包有三个好处:
- 可以单独测试 JSON-RPC framing、request/response 和 notification;
- Server 的 Provider adapter 只做产品语义映射,不重复实现传输;
- 上游协议升级时,改动边界清楚。
应用层的不同职责
| 应用 | 状态权威 | 平台特权 | 失败后能否重建 |
|---|---|---|---|
| Server | 是:事件、投影、session 绑定 | 文件、Git、进程、PTY、网络监听 | 从 SQLite + Provider 状态恢复 |
| Web | 否:本地缓存与视图状态 | 浏览器 API、Web Worker/WASM | 从 Server 快照恢复 |
| Desktop | 后端池与窗口状态部分权威 | Electron IPC、更新、协议、SSH/WSL | 可重启内嵌 Server 与 renderer |
| Mobile | 否:环境目录和导航状态 | 原生模块、后台/前台生命周期 | 从环境目录与快照恢复 |
| Relay | 配对/发现/路由元数据 | 边缘网络与隧道配置 | 不应重建业务会话 |
Workspace 布局与运行数据
源码仓库与用户运行数据是两回事。Server 将数据库、认证状态、日志和 Provider 运行元数据放在自己的 userdata 目录, 工作区只承载被 Agent 修改的项目。设计上必须避免:
- 把 Server 数据库写进每个项目,污染 Git;
- 把多个执行环境的缓存混在一起;
- 让测试误用真实用户数据目录;
- 用项目路径直接做网络身份,导致移动或重命名后失联。
内部文档 workspace-layout.md
用于说明构建和运行资产的归属。代码中进一步用 environment ID、project ID 和规范化 path 区分网络身份与文件位置。
构建工具为何混合
根脚本通过 Vite+ (vp) 编排 TypeScript 构建、测试、lint 和格式化;不同目标再使用最合适的工具:
- Web:Vite 系列构建;
- Desktop:Electron 打包与平台安装器;
- Mobile:Expo / 原生 iOS、Android 工具链;
- Server:可在 Node 或 Bun HTTP/PTY 适配器上运行;
- Resource monitor:Cargo 构建 Rust 二进制。
这不是“一个 bundler 统一天下”的仓库。统一的是顶层命令、版本目录和契约,平台产物仍有独立 pipeline。
变更影响分析
假设要给 Thread 增加一个可同步字段,通常会跨过以下层:
contracts增加 command/event/schema;decider校验并产生事件;projector更新内存 read model;- persistence migration / repository 更新持久化投影;
- RPC 快照或事件订阅携带新字段;
- client-runtime snapshot loader 接收;
- Web/Mobile 展示和交互;
- 兼容旧事件与旧数据库的测试。
这解释了为何 domain 变更看似“改一个 boolean”却会触及多个包:系统在主动支付跨进程、断线恢复和历史兼容的成本。
源码锚点
可迁移经验
- 共享稳定语义,不共享所有代码。 跨端包适合连接、协议、Schema,不适合强行统一导航和 UI。
- 协议实现与产品适配分层。 一个包处理 wire protocol,另一个模块把它映射成领域事件。
- Monorepo 的价值是原子演化。 客户端、服务端和契约可在同一变更中升级,但仍要用依赖方向维持边界。
- 平台特权应该显式列出。 一旦知道哪个进程能碰文件、进程和密钥,安全审查与故障归属都会简单很多。