跳到主要内容

数据截至 (上游 commit dad6f5196773)

四个执行面:同一条指令流跑在浏览器、服务端、云沙箱和本地 CLI 上

30 秒导读: 前面几章讲的是 agent「怎么想、怎么调模型、怎么用工具」。这一章只回答一个很物理的问题——这次运行到底发生在哪台机器上。LobeHub 把同一套 agent 语义搬到了四个截然不同的执行面上,每个面对「状态存哪、事件怎么广播、断线怎么接」给出的答案都不一样。


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

一句话定义: 「执行面」(execution plane)= agent 循环真正跑起来的那个进程 + 那台机器。

同一句「帮我把这个仓库的测试跑通」,在 LobeHub 里可能落在四个完全不同的地方。

执行面代码里的名字循环跑在哪典型场景
浏览器执行面runtimeType: 'client'你的浏览器标签页 / Electron 渲染进程默认聊天,关掉页面就停
服务端执行面runtimeType: 'gateway' 的服务器一侧LobeHub 服务器进程(或队列 worker)云端长任务,关页面照跑
云沙箱面executionTarget: 'sandbox'一次性云容器里的 CLI 进程网页版跑 Claude Code
本地 CLI 面runtimeType: 'hetero'你本机上 claude / codex 子进程桌面端跑外部 CLI agent

为什么需要四个? 因为三种诉求互相冲突:

  • 要快、要能碰你的文件 → 只能在本机跑。
  • 要关掉页面还继续跑、手机上能看进度 → 只能在服务端跑。
  • 网页版没有本地文件系统,但又想用 Claude Code → 只能开个云容器跑。

一句话直觉: 把 LobeHub 想成一个调度台。用户按下回车时,调度台先查一张「这活儿该派给谁」的表(路由决策),再把活儿丢进对应的车间(执行面)。四个车间的产出规格是统一的——都吐 AgentStreamEvent——所以聊天界面这一端根本不用知道活儿在哪儿干的。

本章不讲多 agent 之间怎么编排、怎么互相调用(那是 第 6 章),只讲运行时选址与传输


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

怎么读这张图: 从上往下是一次发送消息的时间顺序。② 是全局唯一的路由决策点,③~⑥ 是四个并列的执行面,最下面它们重新汇合成同一种事件流。

send / regenerate / resume / sub-agent call
|
+---------v---------+
| 1 command bus | /compact @mention skill
+---------v---------+
|
+---------v---------+
| 2 route decision | selectRuntimeType
| "who runs it" | + resolveExecutionTarget
+--+-----+-----+--+-+
| | | |
+------------+ | | +--------------------+
| | | |
+---v--------+ +------v---+ | +---------+ +--------v--------+
| 3 browser | | 4 server | +>| 5 cloud | | 6 local CLI |
| client | | gateway | | sandbox | | hetero |
| AgentRuntime| | AgentRuntime | lh hetero| | claude / codex |
| in the tab | | on server| | exec | | child process |
+---+--------+ +------+---+ +----+----+ +--------+--------+
| | | |
+---------+--------+------------+----------------+
|
+----------v-------------------------+
| unified AgentStreamEvent |
| -> one gatewayEventHandler |
+----------------+-------------------+
v
chat UI render

各部件一句话职责:

部件干什么在哪个文件
selectRuntimeType决定 client / gateway / heterosrc/store/chat/slices/agentRun/actions/dispatch/agentDispatcher.ts:113
resolveExecutionTarget决定 none / local / sandbox / device / autosrc/helpers/executionTarget.ts:172
streamingExecutor浏览器面:组装并跑 AgentRuntimesrc/store/chat/slices/agentRun/actions/transports/client/streamingExecutor.ts:692
AgentRuntimeService服务端面:同样组装并跑 AgentRuntimeapps/server/src/services/agentRuntime/AgentRuntimeService.ts:3006
AgentStreamClient云端事件的 WebSocket 收管packages/agent-gateway-client/src/client.ts:64
spawnAgent本地 CLI 面:拉起子进程packages/heterogeneous-agents/src/spawn/spawnAgent.ts:579
GatewayClient本机作为「设备」被服务器调度packages/device-gateway-client/src/client.ts:82
createGatewayEventHandler四个面共用的事件落地器src/store/chat/slices/agentRun/actions/transports/gateway/gatewayEventHandler.ts:296

3. 路由决策:一次算清,全程不改

3.1 为什么是两层而不是一层

刚接触会觉得奇怪:明明就是「在哪跑」一件事,为什么有两个函数?

因为问的是两个不同粒度的问题:

问题答案的取值谁在用
selectRuntimeType用哪套 运行时代码 驱动循环client / gateway / hetero客户端 store 的分发口
resolveExecutionTarget工具最终 落在哪台机器none / local / sandbox / device / auto客户端 UI、服务端工具引擎、CLI 三方共用

第一层管「谁来跑循环」,第二层管「手落在谁的磁盘上」。第二层是跨端共享的纯函数,所以客户端的设备切换器、服务端的工具门控、tools engine 三处永远得出同一个答案——这条注释在源码里被反复强调(src/helpers/executionTarget.ts:34-54,ResolveExecutionTargetOptions.clientExecutionAvailable)。

3.2 第一层:selectRuntimeType 的四级优先级

它是一个不到 30 行的纯函数,优先级写死成 parentRuntime > hetero > gateway > client:

// src/store/chat/slices/agentRun/actions/dispatch/agentDispatcher.ts:94 selectRuntimeType
if (ctx.parentRuntime) return ctx.parentRuntime;
if (ctx.heterogeneousProvider && isRemoteHeterogeneousType(ctx.heterogeneousProvider.type))
return 'gateway';

四条规则逐条说:

  1. parentRuntime 一票通过(agentDispatcher.ts:145)。子 agent 必须待在父 agent 的执行面上——在 Gateway 上跑起来的父 agent,它 spawn 的子 agent 不能突然掉回浏览器。
  2. 远程平台型异构 agent 直接走 gateway(agentDispatcher.ts:150)。openclaw / hermes / amp / opencode 这几种没有本地子进程,永远靠 lh connect 连上来的设备执行,所以不分桌面/网页(packages/heterogeneous-agents/src/config.ts:44 isRemoteHeterogeneousType)。
  3. 本地 CLI 型异构 agent 交给第二层裁决(agentDispatcher.ts:159-173):resolveExecutionTarget 返回 local 就是 hetero(本机 in-process),否则一律 gateway(交给服务器去派设备或起沙箱)。
  4. 剩下的看全局 gateway 开关,否则 client(agentDispatcher.ts:175-176)。

