Day 19 / 共 20 天 · 第 4 周 平台化进阶 · 加深版

远程控制 / daemon / workflow

前面讲完了 Agent 的核心与本地体验;今天走到最外圈——本 fork 最有特色的两块扩展:手机/web 远程操控终端里的 Claude Code,以及 workflow 多 agent 编排引擎。贴真实代码,看一类系统能扩展到什么程度,也为明天 Day 20 收官做铺垫。

📍 你在整门课的位置 · 第 4 周「平台化进阶」(今天讲最外圈的扩展:远程控制 + 多 agent 编排)
D16 会话/记忆 D17 配置/成本 D18 TUI 深入 D19 远程/daemon/workflow D20 收官
L01

先说清:这些是本 fork 的扩展

🤔 痛点:Claude Code 在公司电脑上跑,你却出门在外 任务跑到一半你得去开会,能不能用手机看看它跑到哪、临时批个权限?还有:一个大任务("给整个仓库补测试")能不能自动拆成几十个子任务并行跑、断了还能接着跑?
💡 本质:远程控制 = 家里装了个"回拨电话"的智能音箱 远程控制像给你本地 CLI 装了个会主动回拨的智能音箱:它不开放让外人打进来(不用公网 IP/开端口),而是自己定期"打电话回服务器"问有没有指令(长轮询/反向连接)——你在手机上说的话,它回拨时取走执行。workflow 引擎则像项目经理写的一张可断点续跑的排产脚本,把大任务拆成一串子 agent 自动派发。
重要前提:今天讲的远程控制、daemon、bridge、ssh、workflow-engine,官方 Claude Code 都没有——是这个逆向 fork 加的额外功能(Day 01 讲的"扩展了更多特性")。而且 src/server/* 有多个文件是明写的 "Auto-generated stub"(未完成占位)。所以今天定位是"了解一类 Agent CLI 能扩展到什么程度",而非官方核心行为。

怎么判断"是不是官方功能"(Day 01/17 窍门):看 feature('BRIDGE_MODE') 这种自定义门控、包名 @claude-code-best/*、成片 stub 文件。这几块全中。

L02

远程控制全景

目标:用手机/网页远程操控你本地终端里的 Claude Code 会话。数据流:

手机/网页操作界面 RCSremote-control-server bridgeCLI 内的客户端 子 CLI 会话真正干活的

组件:src/bridge/(CLI 侧远程客户端)+ packages/remote-control-server/(自托管服务端,Hono+Bun+React Web UI,Docker 部署)+ src/daemon/(后台守护)+ src/ssh/(SSH 远程部署)。

关键设计:bridge 主动向 RCS "长轮询"要活。这意味着你的本地 CLI 不需要公网 IP、不用开端口——它主动去连服务器问"有没有任务给我"。就像你不用装门铃(让别人来按),而是自己定期出门看信箱。这是"内网机器如何被外部控制"的经典方案(反向连接),避免了本地机器暴露在公网的安全风险。RCS 可自托管,设 CLAUDE_BRIDGE_BASE_URL + token 后跑 ccb --remote-control
L03

bridge:长轮询 + spawn 子会话(真实代码)

src/bridge/(40 文件)。入口 runBridgeLoopbridgeMain.ts:140)。核心是长轮询——真实代码bridgeApi.ts:214):

// bridge 主动 GET 服务器的 work/poll 接口,问"有没有任务"
`${deps.baseUrl}/v1/environments/${environmentId}/work/poll`
// 日志:GET .../work/poll -> 200 (no work, N consecutive empty polls)
//       GET .../work/poll -> 200 workId=... type=... sessionId=...

其它机制:

  • spawn 子 CLI:拿到任务就起一个子 CLI 进程跑(--print --sdk-url ... --input-format stream-json),env 里剥离 bridge token、改用会话专属 token(安全)。
  • 三层凭证:可信设备 token + work secret/JWT(到期前 5 分钟刷新)+ 二维码配对。
为什么远程任务要 spawn 一个"子 CLI 进程"? 而不是在当前进程直接跑?隔离。远程来的任务在独立子进程跑,用独立的会话 token(不是你本地的凭证)——即使远程任务出问题,也不会污染/危及你正在用的主会话。这跟 Day 15 子代理、Day 18 沙箱的隔离思想一致:不受信/独立的工作放进隔离的执行环境。子进程用 --input-format stream-json 通过标准流通信(像 Day 12 的 stdio MCP)。长轮询(long poll):客户端发一个请求,服务器如果暂时没数据就"挂着"不立刻回,直到有数据或超时——比"每秒问一次"更省资源。
L04

daemon:后台守护进程

src/daemon/feature('DAEMON'))让远程控制能"一直挂着"——你关掉终端,daemon 仍在后台跑、接远程任务。

  • daemonMainmain.ts:52):start/stop/status 等子命令。
  • runSupervisor:230):监管 worker,崩溃指数退避重启(退出码 78 视为永久错误不重启)。
  • 唯一内置 worker = remoteControl,实体 runRemoteControlWorkerrunBridgeHeadless(把 daemon 和 bridge 连起来)。
daemon 是什么? 一个在后台一直运行的进程(守护进程),脱离你的终端存活。这里作用是"保证远程控制常在线"——你不用一直开着终端窗口。指数退避重启(exponential backoff):worker 崩了就重启,但如果反复崩,重启间隔越来越长(1s→2s→4s…),避免疯狂重启耗尽资源。这是守护进程管理子进程的标准做法。"退出码 78 = 永久错误不重启"是一种"知道自己没救了别再试"的约定。
L05

远程权限桥:手机上批准工具(真实代码)

一个精妙问题:远程会话里 Agent 要调 Edit,权限卡该弹在哪?——弹在你的手机/网页上src/remote/remotePermissionBridge.ts:12 有个关键函数:

export function createSyntheticAssistantMessage(...) {
  // 远端执行工具时,本地没有真实的 assistant 消息,
  // 就"伪造"一个带 tool_use 的消息,好复用本地那套权限卡 UI
}
"伪造 assistant 消息"妙在哪? Day 09 讲过,权限卡是由"模型发出 tool_use → useCanUseTool 拦截"触发的。但远程场景里,模型跑在远端,本地根本没收到那条 tool_use 消息。为了复用本地现成的权限卡 UI 和"await Promise"逻辑(Day 09 那套),就伪造一条"假装模型说要调这个工具"的消息喂给本地——本地照常弹卡、你照常批,结果再传回远端。用"伪造消息"让远程流程复用本地 UI,避免为远程再写一套权限界面。这是"适配层"的典型手法——把新场景翻译成旧场景能处理的形式。
L06

workflow 引擎:脚本化多 agent 编排(真实原语)

packages/workflow-engine/——"多 agent 编排"(对应 /ultracode/workflows)。核心抽象不是图或状态机,而是一段确定性、可续跑的命令式 JS 脚本。引擎注入给脚本的原语——真实类型packages/workflow-engine/src/engine/script.ts:11):

export type WorkflowHooks = {
  agent: (prompt, opts?) => Promise<unknown>          // 派一个子 agent 跑,返回结果
  parallel: (thunks) => Promise<Array>                // 并行跑多个,全完成才返回
  pipeline: (items, ...stages) => Promise<Array>      // 每个 item 流过所有 stage
  phase: (title) => void                             // 进度分组标签
  log: (message) => void
  workflow: (nameOrRef, args?) => Promise<unknown>    // 调另一个 workflow
}
你/模型写一小段 JS 脚本,里面调 agent()/parallel()/pipeline()。引擎执行脚本,把每个 agent() 调用派发成一次真实的 Claude Code 子 agent 运行(src/workflow/backends/claudeCodeBackend.ts,委派给 Day 15 的 runAgent)。
agent("列出测试")→ 得到 [a.ts, b.ts, c.ts] parallel(...)按文件数动态 fan-out 子 agent:修 a.ts(Day 15 runAgent) 子 agent:修 b.ts 子 agent:修 c.ts
图注:一段 JS 脚本"先发现、再动态并行处理"——每个 agent() 落成一次真实的 Day 15 子 agent 运行。
📝 举个例子:三行脚本干一件大事 const files = await agent("列出所有缺测试的文件") → 拿到 3 个文件 → await parallel(files.map(f => () => agent(`给 ${f} 补测试`))) → 引擎同时派 3 个子 agent 各修一个文件,全部完成才返回。若中途挂了,--resume 时已修好的靠 journal 跳过(L07)。
为什么用"JS 脚本"而非"可视化流程图"? 因为脚本能表达任意控制流——循环、条件、动态 fan-out("找出所有测试文件,每个派一个 agent 修")。图形化编排在这些场景下很笨拙。写 const files = await agent("列出所有测试"); await parallel(files.map(f => () => agent(\`修 ${f}\`))) 就能"发现-然后-并行处理"。这就是你在本对话里可能见过的 Workflow 工具背后的引擎。它"零核心层运行时依赖,通过端口适配器和世界对话"——引擎本身不知道 Claude Code 存在,backend 才把它接上。注意:workflow 建立在 Day 15 的"递归 query"(runAgent)之上——又是统一原语的复用。
L07

workflow 可续跑:journal 机制

workflow 可能跑很久(几十个 agent)。中途挂了怎么办?靠 journal(日志)可续跑(resume)

  • 每个 agent() 调用完成后,把结果记进 journal。
  • 续跑时,journal 命中的 agent 调用直接返回记录的结果、跳过重跑——只重跑没完成的。
  • 如果脚本源码 hash 变了,整份 journal 失效重跑(防止用旧结果跑新逻辑)。
  • 持久化在 .claude/workflow-runs/(原子写 tmp+rename,保留最近 50 个)。
为什么要"确定性 + journal"? 要可续跑,脚本必须确定性——同样的输入每次跑出同样的过程(所以引擎沙箱化了 Date.now()/Math.random(),禁止不确定来源)。这样中断后,前面已完成的 agent 调用能安全地用 journal 里的旧结果"快进",只重跑断点之后的。这跟 Day 03 的 checkpointer(断点续跑)是同类思想,但粒度是"整个 agent 调用"。TUI 面板 /workflows 能实时看进度、杀/续跑。
L08

今日小结 + 动手

🧠 今天你应该能回答

  • 为什么这些是"扩展"而非官方功能?怎么判断?
  • 远程控制数据流?bridge 为什么"长轮询主动要活"?
  • 远程任务为什么 spawn 独立子 CLI 进程?
  • 远程权限桥为什么"伪造 assistant 消息"?(复用本地 UI)
  • workflow 引擎的核心抽象是什么(脚本 vs 图)?有哪些原语?
  • workflow 怎么靠 journal 做可续跑?为什么要确定性?

✋ 动手:对着真实代码读一遍

# 1. bridge 长轮询(L03)
grep -n "work/poll\|runBridgeLoop" src/bridge/bridgeApi.ts src/bridge/bridgeMain.ts | head

# 2. 远程权限桥"伪造消息"(L05)
sed -n '12,40p' src/remote/remotePermissionBridge.ts

# 3. workflow 原语(L06,本页精华)
sed -n '10,26p' packages/workflow-engine/src/engine/script.ts

# 4. workflow backend 委派 runAgent(接 Day 15)
grep -n "runAgent\|claudeCodeBackend" src/workflow/backends/claudeCodeBackend.ts | head

# 5. daemon 监管
sed -n '52,60p' src/daemon/main.ts
明天预告 · Day 20(收官):最后一天讲构建/测试/native 包,然后把 20 天串成一张完整地图、总结贯穿全书的设计哲学、给你继续深入的路线。

← Day 18 TUI 深入 Day 20 · 构建/测试/native + 收官 →