跳到主要内容

数据截至 (上游 commit eb980a5c9eea)

Happy — 架构与原理

30 秒导读: 你在终端里把 claude 改成 happy claudecodex 改成 happy codex),这个会话就同时挂上了一条端到端加密的消息流——手机上能看它在干什么、能追加提示词、能逐个批准它要跑的命令。它自己不是 agent:没有模型循环,智能全在被它包住的那个 CLI 里;中间那台服务器只经手密文,看不到你的代码。


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

一句话定义: Happy 是一层会话外壳——它在你本机启动真正的编码 agent CLI,同时把这次会话的每一条消息加密后同步到手机和网页,让你能从别的设备接管同一个会话。

解决什么问题

场景:你在公司电脑上让 Claude Code 重构一个大模块,它要跑二十分钟。你去吃午饭了。

  • 它跑完了吗?出错了吗?——你不知道,得回工位看。
  • 它卡在「要不要允许我执行 rm -rf build/」的确认框上,干等了十五分钟。
  • 你路上突然想到该补一句需求——只能记在备忘录里。

Happy 把这三件事搬到手机上:看进度、批权限、追加提示词

给谁用

人群典型用法
用 Claude Code / Codex 的开发者离开工位后继续盯着长任务
手上有多台机器的人从手机挑一台机器,凭空开一个新会话
在意代码隐私的人中继服务器只存密文;也可以自己部署一台

它能做什么

  • 包住 claude / codex / gemini 等 CLI,不改变你在终端里的原生使用体验
  • 把会话消息端到端加密后同步给手机 App / 网页 App;服务器只见密文。
  • 双向接管:手机上点一下就把控制权拿走;终端里按个键就抢回来。
  • 远程逐个批准工具调用(含 plan 模式、「本会话都允许」这类决策)。
  • 需要权限或出错时发推送通知。
  • 装一个守护进程后,可以从手机凭空在这台机器上新开一个会话,不必先坐到电脑前。

用起来什么样

安装后,唯一要改的是你敲的那行命令:

npm install -g happy

# 原来这么用:
claude
codex

# 现在这么用:
happy claude # 或者直接 happy
happy codex

第一次运行会打印一个二维码,手机 App 扫一下就配对完成——之后所有会话自动出现在 App 里,终端里的体验和直接跑 claude 一模一样。

子命令分发集中在一处:packages/happy-cli/src/index.ts:49args[0] 当子命令,codexhandleCodexCommandindex.ts:139),gemini / acp / daemon / doctor / auth 各有分支;而 happy claudeclaude 这个词会被直接丢掉(index.ts:615),落到默认路径 runClaudeindex.ts:783)——所以 happyhappy claude 是同一件事。

一句话直觉

把 Happy 想成给终端会话装的一套「投屏 + 遥控器」,而投屏信号是加密的——中间的电视盒子只负责转发一堆看不懂的乱码,但它知道该转给谁。

本节到此不涉及代码细节。往下开始讲它怎么转。


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

2.1 七个包,四个是主力

干什么入口
happy-cli装在开发机上的壳:启动真 CLI、加密上行、跑双模态状态机、守护进程packages/happy-cli/src/index.ts:49
happy-appExpo 写的客户端(iOS / Android / Web / 桌面):持有私钥、解密、把消息归一成聊天界面、发 RPCpackages/happy-app/sources/sync/sync.ts:105class Sync
happy-server中继(Fastify + Prisma/Postgres + Socket.IO):只存密文、按房间转发 RPC、发推送packages/happy-server/sources/main.ts:14main)→ startApi
happy-wire两端共用的 Zod 线协议(会话信封、控制消息)packages/happy-wire/src/index.ts:1-6

另外三个是配角:happy-agent(命令行版的远程遥控器,脚本里创建 / 发消息 / 监控会话,packages/happy-agent/src/index.ts)、codium(Electron 桌面壳,复用同一套加密与同步,也是全仓库唯一用到 node-pty 的地方,packages/codium/package.json:96)、happy-app-logs

服务器的启动顺序很短:连数据库 → 初始化加密与 GitHub 模块 → startApi() 起 Fastify(packages/happy-server/sources/app/api/api.ts:37),并在同一个 HTTP server 上挂 Socket.IO(socket.ts:18startSocket)。

2.2 一条消息怎么走完全程

怎么读这张图: 从左到右是数据流向;中间那一格是服务器,它拿到的只有密文,连解密都做不到;两侧共用一份线协议定义。

