跳到主要内容

数据截至 (上游 commit eb980a5c9eea)

守护进程与机器:从手机凭空开一个新会话

30 秒导读: 本章讲 Happy 里比「会话」更大的那层实体——机器,以及守在机器上的常驻进程 daemon。 读完你能回答:手机上并没有终端,为什么点一下就能在家里的 Mac 上开出一个全新的 Claude 会话?

前一章讲的都是「已经存在的会话」怎么被远程驱动(见 04-remote-turn-and-permissions.md)。 本章讲的是会话还不存在时谁来把它造出来。传输机制(加密 RPC 怎么经服务器中继)在 02-sync-and-rpc.md,这里只讲用这条管道做什么


1. 先弄清两个实体:会话 vs 机器

Happy 的服务器上有两类长期对象,它们的生命周期完全不同:

实体对应现实中的什么谁在本地代表它活多久
Session(会话)一次与 Claude / Codex 的对话一个 happy claude 进程从开一次对话到进程退出
Machine(机器)一台电脑一个常驻的 happy daemon 进程电脑开着就一直在

为什么必须有「机器」这一层? 因为会话是被造出来的东西,总得有人造。手机上没有你的文件系统、没有 claude 可执行文件、没有你的 API token;这些只在你那台电脑上。于是每台电脑常驻一个 daemon,它替这台电脑在服务器上登记为一个 Machine,然后挂一条长连接等着接活。

daemon 接的活大致三类:

  • 造会话:在某个目录下起一个新的 agent 会话(spawn-happy-session)。
  • 改会话的历史:复制/截断一份对话记录,做 fork 与 rewind(claude-fork-session 等)。
  • 当这台机器的手脚:跑 bash、读写文件、列目录、ripgrep、difftastic。

用起来什么样

手机端 App 上的动作,落到代码里就是一次 machine-scoped RPC:

// 示意,非源码:App 端「在这台机器上新建会话」按钮背后
await machineSpawnNewSession({
machineId: 'machine-uuid-123', // 选中的那台电脑
directory: '/Users/me/projects/foo',
agent: 'claude',
})
// → 返回 { type: 'success', sessionId: 'happy-session-...' }
// App 随后直接路由进这个新会话的聊天页

真实实现是 packages/happy-app/sources/sync/ops.ts:258machineSpawnNewSession),它把方法名拼成 ${machineId}:spawn-happy-session 发给服务器(packages/happy-app/sources/sync/apiSocket.ts:172machineRPC)。重点看这个前缀:会话 RPC 用 sessionId 作用域,机器 RPC 用 machineId 作用域,走的是同一条中继机制。


2. 顶层全景:一次「凭空开会话」的完整链路

先给一句话读图指引:从左到右是请求方向,最右边那个新进程是被造出来的产物,它造好后自己回头连服务器。

手机 App Happy 服务器 你的电脑
┌─────────┐ rpc-call ┌──────────┐ rpc-request ┌──────────────────┐
│ 选机器 │ ────────► │ 按前缀 │ ──────────► │ daemon 进程 │
│ 选目录 │ │ machineId │ │ (machine-scoped) │
└─────────┘ ◄──────── │ 路由 │ ◄────────── │ spawnSession() │
▲ result └──────────┘ result └────────┬─────────┘
│ ▲ │ spawn(tmux 或 detached)
│ │ ▼
│ │ ┌──────────────────┐
│ │ 新会话自己注册 │ happy claude 进程 │
│ └───────────────────│ (session-scoped) │
│ └────────┬─────────┘
│ 会话出现在列表里 │ HTTP 本机回调
└──────────────────────────────────────────── /session-started
(daemon 靠这一步才知道 sessionId)

部件职责一览:

部件干什么在哪个文件
ApiMachineClient机器这一侧的 socket 客户端:注册所有 machine RPC、同步 daemonState、20 秒心跳packages/happy-cli/src/api/apiMachine.ts:115
startDaemon()daemon 的一生:抢锁、认证、起控制服务器、连服务器、心跳、优雅退出packages/happy-cli/src/daemon/run.ts:79
spawnSession()真正把一个新会话进程拉起来packages/happy-cli/src/daemon/run.ts:278
本机控制服务器127.0.0.1 上的 HTTP,接收新会话上报、供 CLI 命令查询/停机packages/happy-cli/src/daemon/controlServer.ts:15
machineUpdateHandler服务器侧:机器心跳、metadata / daemonState 的乐观并发写packages/happy-server/sources/app/api/socket/machineUpdateHandler.ts:10
machinesRoutes服务器侧:机器实体的 REST(注册/查询/删除)packages/happy-server/sources/app/api/routes/machinesRoutes.ts:11