3.3 第二层:五个 target 的语义

// src/helpers/executionTarget.ts:90 resolveExecutionTarget
let effective = stored ?? (clientExecutionAvailable ? 'local' : 'none');
if (isHetero && !clientExecutionAvailable && stored === 'local' && agencyConfig?.boundDeviceId)
return 'device';
if (isHetero && effective === 'none') effective = clientExecutionAvailable ? 'local' : 'sandbox';
if (!clientExecutionAvailable && effective === 'local') return 'sandbox';
target含义关键性质
none纯聊天,没有执行环境网页端默认值
local本机 in-process(桌面端 IPC)只在这台桌面的会话里可见
sandbox服务器起的一次性云容器永不碰用户机器
device派给某台注册设备(boundDeviceId)经服务器网关,进度对所有客户端可见
auto自动挑一台在线设备唯一会激活用户没主动选过的设备的模式

这里有条容易被当成「优化点」而写错的规则,源码专门写了注释:

localdevice 即使指向同一台机器也必须保持区分——device 走服务器网关,所以手机/网页都能跟着看;local 是更快的 in-process 路径,但这次运行只活在这个桌面会话里(src/helpers/executionTarget.ts:138-144)。

也就是说,「本机」不等于「本进程」。这是可观测性与延迟之间的用户选择,不许代码替用户合并。

3.4 第三层(可选):resolveExecutionPlan 把「哪台设备」也定死

resolveExecutionTarget 只说「要不要设备」,resolveExecutionPlan(src/helpers/executionTarget.ts:419)进一步说「具体哪台、连不上怎么办」,返回四种 kind:

resolveExecutionPlan
├─ kind: 'none' 纯聊天,永不碰设备
├─ kind: 'sandbox' 云容器
├─ kind: 'device' deviceId 已确定
└─ kind: 'device-unrouted' 想要设备但现在没有
reason: no-bound-device / bound-device-offline
/ no-online-device / ambiguous-online-devices

device-unrouted 是个很克制的设计:绑定的机器离线时不猜别的机器,而是保持未路由,让模型在运行中通过 remote-device 工具去激活一台(executionTarget.ts:290-301 ExecutionPlanUnroutedReason)。只有显式的 auto 模式、且恰好只有一台在线设备时,才会自动选中(executionTarget.ts:506-509)。

3.5 子 agent 怎么继承执行面

三种调用姿势(callSubAgent / callAgent / @提及)先被压成同一个 AgentInvocationIntent(agentDispatcher.ts:33),再交给统一分发口:

// src/store/chat/slices/agentRun/actions/dispatch/nonHeteroSubAgentDispatcher.ts:77
const runtimeType = selectRuntimeType({ ..., parentRuntime: ctx.parentRuntime });

两个面的语义差异,是这个函数里最值得抄走的一段:

runtimeagentId 传谁消息从哪来
client agent(保证 message map key 正确),subAgentId 指目标调用方传进来的 messages 数组
gateway目标 agent(服务器要跑的是它)服务器自己从 topic 库里读历史,指令变成一条真实用户消息

hetero 在这里是显式抛错而不是悄悄降级到 client(nonHeteroSubAgentDispatcher.ts:129)——异构 agent 有自己的管线,静默 fallback 会让用户以为跑的是 Claude Code、其实跑的是普通模型。


4. 浏览器执行面:循环就在你这个标签页里

4.1 它要解决的小问题

最朴素的那种:用户打字,模型回话,工具在浏览器沙箱或 Electron 里执行。没有服务器参与循环,关掉页面就没了。

4.2 运行时是怎么被"攒"出来的

streamingExecutor 里三步走。先算配置与工具集:

// src/store/chat/slices/agentRun/actions/transports/client/streamingExecutor.ts:522 internal_createAgentState(...)
const { agentConfig, toolsEngine, ... } = this.#get().internal_createAgentState({ ... });

再造「大脑」和「引擎」两个对象(职责分工见 第 1 章):

// streamingExecutor.ts:557
const agent = new GeneralChatAgent({ agentConfig: { maxSteps: 1000 }, compressionConfig, ... });
// streamingExecutor.ts:568
const runtime = new AgentRuntime(agent, {
executors: createAgentExecutors({ agentConfig, get, messageKey, operationId, toolsEngine }),
getOperation: (opId) => ({ abortController: ..., context: ... }),
operationId,
});

重点看 executors AgentRuntime 本身是平台无关的;真正把它绑在浏览器上的,是这一组 executor——它们知道怎么写 zustand store、怎么调 Electron 的本地文件服务。服务端那一份组装(第 5 节)换的就是这一组。

4.3 循环:每一步都重算一次上下文