开发机(你的电脑) 服务器(读不懂内容) 手机 · 网页 · 桌面
┌────────────────────────┐ ┌────────────────────────┐ ┌────────────────────────┐
│ happy-cli │ │ happy-server │ │ happy-app(Expo) │
│ ├ 真 claude / codex │密文│ ① 存 {t:'encrypted' │密文│ ├ 持有私钥、负责解密 │
│ ├ 会话密钥(只在本机) │───▶│ ,c:<base64>} │───▶│ ├ reducer → 聊天界面 │
│ ├ 注册 RPC 方法 │◀───│ ② 按房间转发 RPC │◀───│ ├ 发新提示词 │
│ └ daemon 守护进程 │加密│ ③ 发推送通知 │加密│ └ 批准 / 拒绝工具调用 │
└────────────────────────┘ └────────────────────────┘ └────────────────────────┘
│ │
└──────────── 共享线协议:@slopus/happy-wire(Zod)──────────┘

服务器侧的「读不懂」是数据库层面的事实:会话消息表里 content 存的就是那个 {t:'encrypted', c} 的 JSON,metadata / agentState 是 base64 密文字符串,dataEncryptionKey 是一段原样进出的 Bytes

依据:packages/happy-server/prisma/schema.prisma:94model Session,全部不透明字段见 :94-115)、:117model SessionMessage);packages/happy-cli/src/api/apiSession.ts:675enqueueMessage)。

2.3 部件一句话职责

部件干什么在哪个文件
runClaude总装配:建会话、起 MCP / hook 服务、拼 metadata,最后进循环packages/happy-cli/src/claude/runClaude.ts
loop双模态状态机:localremote 反复切换packages/happy-cli/src/claude/loop.ts:52
claudeLocalLauncher本地模式:spawn 真 claude,靠读它的 JSONL 转录文件同步packages/happy-cli/src/claude/claudeLocalLauncher.ts
claudeRemoteLauncher远程模式:用 Claude Agent SDK 代跑,手机是唯一输入端packages/happy-cli/src/claude/claudeRemoteLauncher.ts
ApiSessionClient会话通道:加密、outbox / inbox、RPC、乐观并发packages/happy-cli/src/api/apiSession.ts
ApiMachineClient机器通道:注册 machine 级 RPC、同步 daemonState、心跳packages/happy-cli/src/api/apiMachine.ts:115
rpcHandler服务器 RPC 中继:rpc:<userId>:<method> 房间路由packages/happy-server/sources/app/api/socket/rpcHandler.ts
reducerApp 端把裸消息流归一成带工具卡片的会话packages/happy-app/sources/sync/reducer/reducer.ts

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

  1. 配对:CLI 现场生成一对 X25519 临时密钥,把公钥编进 happy://terminal?<公钥> 的二维码打在终端里(ui/auth.ts:102);已登录的手机用这把公钥把自己的密钥材料封好回传,CLI 轮询取回后用私钥解开(ui/auth.ts:162:234)。凭据落到 ~/.happy/access.keyconfiguration.ts:47)。服务器全程只是个信箱。
  2. 建会话:CLI 随机生成一把会话密钥,用手机给的公钥封好,连同已加密的 metadata / agentState 一起 POST /v1/sessionsapi/api.ts:30getOrCreateSession,密钥封装在 :43:49),再开一条 Socket.IO 长连(/v1/updates)。
  3. 进循环:默认 local 模式,直接 spawn 真的 claudestdio 全部 inheritclaudeLocal.ts:316),终端完整交给它。
  4. 同步:CLI 监听 Claude Code 自己写的 JSONL 转录文件,把每条新记录映射成统一信封、加密、按 seq 排队上行(apiSession.ts:675);下行走 Socket.IO 的 update 事件(apiSession.ts:304)。
  5. 接管:你在手机上发一句话 → 服务器把加密 RPC 转给 CLI → CLI 中止本地进程、切到 remote,改用 Agent SDK 的 query() 驱动同一条会话(claudeRemote.ts:168)。
  6. 授权:SDK 每要用一个工具就回调一次;CLI 把请求写进 agentState.requestspermissionHandler.ts:264)并发推送,挂起等手机回 permission RPC(App 侧 ops.ts:753sessionAllow,CLI 侧 permissionHandler.ts:367),拿到结果才放行。
  7. 抢回:终端里按两下空格(或 Ctrl-T),remote 退出、local 重新接管终端。每次换手都会把 controlledByUser 写进加密的 agent state(runClaude.ts:937-943),手机界面据此显示「终端在用」还是「你在控制」。

第 3 到第 7 步的来回,就是 loop() 这个二态状态机(loop.ts:52mode: 'local' | 'remote':76):

happy claude 启动