3. 机器实体:两份数据、两个版本号、一个在线灯

服务器上一台机器存的是两块互相独立的密文,各带各的版本号:

字段装什么变化频率谁写
metadata主机名、平台、CLI 版本、home 目录、装了哪些 agent CLI很少daemon(能力变化时)/ App(改名)
daemonStatestatuspid、本机 HTTP 端口、启动时间、关停原因频繁daemon

两者的 schema 见 packages/happy-cli/src/api/types.ts:130MachineMetadataSchema)与 :158DaemonStateSchema);初始 metadata 在 packages/happy-cli/src/daemon/run.ts:68initialMachineMetadata)拼好,里面塞了 detectCLIAvailability() 的探测结果——手机上「这台机器能不能起 Codex」的那个灰按钮,就来自这里

写冲突怎么办:带版本号的 CAS

daemon 和手机可能同时改同一台机器(手机改名 vs daemon 更新状态)。写入走的是乐观并发:客户端带上 expectedVersion,服务器用它当 SQL 的 where 条件做原子比较交换。

daemon: machine-update-state { expectedVersion: 3 }


服务器 updateMany(where: { daemonStateVersion: 3 }) ──► count=1 ─► success, version=4
│ └─► count=0 ─► version-mismatch + 当前值

客户端收到 mismatch → 采纳更高版本的密文 → 抛错 → backoff 重试

服务器侧的原子 CAS 在 machineUpdateHandler.ts:187-199updateManydaemonStateVersion: expectedVersion),客户端侧「采纳更新版本再重试」在 apiMachine.ts:417-423updateDaemonStateversion-mismatch 分支)。这套版本比较与会话那一侧的 metadataVersion / agentStateVersion 是同一套机制,详见 02-sync-and-rpc.md

一个容易忽略的细节: 只有写 daemonState 时服务器才顺手把机器标成 active: true 并刷新 lastActiveAtmachineUpdateHandler.ts:196-197),改 metadata 时明确不动这两个字段(:102 的注释)。也就是说「机器在线」这件事被绑在运行时状态上,而不是随便一次改名。

在线灯的三个来源

信号触发时机代码
socket 连上/断开立即广播 machine online / offline 的 ephemeral 事件packages/happy-server/sources/app/api/socket.ts:161-170:195-200
machine-alivedaemon 每 20 秒一次apiMachine.ts:546-548 发;machineUpdateHandler.ts:13
daemonState.status启动写 running、关停前写 shutting-downapiMachine.ts:451-457run.ts:997-1002

服务器对心跳做了时间戳消毒:未来时间被夹到「现在」,超过 10 分钟的陈旧心跳直接丢弃(machineUpdateHandler.ts:27-33)。


4. daemon 的一生

怎么被拉起来

用户几乎不用手动开 daemon。任何一次 happy claude 启动,认证之后立刻调 ensureDaemonRunning()packages/happy-cli/src/index.ts:779)。

happy claude

├─ 已有 daemon 且版本一致? ── 是 ─► 直接返回
│ └ 否 ─► spawn 'daemon start-sync'(detached, stdio ignore)
│ │
│ └─ 轮询 100ms,最多 5 秒,等它写状态文件 + HTTP 应答

继续启动本次会话

ensureDaemonRunning 全文只有 39 行(packages/happy-cli/src/daemon/ensureDaemonRunning.ts:9),关键是那段等就绪的轮询:不等的话,会话马上要发的 /session-started webhook 会打空,daemon 就永远不知道这个会话存在。

happy daemon start 也只是把自己 detached 地重新执行成 daemon start-syncindex.ts:535-561),真正的常驻逻辑在 start-sync 里跑 startDaemon()

启动序列

startDaemon() 的顺序是有讲究的(daemon/run.ts:79 起):