// streamingExecutor.ts:600
while (state.status !== 'done' && state.status !== 'error') {

循环体里做的事,顺序值得记:

  1. 先查 operation 有没有被取消;被取消就把 state 打成 interrupted 再跑最后一步,让 agent 有机会收拾挂起的工具。
  2. dbMessagesMap 重算 stepContext——todos、已激活工具、已激活技能、队列里有没有排队消息。
  3. 如果是 page agent,再抓一次页面最新 XML 塞进 stepContext.stepPageEditor
  4. 才调 runtime.step(state, nextContext)

也就是说,每一步的上下文都是从持久化消息现算的,不是循环开始时快照的。这让用户中途改页面、追加消息都能被下一步看到。上下文怎么拼见 第 2 章

4.4 生命周期:parked 不是 terminal

buildRunLifecycle(src/store/chat/slices/agentRun/actions/lifecycle/buildRunLifecycle.ts:134)把「一次运行」的钩子集中起来,四个面共用同一套语义。最关键的一条区分:

状态走哪个钩子触发终态副作用吗
completed / failed / cancelledcompleteRun / onRunError会(生成标题、排空队列、通知)
waiting_for_human(等人批准)onRunParked不会
waiting_for_async_tool(等异步工具)onRunParked不会
// src/store/chat/slices/agentRun/actions/lifecycle/buildRunLifecycle.ts:388 onRunParked
// Parked is NOT terminal: fire NO terminal side effects (title / queue …)

为什么这条重要: 停下来等人点「批准」的运行,如果被当成结束,就会去生成标题、排空排队消息——用户点完批准后世界已经变了。类型定义在 src/store/chat/slices/agentRun/actions/lifecycle/types.ts:18 RunParkedReason

4.5 入口那一圈:命令总线与信号桥

  • 命令总线(src/store/chat/slices/agentRun/actions/entries/commandBus/index.ts:31 processCommands)在路由决策之后、真正发送之前跑。它从富文本编辑器的 JSON 里抠出内建命令:/compact 直接触发上下文压缩然后 return,连消息都不发(src/store/chat/slices/agentRun/actions/entries/conversationLifecycle.ts 的 compression 分支)。@某个 agent 会被 parseSingleAgentMentionDirectRoute(src/store/chat/slices/agentRun/actions/entries/commandBus/parseCommands.ts:174)识别成直连路由。
  • 信号桥(src/store/chat/slices/agentRun/actions/lifecycle/agentSignalBridge.ts:53 emitClientAgentSignalSourceEvent)负责把浏览器里观察到的边界事件,推给服务器的 AgentSignal 管线去做去重与背压。设计意图写在注释里:浏览器可以「看见」事件,但不许在本地跑策略——策略统一在服务端。失败只 log 不抛,绝不阻塞 UI。
  • 流式状态(src/store/chat/slices/agentRun/actions/state/streamingStates.ts:29 internal_toggleToolCallingStreaming)用 fast-deep-equal 比一次再 set,避免高频 chunk 打爆 React 渲染。

5. 服务端执行面:关掉页面它还在跑

5.1 同一个 AgentRuntime,换一组 executor

服务端的组装长得和浏览器那份几乎一样,这是刻意的:

// apps/server/src/services/agentRuntime/AgentRuntimeService.ts:2451
const runtime = new AgentRuntime(agent as any, {
executors: createRuntimeExecutors(executorContext),
});

executorContext(AgentRuntimeService.ts:2967 起)里塞的是服务器才有的东西:serverDBmessageModelstreamManagertoolExecutionService,以及三个子 agent 委托(execSubAgent / execVirtualSubAgent / execGroupMember)。

所以「服务端执行面」不是另一套 agent 实现,是同一个内核换了一组手脚。这正是 第 1 章那个「大脑 / 引擎」分层带来的红利。

5.2 状态存哪:两种实现,一个工厂

createAgentStateManager()

enableQueueAgentRuntime ? ──── 否 ──→ InMemoryAgentStateManager
│ (Map,进程内,本地开发)
└── 是 ──→ AgentStateManager
(Redis,4 个 key 前缀,TTL 2h)

Redis 版把一次运行拆成四组 key(apps/server/src/modules/AgentRuntime/AgentStateManager.ts:56-59):

key 前缀存什么
agent_runtime_state当前 AgentState(整块 JSON)
agent_runtime_steps每一步的 StepResult
agent_runtime_meta操作元信息:状态、总步数、总花费、workspaceId
agent_runtime_events每步产生的事件

TTL 统一 2 小时(AgentStateManager.ts:60 DEFAULT_TTL)。workspaceId 被特意持久化进 metadata,注释解释了原因:队列 worker(比如 QStash 触发的 runStep)要靠它重建 workspace 作用域的运行时,否则消息/话题查询会漏掉整个工作区的行(AgentStateManager.ts:46-51)。

选哪一种由 createAgentStateManager(apps/server/src/modules/AgentRuntime/factory.ts:33)决定;Redis 连接是全局单例(apps/server/src/modules/AgentRuntime/redis.ts:86 getAgentRuntimeRedisClient),没配 REDIS_URL 就返回 null 而不是崩。

5.3 事件怎么广播:Redis Stream + 一层可选装饰器

runtime.step()


StreamEventManager.publishStreamEvent
│ ├── stripFinalStateInEventData ← 卡点裁剪
│ ├── XADD MAXLEN ~ 1000
│ └── EXPIRE 2h

└──(若配了 Gateway)──▶ GatewayStreamNotifier.pushEvent
└─ HTTP 推给 Agent Gateway
└─ WebSocket 下发给浏览器

三个细节:

  1. 裁剪是卡点式的。 每个经过 publishStreamEvent 的事件,data.finalState 都会被剪掉消息体和工具集再序列化(apps/server/src/modules/AgentRuntime/StreamEventManager.ts:191)。注释说明动机:长话题下单次 XADD 会撞破 Upstash 的 10MB 请求上限。
  2. 流是有界的。 XADD ... MAXLEN ~ 1000(StreamEventManager.ts:200-203),保留 2 小时(STREAM_RETENTION,StreamEventManager.ts:142)。
  3. Gateway 只是附加通道。 GatewayStreamNotifier(apps/server/src/modules/AgentRuntime/GatewayStreamNotifier.ts:25)是个装饰器:先 await inner.publish...,再 fire-and-forget 地 HTTP 推给网关。Redis 始终是权威存储;网关掉了不影响事件落库。并发有上限(MAX_INFLIGHT = 20)、单次超时 5 秒。

订阅侧XREAD BLOCK 1000(StreamEventManager.ts:301 subscribeStreamEvents),从调用方给的 lastEventId 往后读——这就是「断线重连能补上漏掉的事件」的底层机制。

5.4 事件多路复用:一条 WebSocket 带多个 operation

群聊广播场景下,主持 agent 的每个成员都是独立 operation。如果每个成员开一条 WebSocket,连接数会炸;如果只把成员事件发到成员自己的频道,又没人订阅。

解法是 mirrorToOperationId:成员 op 在元信息里声明「把我的事件也镜像到主持人的频道」,GatewayStreamNotifier 每次推送时额外投递一份(GatewayStreamNotifier.ts:28-49)。它有两条填充路径,因为队列模式下发成员 chunk 的 worker 根本没跑过那个 op 的 init:

  • 快路径: publishAgentRuntimeInit 时从初始 state 直接填。
  • 队列路径: pushEvent 首次遇到该 op 时,懒加载地从持久化的 op metadata 里解析并缓存(factory.ts:86-89 注入的 resolver)。

客户端那头由 createGatewayEventRouter(src/store/chat/slices/agentRun/actions/transports/gateway/gatewayEventRouter.ts:51)拆包:事件自带 operationId,等于 owner 的进主处理器,其它的懒建一个「只渲染、不驱动生命周期」的成员处理器(src/store/chat/slices/agentRun/actions/transports/gateway/gatewayMemberStreamHandler.ts:62)。没有 operationId 的老版本事件回退给 owner,而不是丢掉。

5.5 断了怎么接上:什么算「流结束」

这是本章最微妙的一处。AgentRuntimeCoordinator 定义了一个集合:

// apps/server/src/modules/AgentRuntime/AgentRuntimeCoordinator.ts:26
const STREAM_END_STATUSES = new Set([
'done', 'error', 'interrupted', 'waiting_for_human',
]);

注意 waiting_for_human 集合里而 waiting_for_async_tool 不在,原因写在上方注释里:

状态流还会有事件吗为什么
waiting_for_human不会状态还活着可以恢复,但恢复会开一个新的 operationId、有它自己的事件流
waiting_for_async_tool异步工具结果回填后,同一个 operationId 继续跑

搞反任何一个都会出线上 bug:前者会让客户端永远转圈等一个不会来的事件;后者会让界面在服务器还在等子 agent 时就显示「已停止」。

状态每次落盘时(AgentRuntimeCoordinator.ts:173 saveAgentState:180 saveStepResult)都用 hasEnteredStreamEndState 做一次「上一状态不是终态、这一状态是」的边沿检测,只在边沿上发 agent_runtime_end,避免重复。

5.6 服务端的配套模块

模块职责位置
CompletionLifecycle终态之后的一切:完成信号、onComplete/onError 钩子、把错误写回助手消息行。所有方法 fire-and-forget,保证快照收尾与锁释放一定跑到apps/server/src/services/agentRuntime/CompletionLifecycle.ts:121
HumanInterventionHandlerwaiting_for_human 的三个分支:批准(phase: 'human_approved_tool' 直通 call_tool)、拒绝但继续(当作用户反馈)、拒绝并中止apps/server/src/services/agentRuntime/HumanInterventionHandler.ts:39
OperationTraceRecorder逐步累积快照;上下文工程的输入输出绕开 Redis state、只进 trace,因为它曾占单步载荷的 ~97%apps/server/src/services/agentRuntime/OperationTraceRecorder.ts:89
AbandonOperationService反向收尾:Vercel 函数被杀后,由网关看门狗拿着 operationId 从新的调用里补完apps/server/src/services/agentRuntime/AbandonOperationService.ts:66
snapshotStore生产走 S3、开发走文件、其余关闭;S3 构造失败大声 log 但不炸运行apps/server/src/services/agentRuntime/snapshotStore.ts:34
stepPresentation把一步的结果转成给钩子/webhook/快照用的结构 + 一行人类可读日志摘要apps/server/src/services/agentRuntime/stepPresentation.ts:34
formatErrorForState把各种上游错误规整成带分类的 ChatMessageErrorapps/server/src/modules/AgentRuntime/formatErrorForState.ts
messagePersistErrors识别「用户中途删了话题/父消息」造成的外键冲突,归类成用户侧错误而非 500apps/server/src/modules/AgentRuntime/messagePersistErrors.ts:37

AbandonOperationService 里有一处值得单独提:被遗弃的运行如果是某个父运行的子 agent,收尾时必须把父运行从 waiting_for_async_tool 里救出来,否则父运行永远挂着(AbandonedSubAgentResume,AbandonOperationService.ts:35)。


6. Gateway 云沙箱面:连接层与一次性容器

6.1 客户端的 WebSocket 收管

AgentStreamClient(packages/agent-gateway-client/src/client.ts:64)是个不依赖 node:events 的浏览器版客户端。它的重连协议比常见实现讲究:

connect

├─ auth_success
│ │
│ ├─ resumeOnConnect 且 lastEventId 为空?
│ │ 是 → 进入 resumeMode,缓冲所有事件
│ │ 否 → 正常直通
│ │
│ └─ 发送 { type: 'resume', lastEventId, wantStatus: true }

├─ agent_event ──→ 记下 message.id 为 lastEventId
│ (resumeMode 下先进 resumeBuffer)

└─ resume_complete ──→ 冲刷缓冲、按序发出、再由 DO 的权威状态决定是否收尾

几个不明显的选择:

  • lastEventId 是逐事件更新的(client.ts:284),所以重连时能精确从断点续读,对上 §5.3 的 XREAD 起始位置。
  • 重放的结束由服务端的 resume_complete 说了算,不靠客户端定时器猜(client.ts:323 起)。注释直言:曾经靠 500ms 静默判定,导致「假取消」bug;现在宁可一直等——那是个安全可恢复的状态,心跳丢失仍会强制重连。
  • 只有本 op 的终态才结束会话(client.ts:302),多路复用进来的成员终态不算。
  • 心跳 30s、最多漏 3 拍、重连退避 1s → 30s(client.ts:13-17)。

6.2 客户端的三个入口

入口什么时候用位置
executeGatewayAgent新发一条消息走云端src/store/chat/slices/agentRun/actions/transports/gateway/gateway.ts:359
connectToGateway建立/替换某个 op 的连接src/store/chat/slices/agentRun/actions/transports/gateway/gateway.ts:161
reconnectToGatewayOperation页面刷新后从话题元信息里的 runningOperation 重连src/store/chat/slices/agentRun/actions/transports/gateway/gateway.ts:671

executeGatewayAgent 与浏览器面最大的不同:不走 sendMessageInServer,用户消息、助手消息、话题都由服务器在 execAgentTask 里创建(src/store/chat/slices/agentRun/actions/entries/conversationLifecycle.ts:769 的分支注释)。客户端只是先塞几个临时消息 id(tempMessageIds)顶住 UI,等第一个 step_start 事件到来时用服务器的真实 id 做 replaceMessages

重连路径上有两道防重复的闸(gateway.ts:687-706):连接状态不是 disconnected 就跳过(说明 gateway action 已经拥有这条连接);话题上已有更新的 running op 也跳过(避免新老两条连接同时收事件)。这类竞态在「新建话题 + 页面刷新」时最容易出现。

6.3 云沙箱里跑的其实是 CLI

这是很多人会猜错的一点:「云沙箱面」不是在容器里跑 LobeHub 的 agent 内核,而是在容器里跑 lh hetero exec——也就是把第 7 节那套本地 CLI 管线,原样搬进一次性容器。

// apps/server/src/services/heterogeneousAgent/sandboxRunner.ts:154 起
const args = ['lh', 'hetero', 'exec', '--type', agentType,
'--operation-id', operationId, '--topic', topicId,
'--render', 'none', '--input-json', '-'];

几处工程细节:

  • --cwd 必须显式给 /workspace(sandboxRunner.ts:157)。Claude Code 的会话文件按 cwd 编码存放(~/.claude/projects/<encoded-cwd>/),cwd 一变 --resume 就找不到会话。
  • prompt 走 base64 + stdin,不走 --prompt(sandboxRunner.ts:188-194)。注释说 echo 加 shell 引号会把内层 JSON 引号搞烂,base64 是引号安全的。
  • fire-and-forget:调用方不 await(sandboxRunner.ts:133-135),沙箱通过 aiAgent.heteroIngest tRPC 批量把事件推回服务器(apps/server/src/routers/lambda/aiAgent.ts:1990),由 HeterogeneousPersistenceHandler(apps/server/src/services/heterogeneousAgent/HeterogeneousPersistenceHandler.ts:205)落库并转成流事件。
  • 沙箱供应商可换(market / onlyboxes),由 createSandboxService 按环境变量选(apps/server/src/services/sandbox/factory.ts:29)。

7. 异构 CLI agent 面:把 Claude Code 装进聊天框

7.1 它要解决的小问题

claudecodex 是两个成熟的命令行 agent,它们自带工具链、自带权限模型、自带会话文件。LobeHub 不想重新实现它们,只想把它们的输出接到自己的聊天气泡上,顺便把「向用户提问」这种交互接回界面。

7.2 注册表:加一个新 CLI 要动几行

// packages/heterogeneous-agents/src/registry.ts:15
const registry: Record<string, AgentRegistryEntry> = {
'claude-code': { createAdapter: () => new ClaudeCodeAdapter() },
'codex': { createAdapter: () => new CodexAdapter() },
// 'kimi-cli': { createAdapter: () => new KimiCLIAdapter() },
};

配置分两张表(上游已挪到 packages/types/src/agent/heterogeneousAgent.ts:54 HETEROGENEOUS_AGENT_CONFIGS:341 REMOTE_HETEROGENEOUS_AGENT_CONFIGS,config.ts:21 转发导出):本地 CLI 型(有 command,起子进程)和远程平台型(靠 lh connect 的设备,没有子进程)。isRemoteHeterogeneousType(config.ts:38)就是 §3.2 第 2 条规则的判据。

7.3 从子进程到统一事件:一条流水线

CLI stdout (bytes)


JsonlStreamProcessor 按 \n 切行、容忍非 JSON 噪声行
│ jsonlProcessor.ts:6

CodexFileChangeTracker (仅 codex)读改前文件算 diff / 行数
│ codexFileChangeTracker.ts:158

AgentEventAdapter.adapt 各家私有 schema → HeterogeneousAgentEvent
│ registry.ts:28

toStreamEvent 盖上 operationId → AgentStreamEvent
│ streamEvent.ts:11

和 Gateway / 服务端完全同型的事件

编排者是 AgentStreamPipeline(packages/heterogeneous-agents/src/spawn/agentStreamPipeline.ts:59),桌面主进程和 lh hetero exec 都用它,所以消费端永远只见到一种线格式(注释原话就是这个意思)。

spawnAgent(packages/heterogeneous-agents/src/spawn/spawnAgent.ts:579)里有一处并发正确性处理值得抄:所有 push / flush 串在同一条 pipelineQueue Promise 链上(spawnAgent.ts:683)。原因有二——codex 的 tracker 要读磁盘,不串行会让第 2 块 chunk 先于第 1 块出结果;end 的 flush 必须排在所有 push 之后,否则异步迭代器可能在迟到事件入队前就 done: true(事件丢失)。

7.4 两家 CLI 的差异在哪

维度Claude CodeCodex
启动参数-p --input-format stream-json --output-format stream-json --verbose(spawnAgent.ts:139)exec --json --skip-git-repo-check(spawnAgent.ts:183)
输入怎么给stdin 写一行 stream-json,图片是 base64 内容块(spawn/input/buildAgentInput.ts:49 buildClaudeCompatibleStdin)stdin 写纯文本,图片落盘后用可重复的 --image <path>(spawn/input/buildAgentInput.ts:111 buildCodexInput)
权限模式bypassPermissions;以 root 运行(云沙箱)时降级为 acceptEdits + 白名单(spawnAgent.ts:173-181 CLAUDE_CODE_PERMISSION_ARGS)--dangerously-bypass-approvals-and-sandbox,除非调用方已给了执行模式标志(spawnAgent.ts:267 buildCodexArgs)
续会话--resume <sessionId> 追加在参数里exec resume <sessionId> -,子命令形态(spawnAgent.ts:273-275)
增量输出可选 --include-partial-messages 出 token 级 delta(桌面开,CLI/沙箱不开)无对应开关
适配器ClaudeCodeAdapter(adapters/claudeCode.ts:2436,现为 ClaudeCompatibleStreamAdapter 的薄子类)CodexAdapter(adapters/codex.ts:778)

ClaudeCodeAdapter 顶部那段注释是理解它的钥匙(adapters/claudeCode.ts:1-36):同一个 message.id 下的多个事件属于一个 LLM 回合,id 变了才是新回合、才建新的助手消息;tool_result 出现在 type: 'user' 事件里而不是 assistant 事件里;开了 partial 时 delta 先于完整块到达,所以已经流过的 message.id 要在 handleAssistant 里去重。

Windows 上还有一层 resolveCliSpawnPlan(packages/heterogeneous-agents/src/spawn/cliSpawn.ts:218):npm 装出来的 claude 往往是个 shim 脚本,直接 spawn 会失败,所以要读 shim 内容反推出真正的 node.exe + 脚本路径。

7.5 用一个 MCP server 把 CLI 的提问接回聊天框

Claude Code 在 -p 模式下会自注入一条 is_error: "Answer questions?" 的 tool_result,导致内建的 AskUserQuestion 根本走不通,所以 LobeHub 直接把它禁掉(spawnAgent.ts:146-147),换成自己的:

CLI 里的模型
│ 调用 mcp__lobe_cc__ask_user_question

AskUserMcpServer (本地 HTTP + MCP) AskUserMcpServer.ts:126
│ bridge.pending({ toolCallId })

AskUserBridge AskUserBridge.ts:104
│ events() 发出 agent_intervention_request

桌面主进程 broadcast('heteroAgentEvent') HeterogeneousAgentImpl.ts:1366

聊天界面渲染成一个可点选的工具气泡

用户提交 → submitIntervention IPC → bridge.resolve(toolCallId, …)

MCP 工具调用返回 → CLI 里的模型拿到答案继续跑

两个细节让它不至于卡死:

  • toolCallId 用 CC 的 _meta['claudecode/toolUseId'](AskUserBridge.ts:31-40),这样界面上的 tool_use 气泡和这次提问指的是同一个气泡,能对上号。
  • 有心跳有超时:默认 5 分钟截止,期间每 30 秒推一次 MCP notifications/progress,因为 CC 的 HTTP 传输大约 5 分钟无数据就断(AskUserBridge.ts:43-60)。超时按 { cancelled: true, cancelReason: 'timeout' } 返回,不是悬挂。

MCP server 是懒启动的单例(HeterogeneousAgentImpl.ts:1010 ensureBuiltinMcpServerStarted(原 ensureAskUserMcpServerStarted)),按 operationId 而不是 sessionId 索引待答问题,因为一个会话生命周期里会有很多次 operation(HeterogeneousAgentImpl.ts:427 opIdToIntervention)。

7.6 客户端侧:IPC 订阅 + 续会话的安全阀

executeHeterogeneousAgent(src/store/chat/slices/agentRun/actions/transports/hetero/heterogeneousAgentExecutor.ts:346)的流程注释写得很清楚:先订三个 IPC 频道(heteroAgentEvent / heteroAgentSessionComplete / heteroAgentSessionError,:254),再让主进程 spawn,主进程跑完整条流水线,渲染进程收到的已经是统一线格式,喂给同一个 createGatewayEventHandler

gatewayEventHandlerruntimeType: 'gateway' | 'hetero' 区分两者(src/store/chat/slices/agentRun/actions/transports/gateway/gatewayEventHandler.ts:326):hetero 模式下它只做逐事件的消息对账,终态的通知与队列排空由 hetero executor 自己拥有,避免双重通知。

续会话有一条严格规则(src/store/chat/slices/agentRun/actions/transports/hetero/heteroResume.ts:34 resolveHeteroResume):

话题元信息决定
heteroSessionId全新会话
有 sessionId、但没存过 workingDirectory不续(历史遗留数据无法验证)
有 sessionId、但存的 cwd 与当前 cwd 不同不续,并 toast 提示用户
两者都有且相等续,传 --resume

理由同 §6.3:CC 的会话按 cwd 分目录存,拿着 cwd 不匹配的 id 去 resume 只会得到 "No conversation found with session ID"。宁可重开也不传脏 id。

也因此,创建话题时就要把 workingDirectory 写进去、而不是等运行结束再补(src/store/chat/slices/agentRun/actions/entries/conversationLifecycle.ts:568 分支的注释:取消或报错的运行永远走不到那次补写,会让按项目分组丢话题、--resume 不安全)。


8. 设备与桌面:让本机变成可被调度的执行设备

8.1 桌面端:两条 IPC 通道

渲染进程 (React)
│ window.electron.ipcRenderer ← packages/electron-client-ipc

主进程 controllers/ ← HeterogeneousAgentCtr、GatewayConnectionCtr …
│ Unix socket / Windows named pipe ← packages/electron-server-ipc

Next.js 服务进程(桌面端内嵌的那个)
  • packages/electron-client-ipc/src/ipc.ts 用两层 Proxyservices.group.method(payload) 自动翻译成 invoke('group.method', payload) 频道名——加一个主进程方法不用改客户端胶水代码。
  • packages/electron-server-ipc/src/ipcServer.ts:13 ElectronIPCServernet.createServer,socket 路径按 appId 隔离,Windows 换成命名管道。
  • apps/desktop/src/overlay/ 是那个悬浮小窗(截屏选区、快捷聊天面板),apps/desktop/src/preload/ 是隔离桥。
  • packages/desktop-bridge 只管路由变体(routeVariants.ts),让同一套 SPA 路由在桌面/网页/弹窗下取不同的入口。

8.2 lh connect:把这台机器注册成设备

lh connect [--daemon] apps/cli/src/commands/connect.ts:59

├─ resolveDeviceIdentity / registerDevice apps/cli/src/device/register.ts:29
│ └─ deriveDeviceId(userId) packages/device-identity/src/index.ts:43
│ sha256(machineId | userId | SALT)

├─ GatewayClient WebSocket 连上网关 packages/device-gateway-client/src/client.ts:81
│ 监听 tool_call_request / rpc_request /
│ system_info_request / agent_run_request

└─ --daemon → spawnDaemon 后台常驻 apps/cli/src/daemon/manager.ts:185

deviceId 的推导方式解释了两条产品性质(packages/device-identity/src/index.ts:45-53):

  • 同机器同用户 → 同 id,重装 LobeHub 也不变(machine id 是 OS 级的)。
  • 同机器不同用户 → 不同 id,服务器无法据此把一台机器上的多个账号关联起来。

注释还特意说明为什么用快 hash 而不是 KDF:输入是高熵机器 UUID,不是低熵口令,慢 KDF 在这里不增加任何安全性。

8.3 服务器怎么把一次运行派到设备上

服务器 网关 设备(lh connect)
│ dispatchAgentRun ──HTTP──▶ /api/device/agent/run
│ (device-gateway-client │
│ /src/http.ts:201) │──WS: agent_run_request──▶ │
│ │
│ spawnHeteroAgentRun│ apps/cli/src/device/agentRun.ts:51
│ 起 `lh hetero exec`│
│ │
│◀───────── heteroIngest 批量事件 ─────────────────────────── │

派发载荷由 buildHeteroExecStdinPayload(packages/heterogeneous-agents/src/protocol/execStdinPayload.ts:31)统一构造,三个派发点(桌面的 spawnLhHeteroExeclh connect 守护进程、服务器沙箱 runner)共用,防止三处载荷格式漂移。纯 prompt 保持历史上的 JSON 字符串形态,带 systemContext 或图片时才升级成内容块数组。

除了 agent 运行,网关还转发通用 RPC(http.ts:234 invokeRpc)——服务器把 { method, params } 原样透传给设备的 RPC 分发器,按 requestId 对回包。新增设备方法不需要改网关路由,这是它和 LLM 面向的 executeToolCall 通道的关键区别。

桌面端也可以扮演设备:GatewayConnectionCtr(apps/desktop/src/main/controllers/GatewayConnectionCtr.ts:119)在主进程里做同样的事,并且在新会话第一轮注入一段 lh notify 协议说明,告诉被拉起的 CLI「怎么把结果推回聊天气泡」(GatewayConnectionCtr.ts:47 buildNotifyProtocol)。


9. 回答开头那三个问题

把前面拆开的东西合成一张速查表。这三列就是本章的目标。

执行面我这次对话在哪台机器上跑状态存在哪断了怎么接上
client你的浏览器 / Electron 渲染进程zustand store + messages接不上,关页面即终止;工具挂起态靠 DB 消息重建
gateway(服务端)LobeHub 服务器进程或队列 workerRedis agent_runtime_*(2h TTL)或进程内 MapWebSocket 带 lastEventId 重连,服务端 XREAD 从断点补发,resume_complete 收尾
sandbox一次性云容器里的 lh hetero exec服务器 DB(经 heteroIngest 落库)+ 容器内 CC 会话文件topic.metadata.heteroSessionId + 固定 /workspace--resume
hetero(本机)你机器上的 claude / codex 子进程本机 CC/Codex 会话目录 + LobeHub DB 消息resolveHeteroResume 校验 cwd 一致才 --resume,否则重开
device某台 lh connect 的机器同 sandbox(事件回流到服务器)设备离线则 device-unrouted,不猜别的机器

10. 巧妙之处(可以直接借鉴的)

  1. 把「在哪跑」做成入口处一次性求值的纯函数。 selectRuntimeType / resolveExecutionTarget / resolveExecutionPlan 全是无副作用的纯函数,新增入口(重新生成、恢复、子 agent、定时任务)不需要重新推导规则(agentDispatcher.ts:104-112 的注释就是这个设计意图)。

  2. localdevice 坚决不合并。 明知指向同一台机器也不折叠,因为二者的可观测性不同(executionTarget.ts:138-144)。「看起来等价」的两个状态,只要用户能感知差异,就不该在代码里合并。

  3. 自动选设备只在显式的 auto 模式发生。 local / device 没绑定时宁可 device-unrouted 也不抓一台在线的用(executionTarget.ts:517-519)。这是「永不替用户做他没授权的事」的具体落地。

  4. parked ≠ terminal 被写成一等概念。 客户端(buildRunLifecycle.ts:471)和服务端(AgentRuntimeCoordinator.ts:27)各有一套,但语义对齐,且两处都用注释解释了「为什么 waiting_for_human 算流结束、waiting_for_async_tool 不算」。

  5. 重放结束由服务端权威事件决定,不由客户端定时器猜。 resume_complete 取代了原来的静默超时判定,注释明确指出旧方案造成过「假取消」(agent-gateway-client/src/client.ts:238-247)。

  6. 单卡点做载荷裁剪。 stripFinalStateInEventData 放在 publishStreamEvent 内部,而不是散在各调用点(StreamEventManager.ts:191),保证没有任何路径能绕过 10MB 上限的防护。

  7. 异步流水线串成一条 Promise 链保序。 spawnAgent 里的 pipelineQueue 同时解决了乱序和事件丢失两个问题,注释把两种失败模式都写清楚了(spawnAgent.ts:683 pipelineQueue)。

  8. 用 MCP 反向打通交互。 把「CLI agent 想问用户」这件事,通过一个本地 MCP server + 桥接器接回聊天界面,而不是去改 CLI(§7.5)。

  9. 快照/追踪永远是 best-effort。 S3 构造失败只关追踪不炸运行,但大声 log——注释说明这里曾经因为静默失败而让整个生产环境没有快照(snapshotStore.ts:41-45)。


11. 边界与局限

  • 浏览器面没有断点续传。 页面一关,client 运行就没了;只有 gateway 系的运行才在话题元信息里留 runningOperation 供重连。
  • 队列模式硬依赖 Redis。 AGENT_RUNTIME_MODE=queue 且没有 REDIS_URL 时直接抛错(factory.ts:41-45),不做降级。
  • 状态 TTL 只有 2 小时(AgentStateManager.ts:60 DEFAULT_TTL)。超过这个窗口的中断运行无法从 Redis 恢复,只能靠 DB 消息和快照。
  • 异构 CLI 的续会话是「宁缺毋滥」的。 换目录、老数据都会重开会话,用户会看到上下文丢失的提示。这是 resolveHeteroResume 的有意选择。
  • hetero 子 agent 分发未实现。 dispatchNonHeteroSubAgenthetero 直接抛错(nonHeteroSubAgentDispatcher.ts:129)。
  • 本地 CLI 面权限是放开的。 桌面上默认 bypassPermissions / --dangerously-bypass-approvals-and-sandbox(spawnAgent.ts:181:143);安全边界依赖用户自己选目录,不依赖 CLI 的审批弹窗。
  • 异构 agent 的模型选择不归 LobeHub 管。 发消息时只先写入 provider,实际模型由适配器事后回填(src/store/chat/slices/agentRun/actions/entries/conversationLifecycle.ts hetero 分支注释)。

12. 代码地图(导航索引)

路由决策

主题文件符号
runtime 三选一src/store/chat/slices/agentRun/actions/dispatch/agentDispatcher.tsAgentRuntimeTypeselectRuntimeTypeRuntimeSelectionContextAgentInvocationIntent
执行目标五选一src/helpers/executionTarget.tsresolveExecutionTargetexecutionTargetToRuntimeModeresolveExecutionPlanExecutionPlan
子 agent 继承src/store/chat/slices/agentRun/actions/dispatch/nonHeteroSubAgentDispatcher.tsdispatchNonHeteroSubAgent
发送入口src/store/chat/slices/agentRun/actions/entries/conversationLifecycle.ts三条分支:hetero / gateway / client
控制入口(批准/停止/恢复)src/store/chat/slices/agentRun/actions/entries/conversationControl.tsapproveToolCallingrejectToolCallingstopGenerateMessage
斜杠命令与提及src/store/chat/slices/agentRun/actions/entries/commandBus/processCommandsparseSingleAgentMentionDirectRoute

浏览器执行面

主题文件符号
运行时组装与主循环src/store/chat/slices/agentRun/actions/transports/client/streamingExecutor.tsinternal_createAgentStatenew GeneralChatAgentnew AgentRuntime
运行生命周期src/store/chat/slices/agentRun/actions/lifecycle/buildRunLifecycle.tsbuildRunLifecycleonRunParked
生命周期类型src/store/chat/slices/agentRun/actions/lifecycle/types.tsRunScopeRunParkedReasonAgentRunLifecycle
信号上报src/store/chat/slices/agentRun/actions/lifecycle/agentSignalBridge.tsemitClientAgentSignalSourceEvent
流式 UI 状态src/store/chat/slices/agentRun/actions/state/streamingStates.tsinternal_toggleToolCallingStreaming

服务端执行面

主题文件符号
运行时组装 / 步进apps/server/src/services/agentRuntime/AgentRuntimeService.tscreateAgentRuntimeexecuteStepstartExecution
终态收尾apps/server/src/services/agentRuntime/CompletionLifecycle.tsCompletionLifecycle
人类介入三分支apps/server/src/services/agentRuntime/HumanInterventionHandler.tsHumanInterventionHandler.process
逐步快照apps/server/src/services/agentRuntime/OperationTraceRecorder.tsOperationTraceRecorder
被杀进程的反向收尾apps/server/src/services/agentRuntime/AbandonOperationService.tsAbandonOperationServiceAbandonedSubAgentResume
快照存储选择apps/server/src/services/agentRuntime/snapshotStore.tscreateDefaultSnapshotStore
步骤展示/日志apps/server/src/services/agentRuntime/stepPresentation.tsbuildStepPresentation
状态存储(Redis)apps/server/src/modules/AgentRuntime/AgentStateManager.tsAgentStateManagerAgentOperationMetadata
状态存储(内存)apps/server/src/modules/AgentRuntime/InMemoryAgentStateManager.tsInMemoryAgentStateManager
事件总线apps/server/src/modules/AgentRuntime/StreamEventManager.tsStreamEventManagerstripFinalStateInEventDatasubscribeStreamEvents
网关推送装饰器apps/server/src/modules/AgentRuntime/GatewayStreamNotifier.tsGatewayStreamNotifiermirrorTargets
状态/事件协调apps/server/src/modules/AgentRuntime/AgentRuntimeCoordinator.tsAgentRuntimeCoordinatorSTREAM_END_STATUSES
实现选择apps/server/src/modules/AgentRuntime/factory.tscreateAgentStateManagercreateStreamEventManager
Redis 单例apps/server/src/modules/AgentRuntime/redis.tsgetAgentRuntimeRedisClient
错误规整apps/server/src/modules/AgentRuntime/formatErrorForState.tsmessagePersistErrors.tsformatErrorForStatePERSIST_FATAL_MARKER

Gateway 与云沙箱

主题文件符号
浏览器 WebSocket 客户端packages/agent-gateway-client/src/client.tsAgentStreamClienthandleMessageresume_complete 分支
客户端连接管理src/store/chat/slices/agentRun/actions/transports/gateway/gateway.tsconnectToGatewayexecuteGatewayAgentreconnectToGatewayOperation
多 op 解复用src/store/chat/slices/agentRun/actions/transports/gateway/gatewayEventRouter.tscreateGatewayEventRouter
事件落地(四面共用)src/store/chat/slices/agentRun/actions/transports/gateway/gatewayEventHandler.tscreateGatewayEventHandler
群成员渲染src/store/chat/slices/agentRun/actions/transports/gateway/gatewayMemberStreamHandler.tscreateGatewayMemberStreamHandler
沙箱里起 CLIapps/server/src/services/heterogeneousAgent/sandboxRunner.tsspawnHeteroSandbox
沙箱事件回流apps/server/src/services/heterogeneousAgent/HeterogeneousPersistenceHandler.tsHeterogeneousPersistenceHandler
沙箱供应商apps/server/src/services/sandbox/factory.tscreateSandboxService

异构 CLI agent

主题文件符号
适配器注册packages/heterogeneous-agents/src/registry.tscreateAdapterlistAgentTypes
类型配置packages/heterogeneous-agents/src/config.tsHETEROGENEOUS_AGENT_CONFIGSisRemoteHeterogeneousType
拉起子进程packages/heterogeneous-agents/src/spawn/spawnAgent.tsspawnAgentCLAUDE_CODE_BASE_ARGSCODEX_REQUIRED_ARGS
Windows shim 解析packages/heterogeneous-agents/src/spawn/cliSpawn.tsresolveCliSpawnPlan
JSONL 分帧packages/heterogeneous-agents/src/spawn/jsonlProcessor.tsJsonlStreamProcessor
事件流水线packages/heterogeneous-agents/src/spawn/agentStreamPipeline.tsAgentStreamPipeline
盖 operationIdpackages/heterogeneous-agents/src/spawn/streamEvent.tstoStreamEvent
Codex 文件变更packages/heterogeneous-agents/src/spawn/codexFileChangeTracker.tsCodexFileChangeTracker
多模态输入packages/heterogeneous-agents/src/spawn/input/buildAgentInput.tsbuildAgentInput
派发载荷packages/heterogeneous-agents/src/protocol/execStdinPayload.tsbuildHeteroExecStdinPayload
提问回环packages/heterogeneous-agents/src/askUser/AskUserMcpServerAskUserBridge
两家适配器packages/heterogeneous-agents/src/adapters/ClaudeCodeAdapterCodexAdapter
客户端执行器src/store/chat/slices/agentRun/actions/transports/hetero/heterogeneousAgentExecutor.tsexecuteHeterogeneousAgent
续会话安全阀src/store/chat/slices/agentRun/actions/transports/hetero/heteroResume.tsresolveHeteroResume

设备与桌面

主题文件符号
桌面 CLI 控制器apps/desktop/src/main/controllers/HeterogeneousAgentCtr.tsHeterogeneousAgentCtrensureAskUserMcpServerStarted
桌面设备连接apps/desktop/src/main/controllers/GatewayConnectionCtr.tsGatewayConnectionCtrbuildNotifyProtocol
渲染↔主进程 IPCpackages/electron-client-ipc/src/ipc.tscreateInvokeProxy
主进程↔服务进程 IPCpackages/electron-server-ipc/src/ipcServer.tsElectronIPCServer
路由变体packages/desktop-bridge/src/routeVariants.ts
设备身份packages/device-identity/src/index.tsderiveDeviceId
设备网关 WSpackages/device-gateway-client/src/client.tsGatewayClientagent_run_request
设备网关 HTTPpackages/device-gateway-client/src/http.tsGatewayHttpClientdispatchAgentRuninvokeRpc
lh connectapps/cli/src/commands/connect.tsregisterConnectCommand
设备注册apps/cli/src/device/register.tsregisterDeviceresolveDeviceIdentity
设备侧起 CLIapps/cli/src/device/agentRun.tsspawnHeteroAgentRun
守护进程apps/cli/src/daemon/manager.tsspawnDaemonstopDaemon
lh hetero execapps/cli/src/commands/hetero.tsregisterHeteroCommand

13. 接着读什么