┌────────────┐ 手机接管 / 队列里有消息 ┌────────────┐
│ local 本地 │ ──────────────────────────▶ │ remote 远程 │
│ 真 claude │ │ SDK 驱动 │
│ 占着终端 │ ◀────────────────────────── │ 终端只观战 │
└────────────┘ 终端按两下空格 / Ctrl-T └────────────┘

3. 阅读地图

六章按「先看懂信任模型 → 再看数据怎么流 → 再看两种运行态 → 最后看扩展面」排序。建议顺序即编号顺序;只关心某一点的话直接跳。

讲什么什么时候读
01-encryption-and-pairing.md端到端加密与配对握手:HMAC-SHA512 密钥树、legacy / dataKey 两代方案、二维码里到底放了什么、为什么 CLI 只配拿到公钥想搞清「端到端」到底端到哪儿,先读这章
02-sync-and-rpc.md加密同步流与不可读的 RPC 中继:seq 有序消息、socket 当门铃 / HTTP 当真相、expectedVersion 乐观并发、服务器用房间做路由想知道消息怎么不丢不乱、服务器到底知道什么
03-local-remote-handoff.md本地与远程双模态:扫转录文件旁听、按键夺权、loop() 状态机与两个 launcher 的退出约定项目最有辨识度的机制,核心章
04-remote-turn-and-permissions.md远程回合:query() 怎么被喂提示词、canUseTool 怎么被劫持成一个「等手机回话」的 Promise、消息怎么成型想抄「远程逐个批准工具」这套设计
05-daemon-and-machines.md守护进程与机器:machine 注册、spawn-happy-session RPC、tmux / 裸进程两种拉起方式、fork 与 rewind关心「人不在电脑前也能开工」那条链路
06-multi-agent-and-app-reducer.mdClaude / Codex / Gemini / ACP 多后端如何归一成一种信封,以及 App 端 reducer 的分阶段处理关心多供应商适配与客户端状态机

按任务选章:

  • 想审计安全性 → 01 + 02
  • 想弄清「为什么按一下键就能夺回终端」 → 03
  • 想知道手机上那张权限卡片背后发生了什么 → 04
  • 想接一个新的 agent 后端 → 06

4. 巧妙之处速览

五个不显然的设计决策。每条只说妙在哪,细节留给对应章节。

4.1 CLI 只拿公钥的「只写不读」凭据

妙在哪: 开发机上那份凭据即使被偷走,也解不开你其它会话的历史。

配对的 v2(dataKey)路径里,手机回传的是它 content 密钥对的公钥packages/happy-app/sources/hooks/useConnectTerminal.ts:33-37;那把 contentDataKey 就是 contentKeyPair.publicKey,见 sync/encryption/encryption.ts:50)。CLI 落盘时也只存这把公钥(ui/auth.ts:189-196persistence.ts:272writeCredentialsDataKey)。

于是 CLI 每开一个会话就随机造一把新密钥,再用公钥把它封起来交给服务器(api/api.ts:43:49)——能封,不能拆。私钥只在 App 一侧,由主密钥派生(encryption.ts:17)。

// 示意,非源码:为什么说这是「只写不读」
const sessionKey = randomBytes(32) // 这个会话专用的对称密钥
const sealed = sealForPublicKey(sessionKey, pubKey) // 用手机公钥封好,交给服务器
send({ metadata: encrypt(sessionKey, meta), dataEncryptionKey: sealed })
// 重点看:CLI 手上只有 pubKey,拆不开别人(也拆不开自己上一次)的 sealed

详见 01

4.2 本地模式靠扫转录文件,而不是拦截 stdio

妙在哪: 终端体验零损耗——不改字符、不吃快捷键、不破坏 TUI 渲染。

最直觉的做法是套一层 PTY 抓屏幕字符,或者把 claude 的 stdout 接管过来解析——脆,还会毁掉原生交互。Happy 反过来:stdio 全部 inherit,让真 claude 直接占着你的终端(claudeLocal.ts:312crossSpawn,四个槽位在 :316)。要同步内容,就去读 Claude Code 自己写的 .jsonl 转录文件——createSessionScannerutils/sessionScanner.ts:27)对每个会话起一个文件监听器(:127-131),新行解析后转发上行(runClaude.ts:218-222)。

唯一额外开的通道是一个 fd 3 管道,只用来收「正在请求外部接口」这种思考状态信号(claudeLocal.ts:326 起),不碰对话内容。

代价也很实在:scanner 要处理「转录文件迟迟不出现」的幽灵 id,所以有一份 deadSessions 黑名单防止监听器永久自我续命(sessionScanner.ts:49-55)。详见 03