① 装信号处理器 + shutdown promise(SIGINT/SIGTERM/uncaught/unhandledRejection 全部收敛成一个 requestShutdown)
② 版本比对:跑着的 daemon 是不是当前安装的版本?
├ 是 → console.log('Daemon already running with matching version') 然后 exit(0)
└ 不是 → stopDaemon() 干掉老的,自己接班
③ acquireDaemonLock(5, 200):抢排他锁,抢不到就退出
④ startCaffeinate():macOS 上阻止睡眠
⑤ authAndSetupMachineIfNeeded():拿凭据 + 本地 machineId
⑥ 从磁盘恢复历史会话的加密材料(跨 daemon 重启的 resume 能力)
⑦ startDaemonControlServer():127.0.0.1 随机端口
⑧ getOrCreateMachine() → machineSyncClient() → setRPCHandlers() → connect()
⑨ 60 秒心跳循环
⑩ await shutdown promise

第 ②、③ 步值得单独说:

  • 版本检查读的是 daemon.state.json 里的 startedWithCliVersion,和本次 bundle 里编译进去的版本比(daemon/controlClient.ts:185isDaemonRunningCurrentlyInstalledHappyVersion)。注释里记着为什么不再读磁盘上的 package.json:那样会在 manifest 版本与 bundle 版本不一致时形成无限自重启循环(controlClient.ts:199-212,issue #1107)。
  • 锁文件的获取是连 PID 内容一起原子化的:先把 PID 写进私有临时文件,再 hard-link 到锁路径,link 遇到已存在会像 O_EXCL 一样失败(packages/happy-cli/src/persistence.ts:341-353)。这样任何「锁在但里面没有活 PID」的情况都能被无歧义判为陈旧锁。

心跳循环干四件事

每 60 秒(HAPPY_DAEMON_HEARTBEAT_INTERVAL 可调)跑一轮(run.ts:892):

动作怎么做行号
清理死会话对每个跟踪中的 PID 发信号 0,失败即从表里删run.ts:903-912
检测自身被升级比对 dist/index.mjs 的 mtime 与启动时快照run.ts:918-925
交接给新 daemon先释放锁与状态文件,再 spawn daemon start,然后 exit(0)run.ts:930-954
写心跳覆盖 daemon.state.jsonlastHeartbeatrun.ts:966-975

「先释放再 spawn」这个顺序是踩过坑的:如果先 spawn,新 daemon 会读到还在的状态文件、判定「已有同版本 daemon」然后自己退出,等老的也退出后就一个都不剩了(run.ts:934-937 的注释写得很直白)。

怎么停

三个入口最终汇到同一个 requestShutdown

入口来源标记代码
手机点「停止 daemon」happy-appapiMachine.ts:321stop-daemon RPC,延迟 100ms 再触发)
happy daemon stophappy-cliHTTP POST /stopcontrolServer.ts:197
Ctrl-C / killos-signalrun.ts:115-123

清理序列会先把 status: 'shutting-down' 和关停来源同步到服务器,睡 100ms 等它发出去,再依次关 socket、关 HTTP、删状态文件、停 caffeinate、放锁(run.ts:987-1015)。手机上看到的「正在关闭」就是这 100ms 换来的。

happy daemon stop 一侧则是「先礼后兵」:HTTP 优雅停 + 最多等 2 秒确认进程死亡,不行就 SIGKILLcontrolClient.ts:227-259)。

开机自启:目前其实没有

仓库里有一套 macOS LaunchDaemon 安装脚本(packages/happy-cli/src/daemon/install.ts:4 只允许 darwin 且要求 root,实际写 plist 的是 daemon/mac/install.ts 里的 install,label com.happy-cli.daemon)。但该文件顶部的注释明确写着这条路当前未被使用,理由是要 sudo、而用户反正每次都会敲 happy——所以实际的「自启」策略就是本节开头那个 ensureDaemonRunning()run.ts:154-157 还留了个 TODO:长期应该把启动/升级交给 launchd/systemd,而不是让 daemon 自己替换自己。


5. 凭空开一个新会话:spawnSession 全流程

这是本章的主线。spawn-happy-session 的 handler 只是解包参数(apiMachine.ts:150-172),干活的是 daemon/run.ts:278spawnSession

