资讯动态

Happy CLI Daemon 全解:控制流、生命周期与机器同步架构

发布时间:2026/9/20 12:57:48 来源:尧图企业网站定制
Happy CLI Daemon 全解控制流、生命周期与机器同步架构【免费下载链接】happyMobile and Web client for Codex and Claude Code, with realtime voice, encryption and fully featured项目地址: https://gitcode.com/gh_mirrors/happy20/happy导读Happy CLI 的 daemon 是一个常驻后台进程负责管理 Happy 会话、接受来自移动端的远程控制并在 CLI 版本升级时自动完成自身热替换。本文以仓库内 daemon 设计文档 为骨架结合run.ts、controlClient.ts、controlServer.ts、apiMachine.ts等源码实现系统讲解 daemon 的完整生命周期、会话管理、本地 HTTP 控制协议、进程发现与清理、状态持久化以及机器元数据 守护状态分离的同步架构。读完本文你将掌握happy daemon各子命令的实际行为、升级自愈机制的工作原理以及移动端通过 WebSocket RPC 远程操作本机 daemon 的完整链路。1. Daemon 生命周期1.1 启动happy daemon starthappy daemon start的完整控制流如下src/index.ts 接收daemon start命令通过spawnHappyCLI([daemon, start-sync], { detached: true })派生出分离detached进程新进程调用 run.ts 中的startDaemon()startDaemon()完成一系列启动动作详见下文进程挂起等待 shutdown promise 解析。spawnHappyCLI的实现位于 spawnHappyCLI.ts它刻意绕过了bin/happy.mjs包装脚本直接以node --no-warnings --no-deprecation dist/index.mjs ...方式启动入口文件从而避免 Windows 下 shebang 解析失败EFTYPE错误与 CVE-2024-27980 之后spawn(node)不带.exe导致的ENOENT问题issue #1082。startDaemon()的启动序列对应 run.ts设置 shutdown 承诺与信号处理器SIGINT、SIGTERM、uncaughtException、unhandledRejection都会以对应的sourceos-signal/exception解析 shutdown 承诺同时设置一个 1 秒的兜底定时器若启动流程异常进程会强制以退出码 1 退出避免留下半死守护进程。版本检查调用isDaemonRunningCurrentlyInstalledHappyVersion()读取daemon.state.json中的startedWithCliVersion与configuration.currentCliVersion比较。版本不匹配则先stopDaemon()杀掉旧 daemon版本一致则打印 Daemon already running with matching version 并exit(0)。锁获取acquireDaemonLock(5, 200)创建独占锁文件防止多个 daemon 实例并存获取失败说明已有 daemon 在运行进程以退出码 1 退出。认证与机器注册authAndSetupMachineIfNeeded()确保凭据存在之后ApiClient.create(credentials)与api.getOrCreateMachine(...)完成机器注册在happy daemon启动后、建立 WebSocket 之前执行。状态持久化将 PID、HTTP 端口、启动时间、CLI 版本、日志路径写入daemon.state.json。本地 HTTP 控制服务器startDaemonControlServer()在127.0.0.1随机端口启动 Fastify 服务供本地 CLI 进行list、stop、spawn等控制操作。WebSocket 连接通过ApiMachineClientapi.machineSyncClient(machine)与后端建立持久连接。RPC 注册暴露spawn-happy-session、resume-happy-session、stop-session、stop-daemon、requestShutdown等处理器。心跳循环默认每 60 秒HAPPY_DAEMON_HEARTBEAT_INTERVAL可覆盖执行一次清理已死亡的会话、检测 CLI 是否被升级、写心跳时间戳。收尾await resolvesWhenShutdownRequested等待任一关闭信号触发后进入cleanupAndShutdown()。需要区分两个心跳本地心跳循环60 秒写daemon.state.json的lastHeartbeat、清理会话、检测升级与WebSocket keep-alive20 秒向服务器发送machine-alive见 apiMachine.ts。两者职责不同前者管本地生命周期后者管云端在线状态。1.2 版本不匹配自动更新npm 升级自愈原设计文档描述的是心跳读取磁盘上的 package.json 并与编译进 bundle 的版本比对但当前源码已经做了演进见 run.ts 与 controlClient.ts 中的注释心跳检测dist/index.mjs的mtime是否发生变化——npm i -g happy会用新 bundle 重写该文件mtime 变化即代表真实升级早期实现是比对磁盘package.json.version与编译版本但会因 manifest 与 bundle 版本发散产生无限重启循环issue #1107如happy-coder0.13.1弃用桩只改了 package.json 未重建 dist。改用 bundle mtime 后只有 bundle 被真实替换才触发自愈检测到升级后daemon先释放所有权关闭 WebSocket、停止控制服务器、删除状态文件、释放锁、停止 caffeinate再spawnHappyCLI([daemon, start])拉起新 daemon最后自身exit(0)——顺序至关重要若先退出再拉起新 daemon 读到残留的daemon.state.json会误判同版本已在运行而退出导致无人接管新 daemon 启动时发现状态文件中的版本 ! 自身编译版本于是调用stopDaemon()——优先 HTTP/stop优雅关闭失败则回退SIGKILL——随后接管。在开发模式通过tsx运行下没有dist/index.mjs该升级检测自动禁用。1.3 停止happy daemon stopstopDaemon()位于 controlClient.ts读取daemon.state.json获取 PID 与 HTTP 端口向http://127.0.0.1:port/stop发送 POST尝试优雅关闭daemon 收到请求后延迟 50ms 触发requestShutdown确保响应先返回随后执行cleanupAndShutdown()通过 WebSocket 将后端机器状态更新为shutting-down附带shutdownRequestedAt与shutdownSource关闭 WebSocket、停止 HTTP 服务器、删除daemon.state.json、释放锁文件、停止 caffeinateprocess.exit(0)若 HTTP 关闭失败waitForProcessDeath在 2 秒内未等到进程死亡回退process.kill(pid, SIGKILL)强制终止。注意happy daemon stop的说明文案明确标注 sessions stay alive——停止 daemon 不会终止它已经派生的会话进程。2. 会话管理2.1 daemon 派生会话远程启动移动端通过后端发起远程启动的完整链路对应 apiMachine.ts 与 run.ts移动端 → 后端发出 RPC后端经 WebSocket 向 daemon 转发spawn-happy-session方法名以machineId:为前缀进行 scope 隔离RpcHandlerManager解密参数后调用spawnSession()spawnSession()依次执行目录处理目录不存在时按approvedNewDirectoryCreation决定直接创建返回requestToApproveDirectoryCreation类型还是报错mkdir失败时按错误码EACCES/ENOTDIR/ENOSPC/EROFS给出可读提示认证环境codex代理会把 token 写入临时目录的auth.json并设置CODEX_HOMEclaude代理则设置CLAUDE_CODE_OAUTH_TOKEN额外环境变量HAPPY_FORKED_FROM_SESSION_ID、HAPPY_SIDE_CHAT、HAPPY_FORK_CLAUDE_SESSION_ID、HAPPY_FORK_CODEX_THREAD_ID等按需注入所有值经expandEnvironmentVariables做${VAR}展开残留未解析引用会 fail-fast 报错tmux 优先策略若 tmux 可用且TMUX_SESSION_NAME已定义则在指定 tmux 会话中创建happy-timestamp-agent窗口通过tmux -P拿到真实 PID 后再跟踪tmux 不可用或失败则回退普通进程派生普通派生以--happy-starting-mode remote --started-by daemon启动分离的 Happy 子进程加入pidToTrackedSession映射webhook 等待器为该 PID 注册 1015 秒的 awaiter等待子进程通过/session-started上报自身新 Happy 进程与后端创建会话拿到happySessionId随后调用notifyDaemonSessionStarted()POST 到 daemon 的/session-starteddaemon 收到 webhook 后用happySessionId更新跟踪记录、解析 awaiterRPC 向移动端返回{ type: success, sessionId }。2.2 终端派生会话本地直接运行用户在终端直接运行happy时CLI 按配置自动启动 daemonensureDaemonRunning()见 ensureDaemonRunning.tsHappy 进程调用notifyDaemonSessionStarted()带 3 秒重试窗口防止与 daemon 升级/重启竞态而丢失加密数据daemon 的 webhook 处理器发现该 PID 未被跟踪创建TrackedSession并以startedBy: happy directly - likely by user from terminal标记对应 run.ts该会话进入健康监控列表。2.3 会话终止通过 RPCstop-session或本地 HTTP/stop-session触发stopSession()按happySessionId或PID-pid前缀查找会话对 daemon 派生的会话信号整个进程组而非仅父进程因为 daemon 以detached: true派生父进程是进程组组长process.kill(-pid, SIGTERM)能覆盖所有后代包括 Codex 自行拉起的codex app-server孙进程单纯杀父进程会导致 agent 残留、被 reparent 后不可见Windows 无进程组概念回退到childProcess.kill(SIGTERM)对外部派生会话按 PID 发SIGTERMon(exit)处理器调用onChildExited()将会话从跟踪映射移除若会话持有加密数据则转移到sessionIdToFinishedSession保留映射并调用markSessionStopped()——这样会话数据在进程退出后仍可供 resume 使用。2.4 会话恢复resumeresumeSession()实现了单飞single-flight守卫issue #1715同一会话的并发 resume 请求复用同一个 in-flight promise。resumeConflict()会检测会话是否已在运行返回成功、是否正在停止/正在启动返回错误提示、以及磁盘上是否存在前一个 daemon 遗留的存活 PIDPID 复用保护重启后 PID 空间重置savedAt早于本次开机时间的记录一律视为不在运行。恢复时若本地元数据缺少claudeSessionId/codexThreadId会尽力从服务器端拉取补全fetchServerSessionMetadata只读服务器最近 150 个会话。3. 本地 HTTP 控制服务器controlServer.ts 使用 Fastify Zod 类型提供器构建仅监听127.0.0.1端口随机port: 0。所有端点均为 POST端点清单端点请求体Zod schema作用/status无返回机器 ID、CLI 版本、服务器地址、WebSocket 连接状态/session-startedsessionId、metadata、可选encryption会话启动后自我上报 webhook携带加密数据key、variant、seq、版本号/list无返回被跟踪会话的startedBy、happySessionId、pid列表/stop-sessionsessionId终止指定会话返回{ success }/spawn-sessiondirectory、可选sessionId/agent/permissionMode/modelMode/effortLevel/environmentVariables派生新会话目录需审批时返回 409 requiresUserApproval失败返回 500/stop无延迟 50ms 触发 daemon 优雅关闭先返回{ status: stopping }客户端封装在 controlClient.ts 的daemonPost()中先读daemon.state.json拿端口并用process.kill(pid, 0)探测 PID 存活防 stale 文件请求默认 10 秒超时HAPPY_DAEMON_HTTP_TIMEOUT可调。checkIfDaemonRunningAndCleanupStaleState()还处理了 Windows PID 复用问题PID 存活还不够必须 HTTP ping/list成功才算真的是我们的 daemon否则清理状态文件。4. 进程发现与清理doctor4.1happy doctordoctor.ts 的findAllHappyProcesses()基于ps-list扫描全系统进程识别规则生产环境命令包含happy.mjs、happy或遗留包名happy-coder、dist/index.mjs开发环境命令包含tsx且路径含src/index.ts与happy-cli分类见 ui/doctor.tsdaemon/dev-daemon命令含daemon start-sync或daemon startdaemon-spawned-session/dev-daemon-spawned命令含--started-by daemondaemon-version-check命令含--version通常是卡死的版本探测进程user-session其他用户会话、doctor、current等。4.2happy doctor cleankillRunawayHappyProcesses()对疑似孤儿进程daemon、daemon 派生会话、版本探测进程等执行发送SIGTERM等待 1 秒若进程仍存活则发送SIGKILLWindows 用taskkill /F /PID。集成测试的stopAllTrackedSessions()也复用了/list/stop-session的组合来清理测试环境。5. 状态持久化5.1 daemon.state.json本地状态文件路径为~/.happy/daemon.state.json见 configuration.ts由DaemonLocallyPersistedState定义persistence.ts{ pid: 12345, httpPort: 50097, startTime: 8/24/2025, 6:46:22 PM, startedWithCliVersion: 0.9.0-6, lastHeartbeat: 8/24/2025, 6:47:22 PM, daemonLogPath: /path/to/daemon.log }startedWithCliVersion是版本一致性判断的依据写入方与读取方都取编译进dist/的package.json.version见 controlClient.ts 的演进说明。5.2 锁文件daemon.state.json.lock的实现细节persistence.ts原子获取先把 PID 写入临时文件再通过linkSync硬链接到锁路径——link与O_EXCL一样会在文件已存在时以EEXIST失败因此锁文件永远不会以空/半写状态存在内容为 PID锁内 PID 无效或对应进程已死亡即视为 stale可立即回收生命周期daemon 持有打开的文件句柄直至优雅关闭releaseDaemonLock()关闭句柄并删除文件。5.3 会话持久化重启后仍可 resume~/.happy/sessions.jsonPersistedSession保存每个会话的加密数据与元数据使会话在 daemon 重启后仍可被恢复每次 webhook 上报或派生时persistSession()写入先写临时文件再renameSync原子替换读取时做双重过滤仍在运行的会话绝不丢弃否则会丢失唯一一份加密 key导致活跃会话永久不可达已停止的会话按lastAliveAt保留14 天SESSION_MAX_AGE_MS 14 * 24 * 60 * 60 * 1000isSessionProcessRunning()用记录写入时间是否晚于本次开机时间来判断 PID 是否可信规避重启后 PID 复用。6. WebSocket 通信与机器同步ApiMachineClientapiMachine.ts负责与后端全部双向通信所有数据载荷经TweetNaCl加密后 base64 编码传输RpcHandlerManager以machineId为 scope 前缀 加密 key 隔离。Daemon → Servermachine-alive每 20 秒、machine-update-metadata机器元数据变更罕见、machine-update-statedaemon 状态变更频繁。Server → Daemonrpc-request转发移动端调用方法如machine-uuid:spawn-happy-session、stop-session、resume-happy-session、stop-daemon以及rpc-registered/rpc-unregistered方法注册确认isReady()依赖注册表判断连接就绪。连接参数io(serverUrl, { transports: [websocket], path: /v1/updates, auth: { token, clientType: machine-scoped, machineId, happyClient: cli-daemon/version }, reconnection: false })断线后由startSmartReconnect()按shouldReconnect()策略重连。6.1 元数据与状态分离Machine Sync 架构设计文档提出并已在 types.ts 落地的核心模型是两类数据分离 独立版本号// 静态机器信息极少变化 interface MachineMetadata { host: string; // 主机名dev 变体追加 -dev 后缀见 run.ts platform: string; // darwin / linux / win32 happyCliVersion: string; homeDir: string; happyHomeDir: string; happyLibDir: string; // 项目安装路径 cliAvailability: { claude; codex; gemini; openclaw; agy; detectedAt }; resumeSupport: { rpcAvailable; requiresSameMachine; requiresHappyAgentAuth; happyAgentAuthenticated; detectedAt }; } // 动态 daemon 状态频繁更新 interface DaemonState { status: running | shutting-down | offline; // 含前向兼容 string pid?: number; httpPort?: number; startedAt?: number; shutdownRequestedAt?: number; shutdownSource?: mobile-app | cli | os-signal | unknown; }6.2 注册与实时更新初始注册POST /v1/machines请求体含id、metadata加密 base64、daemonState加密 base64服务端回显并分配metadataVersion: 1、daemonStateVersion: 1状态更新socket.emit(machine-update-state, { machineId, daemonState: 加密, expectedVersion }, callback)服务端返回{ result: success, version: 2 }或{ result: version-mismatch, version: N, daemonState: 当前值 }客户端在version-mismatch且服务端版本更高时同步本地版本并重试backoff包装见 apiMachine.ts服务端广播socket.emit(update, { body: { t: update-machine, id, daemonState: { value, version } } })——客户端只收到发生变化的字段metadata 或 daemonState 单独推送与 session 的 update 模式一致RPC 模式机器级 RPC 方法以machineId为前缀与 session 级 RPC 同理参数加密传输keep-alive 附带能力重发布每次machine-alive会重新探测各 agent CLI 可用性detectCLIAvailability()与 resume 支持若claude/codex/gemini/openclaw/agy任一安装状态或 CLI 版本变化立即走machine-update-metadata更新保证移动端始终看到真实能力。7. 集成测试验证控制流的关键用例daemon.integration.test.ts 覆盖了完整控制流会话跟踪/list初始为空终端派生会话经notifyDaemonSessionStarted上报后以startedBy: happy directly...被跟踪HTTP 派生/停止spawnDaemonSession(projectPath, sessionId)返回{ success, sessionId }随后可通过stopDaemonSession停止压力测试20 个并发 spawn/stop 全部成功且最终列表归零优雅停止stopDaemonHttp()后daemon.state.json被删除混合跟踪终端派生与 daemon 派生会话可同时被跟踪区分。版本不匹配测试默认it.skip破坏性且依赖手写自重启路径模拟npm upgrade happy修改package.json版本号 → 重新pnpm build让新版本编译进dist/daemon 心跳检测到 bundle 变化spawnHappyCLI([daemon, start])拉起新 daemon新 daemon 读到旧版本状态 →stopDaemon()杀掉旧进程 → 接管。关键约束源码注释明示心跳间隔必须大于重建耗时测试等待HAPPY_DAEMON_HEARTBEAT_INTERVAL 10s否则心跳恰逢dist/重建会因入口缺失而 spawn 失败pkgroll 不会更新编译进 dist 的版本常量必须用完整pnpm build该路径本质是进程内手写自替换文档与源码run.ts都倾向于未来迁移到launchd/systemd 原生服务管理类似 OpenClaw 模型让 OS 负责启动、开机自启与升级。8. 关键设计决策与演进方向设计文档明确列出的核心决策均有源码印证关注点分离metadata静态机器信息与daemonState动态运行时状态独立建模、独立加密独立版本号metadataVersion与daemonStateVersion各自递增允许两类更新并发而不冲突乐观并发 version-mismatch回退重试加密两类载荷分别用 TweetNaCl 加密密钥来自机器注册时返回的encryptionKey/encryptionVariant更新事件服务端广播复用 session 的 update 模式t: update-machine仅推送发生变化的字段RPC 模式机器级方法以machineId前缀隔离与 session 级一致。文档的Improvements 清单同时揭示了当前已知短板与演进方向daemon.state.json目前被硬删除未来应保留并增加state/stateReason字段便于区分从未启动 / 被 doctor 清理 / 异常退出文件损坏时应尝试升级或移除daemonPost的返回是响应或{ error }的不定型结构应统一为类型化 envelopedaemon 退出时丢失子进程跟踪——PIDs 应写入同一状态文件供 doctor/cleanup 使用caffeinate 进程未被跟踪可能成为 runaway且会话不应各自启动 caffeinateHTTP 端口目前无鉴权保护未来计划用公钥对载荷做签名会让测试变难属有意取舍。9. 快速参考常用命令与环境变量命令 / 环境变量说明happy daemon start以分离进程启动 daemonstart子命令等待就绪后退出start-sync在前台运行 daemon 本体happy daemon stop优雅停止 daemon会话保持存活HTTP 失败回退 SIGKILLhappy daemon status展示 PID、端口、启动时间、版本、状态文件内容happy daemon logs打印最新 daemon 日志文件路径happy daemon list列出被跟踪的活跃会话happy doctor全量诊断按类型分组展示所有 Happy 进程happy doctor clean清理疑似孤儿进程SIGTERM → 1s → SIGKILLHAPPY_DAEMON_HEARTBEAT_INTERVAL本地心跳间隔毫秒默认60000HAPPY_DAEMON_HTTP_TIMEOUT本地 HTTP 控制请求超时毫秒默认10000DEBUG1打开 daemon 详细日志输出以上所有命令与参数均可在 index.ts 的daemon子命令分发逻辑与 run.ts、controlClient.ts 中找到对应实现适合作为继续深入阅读的入口。【免费下载链接】happyMobile and Web client for Codex and Claude Code, with realtime voice, encryption and fully featured项目地址: https://gitcode.com/gh_mirrors/happy20/happy创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价