4.3 RPC 路由用 Socket.IO 房间,而不是 Redis 键

妙在哪: 把「谁在线、能干什么」这份注册表的生命周期,直接甩给 Socket.IO 自己管。

CLI 每注册一个 RPC 方法,就发一个 rpc-registerapi/rpc/RpcHandlerManager.ts:46),服务器把这个 socket join 进 rpc:<userId>:<method> 房间rpcHandler.ts:66rpcRoom:137 的 join)。App 发起调用时(rpcHandler.ts:160socket.on('rpc-call')),服务器用 fetchRoomSockets 跨副本找人(rpcHandler.ts:183)。

结果是这段代码里没有 Redis 键、没有 TTL、没有 Lua 脚本、没有心跳续期路径——断线时 Socket.IO 自动把 socket 从所有房间摘掉,集群适配器同步给其它副本,所以连 disconnect 处理器都不需要(rpcHandler.ts:258-259)。多进程能力由 Redis streams adapter 提供(app/api/socket.ts:50-52)。

方法名带作用域前缀(<sessionId>:<method> / <machineId>:<method>,见 packages/happy-app/sources/sync/apiSocket.ts:152-161),参数则是密文——服务器知道该转给谁,不知道转的是什么。详见 02

4.4 agentState.requests 当作双端共享的待办清单

妙在哪:「有一个权限在等你批」这件事不靠一条易丢的实时消息,而是靠一份可重放的状态

AgentState 里有两个字典(packages/happy-cli/src/api/types.ts:396 起):

字段含义
requests还没决定的工具调用请求(按 id 索引)
completedRequests已批准 / 拒绝 / 取消的历史,带 statusmode

CLI 一收到 SDK 的工具授权询问,就往 requests 里塞一条(permissionHandler.ts:264);App 只要看到这个字典非空,就渲染出待批卡片、并把会话标成「需要你处理」(packages/happy-app/sources/sync/storage.ts:119)。手机答复走 permission RPC(ops.ts:753sessionAllow:759sessionDeny),CLI 把那一条从 requests 搬到 completedRequests

好处:App 冷启动、断网重连、换一台设备打开,都只需要重新拉一次状态就知道该批什么——不依赖「当时那条推送有没有送到」。会话中止时还能把 requests 整个清空并批量标记为 canceled(permissionHandler.ts:340-357)。详见 04

4.5 服务器挂了也不至于停工

妙在哪: 远程能力是增量,不是前置依赖。

getOrCreateSession 拿不到响应时,CLI 不是报错退出,而是照常在本地把 claude 跑起来,并起一个后台重连器;网络回来后再建会话、把消息接上(claude/runClaude.ts:178 的离线分支,:181 起后台重连;实现在 utils/serverConnectionErrors.ts:147startOfflineReconnection)。


5. 边界与局限(判断相关性用)

  • 服务器不是零信任的元数据保管者。 内容是密文,但会话数量、活跃时间、消息条数、机器数量这些元数据在明处(prisma/schema.prisma:94-115lastActiveAt / seq 等字段)。
  • 本地模式依赖 Claude Code 的转录文件格式。 上游改 .jsonl 布局,扫描路径会受影响(sessionScanner.ts:239readSessionEntries)。
  • 一个方法名同一时刻只该有一个提供方。 房间里出现多个 socket 时,服务器只取第一个并打一条 warn(rpcHandler.ts:193-196)。
  • 各 agent 后端能力不齐。 分叉、回溯点、重放这些能力按 Claude / Codex 分别实现(apiMachine.ts:200claude-fork-session:273codex-fork-thread),不是统一抽象下的免费午餐。
  • 线协议自己承认还没定型。 packages/happy-wire/src/sessionProtocol.ts 顶部写着 "UNDER REVIEW / frozen / do not add new consumers"——它是当前生产者与消费者之间的兼容契约,不是稳定的跨 agent 标准。

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