参数 { directory, agent, permissionMode, token, resumeClaudeSessionId, ... }

├─① 目录存在吗?
│ ├ 存在 ─────────────────────────────► 继续
│ └ 不存在
│ ├ approvedNewDirectoryCreation=false → 返回 requestToApproveDirectoryCreation
│ └ 允许 → mkdir -p,失败按 errno 给人话错误

├─② 组装子进程环境:token 落地 + 变量展开 + 未解析变量则 fail fast

├─③ 选起法:tmux 可用且指定了会话名 → tmux new-window;否则 detached spawn

├─④ 拿到 PID,登记进 pidToTrackedSession,注册 awaiter

└─⑤ 等新进程回调 /session-started(最多 15 秒)→ 拿到 happySessionId → 返回 success

① 目录校验与「要不要替你建目录」

手机上输一个还不存在的路径是很常见的。daemon 不会擅自建:approvedNewDirectoryCreation 为 false 时它返回一个需要用户确认的结果类型,App 弹确认框后再带着 approvedNewDirectoryCreation: true 重发(run.ts:291-297;结果类型定义在 packages/happy-cli/src/modules/common/registerCommonHandlers.ts:155-158)。

建目录失败时按 errno 翻译成人能懂的话——EACCES/ENOTDIR/ENOSPC/EROFS 各有各的提示(run.ts:303-324)。手机用户看不到终端报错,错误信息就是全部的可观测性,这段翻译不是装饰。

② 环境变量:消毒、展开、宁可失败

三件事按顺序发生:

  1. 认证 token 落地。 Codex 要的是文件,于是新建一个临时目录写 auth.json 并设 CODEX_HOME;Claude 直接给 CLAUDE_CODE_OAUTH_TOKENrun.ts:333-348)。
  2. ${VAR} 展开 + fail fast。 用户在 App 里配的 ANTHROPIC_AUTH_TOKEN="${Z_AI_AUTH_TOKEN}" 会用 daemon 自己的环境展开;展开后仍有未解析的 ${...}直接报错而不是把字面量传下去run.ts:378-410)。
  3. 会话级变量消毒。 daemon 有可能是从某个会话里被拉起来的,身上带着上一个会话的 HAPPY_RECONNECT_* / HAPPY_FORK_*。这些 key 被集中列在 packages/happy-cli/src/daemon/sessionEnvironment.ts:6SESSION_SCOPED_ENV_KEYS),sanitizeSessionEnvironment:27)在 daemon 启动时先洗一遍环境(run.ts:83),buildSessionChildEnvironment:40)在每次 spawn 时再确保「干净的祖传环境 + 本次显式变量」。

tmux 那条路还多一层:tmux window 会继承 tmux server 的环境,-e 没覆盖到的 key 照样漏进去。于是命令被包了一层 unset A B C; <真正的命令>sessionEnvironment.ts:59wrapTmuxCommandWithSessionEnvironmentSanitizer)。这是个很容易漏掉的污染路径

③ 两种起法

tmux 路径detached 路径
触发条件tmux 可用 环境里给了 TMUX_SESSION_NAME(空串合法,表示「当前/最近的 session」)其余情况,或 tmux 起失败后回退
怎么起new-window -n happy-<ts>-<agent> -e KEY="V" ... <command>spawnHappyCLI(args, { detached: true, stdio: 'ignore' })
PID 从哪来-P -F '#{pane_pid}' 让 tmux 直接打印 pane 的 PIDchildProcess.pid
用户能看到吗能,tmux attach -t <name> 就贴上去了看不到,纯后台
代码run.ts:429-528packages/happy-cli/src/utils/tmux.ts:745spawnInTmuxrun.ts:601spawnTrackedHappyProcess

为什么优先 tmux? 因为这样手机开出来的会话,人坐回电脑前能直接接管——这正是 03-local-remote-handoff.md 讲的双模态在「机器」这一层的延伸。拿 PID 用 -P -F '#{pane_pid}' 是关键一招:没有它就没法把 tmux 里的会话纳入同一套 PID 跟踪(tmux.ts:830-846)。

不管走哪条路,起出来的命令行都长成 <agent> --happy-starting-mode remote --started-by daemon [...]run.ts:446-450:559-563)——新会话一出生就是远程模式

④⑤ 为什么要等一个 HTTP webhook

daemon spawn 出去的是一个进程,它只知道 PID;但手机要的是 sessionId。Happy session id 是新进程自己去服务器创建的,daemon 无从得知——除非新进程回头告诉它。

于是有了这条本机回环:新进程创建完会话,POST http://127.0.0.1:<port>/session-started,带上 sessionId、metadata(含 hostPid)和加密材料(daemon/controlClient.ts:62notifyDaemonSessionStarted;服务端 controlServer.ts:39)。daemon 用 hostPid 把它和自己 spawn 出的那条记录对上,然后唤醒挂在 PID 上的 awaiter,RPC 这才返回成功(run.ts:221-275onHappySessionWebhookrun.ts:657-675 的 awaiter;超时 15 秒,两条路径共用)。

spawnSession ──spawn──► 新进程 ──创建会话──► 服务器
│ │
│ └── POST /session-started {sessionId, hostPid}
│ │
└──── awaiter(pid) ◄───────────┘ → resolve({ type:'success', sessionId })

webhook 一侧还做了 3 秒的重试(controlClient.ts:78-90),专门对付「daemon 正在自升级重启」这个窗口——丢了这条 webhook,后面手机端的 resume-happy-session 就会以「not tracked by this daemon」失败。

附带能力:停会话与恢复会话

  • stop-session 按 sessionId 查表,daemon 起的用 childProcess.kill('SIGTERM'),外部起的用 process.kill(pid, 'SIGTERM'),然后从表里摘掉(run.ts:769-802)。注意它只发 SIGTERM,不做 SIGKILL 兜底
  • 恢复:进程退出时如果会话有加密材料,记录被移进 sessionIdToFinishedSession 而不是丢弃(run.ts:805-814);这些材料还被写进磁盘,daemon 重启后从 readPersistedSessions() 预加载(run.ts:193-209)。resumeSession 靠它们用 HAPPY_RECONNECT_* 变量把新进程重新贴回原来那条会话(run.ts:702-766)。这个 RPC 是按能力动态注册的:daemon 没提供 handler 时就把 resume-happy-session 从注册表里摘掉(apiMachine.ts:334-368),手机端据此灰掉按钮。

6. Fork 与 rewind:改写磁盘上的转录文件

要解决的小问题

「从第 3 条消息重开一条分支」——手机上一个动作,本质是要一份截到第 3 条为止的对话历史,再让 agent 从那儿接着聊。

思路

Claude Code 把每次会话完整写在一个 JSONL 转录文件里,路径由工作目录派生:~/.claude/projects/<把路径里非字母数字全换成横杠>/<uuid>.jsonlpackages/happy-cli/src/claude/utils/path.ts:4getProjectPath;根目录可被 CLAUDE_CONFIG_DIR 覆盖,见 path.ts:6)。

既然历史就是一个文件,fork 就是复制文件,rewind 就是复制并在某一行截断,然后用新 uuid 跑 claude --resume。不需要服务器参与,也不需要模型帮忙。

源转录文件 目标转录文件(新 uuid)
┌──────────────┐ copyFile ┌──────────────┐
│ user #1 │ ───────────────► │ 完整副本 │ fork:一字不差
│ assistant │ └──────────────┘
│ user #2 ◄──┼── cutAfterUuid ┌──────────────┐
│ assistant │ ───────────────► │ 到 #2 那一轮 │ duplicate:截断
│ tool_result │ │ 结束为止 │
│ user #3 │ ← 从这里丢掉 └──────────────┘
└──────────────┘

真实实现

三个函数在 packages/happy-cli/src/claude/utils/claudeSessionFork.ts

函数干什么行号
forkSession单次 copyFile 成新 uuid;FS 层面原子,源文件正被写也不会撕裂:65
forkAndTruncateSession逐行流式复制,写到临时文件后 rename;找不到 marker 就删临时文件并抛错:100
listClaudeRewindPoints从磁盘读出所有「用户真正打过的字」当作可选的回溯点:180

截断语义有个不显然的选择:保留 marker 那一行以及它之后的全部内容,直到遇见下一条用户 prompt 才停:134-136)。也就是说被选中的那一轮是完整保留的——用户消息、模型回复、以及中间所有 tool_use / tool_result 循环——分支从一个完整回合的末尾开始,而不是断在半个工具调用上。