主题文件符号
CLI 子命令分发packages/happy-cli/src/index.ts顶层 mainsubcommand 分支链,:49
会话总装配packages/happy-cli/src/claude/runClaude.tsrunClaude · loop({...}) 调用点 · onModeChange
双模态状态机(→ 03packages/happy-cli/src/claude/loop.tsloop · LoopOptions
本地模式启动器packages/happy-cli/src/claude/claudeLocalLauncher.tsclaudeLocalLauncher · doSwitch · doAbort
真 CLI 进程 spawn 与标志改写packages/happy-cli/src/claude/claudeLocal.tsclaudeLocal · extractFlag · claudeCliPath · ExitCodeError
转录文件扫描与去重packages/happy-cli/src/claude/utils/sessionScanner.tscreateSessionScanner · readSessionEntries
转录目录映射packages/happy-cli/src/claude/utils/path.tsgetProjectPath
跨模式共享状态packages/happy-cli/src/claude/session.tsSession · onSessionFound · consumeOneTimeFlags
远程模式启动器(→ 04packages/happy-cli/src/claude/claudeRemoteLauncher.tsclaudeRemoteLauncher · onMessage
Agent SDK 驱动packages/happy-cli/src/claude/claudeRemote.tsclaudeRemote(内部 query
工具授权与待办清单packages/happy-cli/src/claude/utils/permissionHandler.tsPermissionHandler · handleToolCall · handlePermissionRequest · reset
加密原语(→ 01packages/happy-cli/src/api/encryption.tsencryptLegacy · encryptWithDataKey · libsodiumEncryptForPublicKey · encryptBlob · authChallenge
密钥派生树packages/happy-cli/src/utils/deriveKey.tsderiveKey · deriveSecretKeyTreeRoot · deriveSecretKeyTreeChild
配对握手(CLI 侧)packages/happy-cli/src/ui/auth.tsdoAuth · doMobileAuth · waitForAuthentication · decryptWithEphemeralKey
凭据落盘的两种形态packages/happy-cli/src/persistence.tsCredentials · writeCredentialsLegacy · writeCredentialsDataKey · acquireDaemonLock
会话创建 / 密钥封装packages/happy-cli/src/api/api.tsApiClient.getOrCreateSession · getOrCreateMachine
会话通道(→ 02packages/happy-cli/src/api/apiSession.tsApiSessionClient · enqueueMessage · fetchMessages · flushOutbox · updateMetadata · updateAgentState · getBlobKey
RPC 注册与作用域前缀packages/happy-cli/src/api/rpc/RpcHandlerManager.tsRpcHandlerManager · getPrefixedMethod · onSocketConnect
机器级 RPC(→ 05packages/happy-cli/src/api/apiMachine.tsApiMachineClient · setRPCHandlers · 'spawn-happy-session' · 'stop-session'
守护进程packages/happy-cli/src/daemon/run.tsstartDaemon · spawnSession · spawnTrackedHappyProcess · initialMachineMetadata
通用文件 / shell RPC 与路径边界packages/happy-cli/src/modules/common/registerCommonHandlers.ts · modules/common/pathSecurity.tsregisterCommonHandlers · validatePath
离线降级packages/happy-cli/src/utils/serverConnectionErrors.tsstartOfflineReconnection
服务器启动与 HTTP 路由packages/happy-server/sources/main.ts · app/api/api.tsmain · startApi
Socket.IO 装配与集群packages/happy-server/sources/app/api/socket.tsstartSocket · clientType 分支(session-scoped / machine-scoped / user-scoped
RPC 房间中继packages/happy-server/sources/app/api/socket/rpcHandler.tsrpcHandler · rpcRoom · waitForRoomMember · fetchRoomSockets
数据模型(全密文字段)packages/happy-server/prisma/schema.prismamodel Session · model SessionMessage
统一线协议(→ 06packages/happy-wire/src/sessionProtocol.tssessionEnvelopeSchema · sessionEventSchema · createEnvelope
Claude → 信封映射packages/happy-cli/src/claude/utils/sessionProtocolMapper.tsmapClaudeLogMessageToSessionEnvelopes · closeClaudeTurnWithStatus
其它 agent 后端packages/happy-cli/src/codex/runCodex.ts · agent/acp/runAcp.ts · agent/core/AgentBackend.tsrunCodex · runAcp · AgentBackend · AgentMessage
App 同步引擎packages/happy-app/sources/sync/sync.tsclass Sync · handleUpdate · fetchInitialLatestPage
App socket 与 RPC 发起packages/happy-app/sources/sync/apiSocket.tsApiSocket · sessionRPC · machineRPC
App 加密根与扫码配对packages/happy-app/sources/sync/encryption/encryption.ts · hooks/useConnectTerminal.tsEncryption.create · contentDataKey · openEncryption · processAuthUrl
App 操作集packages/happy-app/sources/sync/ops.tssessionSwitch · sessionAbort · sessionAllow · sessionDeny · machineSpawnNewSession · forkAndSpawn
App 消息归一packages/happy-app/sources/sync/reducer/reducer.tsreducer · createReducer · ReducerState
控制权归属packages/happy-app/sources/sync/controlHandoff.tsresolveControlMode · resolveControlHandoffDirection
语音控制会话packages/happy-app/sources/realtime/realtimeClientTools.tsrealtimeClientTools · sendMessageToSession · processPermissionRequest