判断「什么算一条用户 prompt」的 isUserPrompt:52)有两个排除条件,都很关键:isSidechain 的条目不算(那是子 agent 的对话),message.content 不是字符串的不算(那是 tool_result 回填,类型同样是 user)。

marker 找不到时硬失败而不是退化成完整复制(:162-165ForkTruncateUuidNotFoundError)——注释里写明理由:悄悄返回未截断的副本等于对调用方撒谎,比失败更糟。

为什么回溯点要从磁盘读

listClaudeRewindPoints 的文件头注释说明了:服务器侧的会话日志里,在 App 里直接打字发出的消息(历史上的 sentFrom: 'web' 路径)不带 claudeUuid,而截断需要的正是这个 uuid。磁盘上的转录文件才是全集(:172-179)。

两步编排:fork 完还得 spawn

fork RPC 只产出一个新的 Claude uuid,它还不是一个 Happy 会话。App 端的 forkAndSpawn 把两步串起来(packages/happy-app/sources/sync/ops.ts:1083):

① claude-fork-session / claude-duplicate-session → newClaudeSessionId
② spawn-happy-session { resumeClaudeSessionId: newClaudeSessionId,
parentSessionId, forkedFromMessageId }
③ sync.refreshSessions() ← 先把新会话行拉进本地,再跳转,避免「Session not found」

daemon 收到带 resumeClaudeSessionId 的 spawn 后做两件事:给 agent 命令行加 --resume <id>run.ts:568-570),并把它同时塞进环境变量 HAPPY_FORK_CLAUDE_SESSION_IDrun.ts:367-369)。

为什么要塞环境变量?命令行不够吗? 不够。SDK 带 resume: 读那份转录文件时是静默的——它把历史当上下文用,但绝不会把历史消息重新 emit 出来。结果就是模型记得一切、而 App 里是一片空白。所以新进程启动时要主动把转录文件回灌一遍(packages/happy-cli/src/claude/runClaude.ts:308-338,注释块 FORK BACKFILL)。

顺带说两个衍生功能,都是同一套机制换个开关:

  • lineageparentSessionId / forkedFromMessageId 经环境变量进入新会话的 metadata,父子关系不需要服务器改 schema(run.ts:354-359)。
  • side chatisSideChat 让新会话不出现在顶层列表里,只在父会话的侧栏渲染;而且它故意不回灌历史——模型有完整上下文,但用户界面从空白开始(ops.ts:1179spawnSideChatrunClaude.ts:313-316 的跳过分支)。

Codex 一侧是同构的另一套(codex-fork-thread / codex-duplicate-threadapiMachine.ts:273-318),差别在于它不是操作转录文件,而是通过 Codex 的 app-server 客户端做,细节见 06-multi-agent-and-app-reducer.md


7. 把手机变成这台机器的手脚

daemon 在构造函数里就注册了一整组通用 RPC(apiMachine.ts:138):

方法干什么行号(registerCommonHandlers.ts
bash跑 shell 命令,默认 30 秒超时:176
readFile读文件,base64 返回:262
writeFile写文件,带 sha256 前置校验:282
listDirectory列目录,目录在前、再按名字排:348
getDirectoryTree递归树,限深度,跳过符号链接:407
ripgrep全文搜索原始接口:493
difftastic结构化 diff 原始接口:523

writeFile 的并发保护值得学:传 expectedHash 就必须和现有文件的 sha256 对得上,传 null 则要求文件不存在——两种前提都会在不匹配时拒写(:292-331)。这让手机端的编辑不会悄悄覆盖掉电脑上刚发生的改动。

同一组 handler,两种边界

同一个 registerCommonHandlers 被注册两次,差别只在第二个参数:

作用域传入的 workingDirectory效果注册点
会话会话的 metadata.path每个路径过 validatePath,越界拒绝packages/happy-cli/src/api/apiSession.ts:252
机器(daemon)nullcheckPath 直接放行,只做 resolve()apiMachine.ts:138

checkPath 的分叉就一行(registerCommonHandlers.ts:170-173),越界防护本体在 packages/happy-cli/src/modules/common/pathSecurity.ts:15validatePath):把 target 相对 workingDirectory 解析成绝对路径,再要求它以 workingDirectory + sep 开头(或恰好等于),从而挡住 ../ 穿越。

machine-scoped 传 null 是有意为之,不是漏写。 源码注释解释得很清楚:daemon 服务的是整台机器,它的 process.cwd() 只是「碰巧从哪儿启动的」,拿它当安全边界既没意义也会误伤。安全边界因此完全落在这层之外——即拿到账号的人本来就等于拿到这台机器(详见第 9 节的局限)。

App 目前对这组机器级 RPC 的用法:machineBash 被用来做 git worktree 的探测与创建(packages/happy-app/sources/utils/worktree.ts:47 等);而文件浏览器界面走的是会话作用域的同名 RPC(ops.ts:901-938)。也就是说「远程文件浏览器」这条能力在两个作用域上都具备,App 现在主要用会话那一侧。


8. 巧妙之处(可以偷走的技术)

① 用一次 HTTP 回环解决「spawn 出来的进程叫什么」。 父进程只有 PID,子进程只有 sessionId,两边靠 hostPid 在一次本机 POST 里对上账,然后 awaiter 把异步 spawn 变回一次同步的 RPC 返回(run.ts:221run.ts:657)。比让 daemon 去猜、去轮询服务器都干净。

② fork 一段对话 = 复制一个文件。 不调模型、不做摘要、不改服务器 schema;rewind 就是流式复制到某一行为止(claudeSessionFork.ts:100)。选中的回合完整保留、下一条用户 prompt 之前停手,这个边界定义让分支永远从一个干净的回合起点开始。

③ 锁文件连内容一起原子化。 写临时文件 → hard-link 就位,link 的 EEXIST 语义等价于 O_EXCL,因此不存在「锁已建但 PID 还没写进去」的中间态,陈旧锁可以被无歧义回收(persistence.ts:341-353)。

④ 升级交接时先弃权再叫接班人。 先删状态文件、放锁,再 spawn 新 daemon,最后自己退出——顺序反了就会两个都退光(run.ts:930-954)。

⑤ 把「会话级环境变量」当污染源来治理。 一张显式清单 + 启动时消毒 + spawn 时重建 + tmux 里额外 unset,四道口子堵住「上一个会话的身份泄漏给下一个会话」(sessionEnvironment.ts:6-66)。

⑥ 能力探测走心跳、按能力增删 RPC 注册。 每 20 秒重探一次装了哪些 agent CLI,变了才推 metadata(apiMachine.ts:520-540);resume 能力没有就把方法从注册表摘掉(apiMachine.ts:365-367)。手机端的按钮是否可点,直接来自这两处。


9. 边界与局限(诚实版)

  • 本机控制服务器没有鉴权。 它绑 127.0.0.1、端口随机,但请求本身不带任何签名或 token(controlServer.ts:15-217 全文没有 auth 钩子);同机的任何进程只要知道端口就能 POST /spawn-session/stop。仓库自己的笔记也把「端口未受保护」列为待办。
  • 机器级文件 / bash RPC 没有路径边界。 见第 7 节:null 作用域下 readFilebash 覆盖整台机器。前提是这条通道端到端加密且只有你的账号能发(见 01-encryption-and-pairing.md),但「账号失守 = 整机失守」这个结论是成立的。
  • 停会话只发 SIGTERM。 没有等待、没有 SIGKILL 兜底(run.ts:777-792);卡死的 agent 进程要靠 happy doctor clean 收拾。
  • 自升级是手搓的。dist/index.mjs 的 mtime 判断被 npm 覆盖,靠自己 spawn 自己完成交接。源码里两处 TODO 都指向同一个方向:应该交给 launchd / systemd(run.ts:154-157:927-929)。
  • 开机自启在实现上等于「没有」。 LaunchDaemon 脚本存在但注释标注未启用(daemon/mac/install.ts 头部),且 install() 只支持 macOS 且要 sudo(daemon/install.ts:5-11)。冷启动后第一个 daemon 由「用户敲一次 happy」拉起。
  • spawn 的 15 秒 webhook 超时是硬编码的run.ts:512:665)。冷启动特别慢的机器上,会话其实起来了但 RPC 已经报超时。
  • stopSession 只能停自己表里有的会话;daemon 重启前就存在的、且没有重新上报过的会话查不到(run.ts:800-801)。
  • fork 只处理 Claude 的转录文件。 Codex 走完全不同的 app-server 路径,Gemini / openclaw / agy 这几个 agent 目前没有对应的 fork RPC(apiMachine.ts 里只有 claude-* 与 codex-* 两组)。

10. 代码地图

主题文件路径符号
机器侧 socket 客户端 / 全部 machine RPCpackages/happy-cli/src/api/apiMachine.tsApiMachineClientsetRPCHandlersupdateDaemonStatesendKeepAlive
resume RPC 的按能力增删packages/happy-cli/src/api/apiMachine.tssyncResumeSessionRpcRegistration
daemon 生命周期packages/happy-cli/src/daemon/run.tsstartDaemoncleanupAndShutdowninitialMachineMetadata
造会话 / 跟踪 / 停止 / 恢复packages/happy-cli/src/daemon/run.tsspawnSessionspawnTrackedHappyProcessonHappySessionWebhookstopSessionresumeSessiononChildExited
本机 HTTP 控制面(服务端)packages/happy-cli/src/daemon/controlServer.tsstartDaemonControlServer
本机 HTTP 控制面(客户端)packages/happy-cli/src/daemon/controlClient.tsdaemonPostnotifyDaemonSessionStartedcheckIfDaemonRunningAndCleanupStaleStateisDaemonRunningCurrentlyInstalledHappyVersionstopDaemon
会话启动时确保 daemon 在packages/happy-cli/src/daemon/ensureDaemonRunning.tsensureDaemonRunning
会话级环境变量消毒packages/happy-cli/src/daemon/sessionEnvironment.tsSESSION_SCOPED_ENV_KEYSsanitizeSessionEnvironmentbuildSessionChildEnvironmentwrapTmuxCommandWithSessionEnvironmentSanitizer
开机自启(未启用)packages/happy-cli/src/daemon/install.tsdaemon/mac/install.tsinstall
锁 / 状态 / 会话持久化packages/happy-cli/src/persistence.tsacquireDaemonLockwriteDaemonStatereadDaemonStatepersistSessionreadPersistedSessions
tmux 起窗与取 PIDpackages/happy-cli/src/utils/tmux.tsspawnInTmuxisTmuxAvailable
Claude 转录文件的 fork / 截断 / 回溯点packages/happy-cli/src/claude/utils/claudeSessionFork.tsforkSessionforkAndTruncateSessionlistClaudeRewindPointsisUserPromptForkTruncateUuidNotFoundError
转录文件路径推导packages/happy-cli/src/claude/utils/path.tsgetProjectPath
fork 后把历史回灌进新会话packages/happy-cli/src/claude/runClaude.tsHAPPY_FORK_CLAUDE_SESSION_ID 分支(FORK BACKFILL
通用文件 / shell RPCpackages/happy-cli/src/modules/common/registerCommonHandlers.tsregisterCommonHandlersSpawnSessionOptionsSpawnSessionResult
路径越界防护packages/happy-cli/src/modules/common/pathSecurity.tsvalidatePath
服务器:机器实体 RESTpackages/happy-server/sources/app/api/routes/machinesRoutes.tsmachinesRoutes
服务器:心跳与乐观并发写packages/happy-server/sources/app/api/socket/machineUpdateHandler.tsmachineUpdateHandler
服务器:machine-scoped 连接与在线广播packages/happy-server/sources/app/api/socket.tssocket 中间件 / connection 分支
App:机器 RPC 与 fork 编排packages/happy-app/sources/sync/ops.tsmachineSpawnNewSessionforkAndSpawnspawnSideChatmachineBashmachineStopDaemon
App:RPC 方法名作用域前缀packages/happy-app/sources/sync/apiSocket.tsmachineRPC

继续读: 02-sync-and-rpc.md(machine-scoped 连接与加密 RPC 怎么中继)· 03-local-remote-handoff.md(daemon 起出来的远程会话怎么被人在终端接管)· 06-multi-agent-and-app-reducer.md(Codex 一侧的 fork 走的是哪条路)· 返回索引