跳到主要内容

数据截至 (上游 commit 3667151744e3)

进程模型:一个常驻服务端 + 一群瘦客户端

30 秒导读: herdr 把自己拆成两个进程角色——一个常驻后台服务端(拥有所有终端、所有状态、所有渲染),和一群随开随关的瘦客户端(只负责把终端切到 raw 模式、把服务端画好的帧写到 stdout、把 stdin 原样送回去)。你在命令行敲的那个 herdr,永远是客户端;服务端是它在发现"没人在跑"时自己偷偷拉起来的。

本章只讲骨架:谁是进程、谁拥有状态、一次启动都发生了什么。终端内核见 终端内核,帧怎么画、怎么压缩见 渲染管线,JSON API 的方法表见 控制面


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

一句话: herdr 是给 AI 编码 agent 用的终端工作区管理器,而它的运行时形态是「一个后台守护进程 + 若干可随时来去的终端前端」。

为什么要这么拆? 因为 agent 干活的时间尺度和人看屏幕的时间尺度不一样。你想合上笔记本、断掉 SSH、换一个终端窗口,而 claudecodex 这些进程得继续跑。如果 herdr 是个单体 TUI 程序,你关掉终端 = 杀掉它 = 杀掉所有 agent。

最贴切的类比:tmux。 你敲 tmux 时,真正长命的是 tmux server;你敲的那个是 tmux client。herdr 是同一套心智模型,只是它把「服务端」做得更重(服务端自己跑完整的 UI 逻辑并渲染成帧),把「客户端」做得更瘦。

用起来什么样:

$ herdr # 第一次:发现没有服务端 → 后台拉一个 → 自己变成客户端接上去
# (界面出现,你在里面开 pane、跑 agent)
ctrl+b q # detach:客户端退出,服务端和所有 agent 继续跑
$ herdr # 第二次:发现服务端在 → 直接接上去,现场原封不动
$ herdr server stop # 真正把服务端停掉(pane 里的进程也一起走)

一句话直觉: 把服务端当成"那台一直开着的机器",客户端当成"你临时插上去的显示器 + 键盘"。显示器拔掉,机器不停。


2. 顶层全景:两个进程、两条 socket

先看图。从左到右读:你的终端 → 客户端进程 → 两条 socket → 服务端进程 → 一堆 PTY。

你的终端窗口 后台常驻(与终端无关)
┌──────────────┐ ┌────────────────────────────┐
│ herdr │ herdr-client.sock │ herdr server │
│ (瘦客户端) │◄══════════════════►│ (HeadlessServer) │
│ │ 二进制线协议 │ │
│ · raw mode │ 帧下行 / 键上行 │ · AppState(唯一真源) │
│ · 写 stdout │ │ · 虚拟 ratatui Buffer │──► PTY: claude
│ · 读 stdin │ │ · 事件循环 │──► PTY: codex
└──────────────┘ │ │──► PTY: shell
└────────────────────────────┘
herdr agent send ... ▲
┌──────────────┐ herdr.sock │
│ herdr CLI │◄══════════════════════════════┘
│ (一次性进程) │ 行分隔 JSON API
└──────────────┘

部件职责一览:

部件干什么主要文件
main()参数分流:CLI 子命令 / 隐藏进程模式 / 默认启动src/main.rs:519
自动拉起探测服务端、没有就 spawn 守护进程、等 socket 就绪src/server/autodetect.rs:290(auto_detect_launch)
服务端跑完整 herdr 事件循环,但不碰真实终端src/server/headless.rs:287(HeadlessServer)
瘦客户端raw mode + 收帧上屏 + 读 stdin 回传src/client/mod.rs:929(run_client)
线协议长度前缀 + bincode 的双向消息src/protocol/wire.rs:343/661
会话与 socket 命名决定这次连的是哪个服务端src/session.rs:29src/server/socket_paths.rs:23

两条 socket 是不同东西,别混:

socket文件名协议谁用覆盖它的环境变量
JSON APIherdr.sock一行一个 JSON 请求/响应herdr agent ... 之类的 CLI、外部 agent、状态探测HERDR_SOCKET_PATH
客户端协议herdr-client.sock二进制帧(bincode)只有 TUI 客户端 / 终端 attachHERDR_CLIENT_SOCKET_PATH(legacy)

一条重要设计约束: 客户端 socket 是"私有 TUI 通道",共享的运行时事实应该走 JSON API 那条路。第 4 章讲的 agent 控制面全部建在 herdr.sock 上,和本章的帧通道互不相干。


3. 一次 herdr 到底发生了什么

这是全章最该记住的一张图。从上往下是时间顺序,虚线框是"只在第一次发生"。

$ herdr

├─ 1. session::configure_from_args 剥掉 --session / session attach,写 HERDR_SESSION
├─ 2. remote::extract_remote_args 剥掉 --remote
├─ 3. cli::maybe_run 是 `agent`/`pane`/`api`… 子命令吗?是就走 JSON API 后退出
├─ 4. 隐藏子命令? server / client / remote-client-bridge → 各自进入对应进程角色
├─ 5. --help / --version / --skill / 未知参数
├─ 6. HERDR_ENV=1 且未允许嵌套 → 直接报错退出(防套娃)

├─ 7. --no-session? 是 → 单进程逃生舱(ratatui::init + App::new,当场跑完)

└─ 8. 默认路径:auto_detect_launch()

├─ connect(herdr-client.sock) 成功?
│ ├─ 是 → 校验协议版本 ─────────────────┐
│ └─ 否 ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐│
│ │ spawn `herdr server`(detach)││
│ │ 轮询 socket,最多 15s ││
│ └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘│
│ │
└─ run_client() ◄──────────────────────────┘
connect → Hello → Welcome → 进 raw mode → 收帧/送键

关键点:第 8 步之前,本进程还有可能变成任何东西;第 8 步之后,本进程一定是客户端。


4. 入口与命令分流

这节讲 src/main.rs:519main() 怎么把同一个二进制劈成好几种进程。

4.1 先剥会话参数,再谈别的

main() 做的第一件"有语义"的事,是把 --session 从参数里拿掉并落到环境变量:

let args = match session::configure_from_args(&raw_args) {};

session::configure_from_args(src/session.rs:29)返回的是已经剥干净的参数数组。这样做的好处很直白:后面所有命令解析都不用再认识 --session,而"我要连哪个服务端"这件事在一切之前就定死了。

它同时处理 herdr session attach <name> 这个写法——识别到就调 apply_explicit_name,然后把参数缩回只剩程序名,于是后续流程等价于一次裸 herdr

4.2 CLI 子命令:借道 JSON API,不启动 TUI

cli::maybe_run(src/cli.rs:95)是一张 match 表,把 api/workspace/pane/agent/session/status 等等分发到各自的处理函数,返回 CommandOutcome::Handled(code)NotCli。这些命令绝大多数是连上 herdr.sock 发一条 JSON 然后打印结果,不进 TUI(方法表见 控制面)。

这里有个巧妙的小设计:herdr server 这个词既是 CLI 前缀(server stopserver reload-config),又是隐藏的进程模式(裸 herdr server)。run_server_command(src/cli/server.rs:3)在没有子命令时返回 Ok(None),maybe_run 于是把它当成 NotCli 交还给 main(),再由 main() 落到真正的服务端入口。

4.3 三个隐藏子命令

这三个不在 --help 的常用列表里,是给 herdr 自己用的:

命令在哪分流变成什么进程
herdr serversrc/main.rs:579常驻服务端,调 server::headless::run_server()
herdr clientsrc/main.rs:583瘦客户端,调 client::run_client();调用前先查嵌套
herdr remote-client-bridgesrc/main.rs:575SSH 远端上的 stdio ↔ socket 管道

remote-client-bridge 只有二十几行实质逻辑:确认远端服务端在跑,连上远端的 herdr-client.sock,然后开一个线程把 stdin 抄进 socket、主线程把 socket 抄进 stdout(src/remote/host_unix.rs:8,run_remote_client_bridge)。跨机器的部分就这么朴素——本地客户端说的还是同一套二进制线协议,只是这段字节流被 ssh 驮了一程。跨机接管的完整故事见 活得久

4.4 --no-session:单进程逃生舱

带上 --no-session 时,main() 跳过一切服务端逻辑,直接在当前进程里开 TUI(src/main.rs:810 处的 if !no_session 判断之后那一大段):

let mut terminal = ratatui::init(); // main.rs:870

let mut app = app::App::new(config, true,);// main.rs:890
let result = app.run(&mut terminal).await;

注意传给 App::new 的第二个参数是 true(no_session),注释写得很直白:单体模式永不保存/恢复会话。这条路径保留的是"服务端化改造之前"的老行为,主要用于调试和不想要守护进程的场景。它照样会去绑 herdr.sock(JSON API),所以和一个正在跑的服务端会 AddrInUse 冲突。


5. 自动拉起:客户端负责把服务端叫醒

src/server/autodetect.rs 顶部的模块注释就是这节的提纲:检查 → 没有就 spawn → 等就绪 → 接上去。

5.1 怎么判断"服务端在跑"

is_server_listening(autodetect.rs:47)→ is_server_listening_at。Unix 下的判据不是"socket 文件在不在",而是能不能连上:

  • 文件不存在 → false
  • connect 成功 → true(测试连接立刻关掉;服务端那边会因为收不到 Hello 而握手超时,无害)
  • ConnectionRefused / TimedOut陈旧 socket(服务端崩了但文件还在),false
  • 其它错误 → 记 warn,当作 false

这条"connect 才算数"的规则是崩溃后能自愈的关键。仓库里有专门的测试盯着它(is_server_listening_returns_false_for_stale_socket,autodetect.rs:410)。

5.2 spawn 一个真·守护进程

spawn_server_daemon(autodetect.rs:189)拿 current_exe() 重新执行自己,参数是 server,并且:

处理为什么
stdin/stdout/stderr 全部 Stdio::null()不能占着你的终端
platform::detach_server_daemon_commandUnix 上 setsid,自己当会话首领,客户端退出不牵连
HERDR_STARTUP_CWD=<当前目录>让新服务端知道"用户是在哪个目录敲的 herdr"
显式指定过会话时,清掉继承来的 socket 覆盖变量防止 --session work 被外层继承的 HERDR_SOCKET_PATH 劫持

最后一条容易被忽略但很要紧:build_server_daemon_command(autodetect.rs:210)里,当 session::explicit_session_requested() 为真时会 env_removeHERDR_SOCKET_PATHHERDR_CLIENT_SOCKET_PATH

5.3 等就绪,和版本闸门

wait_for_server_socket(autodetect.rs:247)按 50ms 一次轮询,最长 15s(SERVER_READY_TIMEOUT,autodetect.rs:23),超时的错误消息里直接给出日志路径,而不是干等或黑屏。

auto_detect_launch(autodetect.rs:290)把三步串起来。有个细节值得单独说:只有"服务端已经在跑"这条分支才做版本校验

服务端已在跑 ──► validate_running_server_compatibility()
│ 通过 JSON API 发 ping,读回 version + protocol
├─ protocol == 本地 PROTOCOL_VERSION → 放行
└─ 不等 → 报错:"Herdr 更新了,但这个会话还跑着旧服务端"

validate_running_server_compatibility(autodetect.rs:150)刻意在连帧通道之前用 JSON API 探版本,因为这样能给出一条人能读懂的、带具体命令的错误(herdr session stop workherdr session attach work),而不是在二进制握手里被一句 Welcome{error} 顶回来。你升级了二进制但老服务端还在跑,踩的就是这条。


6. 服务端主体:HeadlessServer

6.1 它是什么

模块注释(src/server/headless.rs:1-15)把定位说得很干脆:跑完整的 herdr 事件循环,但没有真实终端。具体是:

  • 不进 raw mode、不读 stdin
  • 同时监听 herdr.sock(JSON API)和 herdr-client.sock(二进制协议)
  • 从会话恢复或全新状态初始化 AppState 和全部 PTY
  • 渲染到内存里的虚拟 ratatui Buffer
  • 每次渲染后把帧推给已连接的客户端
  • 客户端断开后继续跑

HeadlessServer 结构体(headless.rs:287)的字段就是这套职责的清单:app(状态真源)、client_listener + client_socket_path + client_socket_identityclients: HashMap<u64, ClientConnection>foreground_client_idheadless_size / effective_size

其中 foreground_client_id 值得点一句:多个客户端可以同时连,但只有一个"前台客户端"决定共享 pane 运行时的尺寸、主题和键位。没有任何客户端时,尺寸退回配置里的 [server] headless_cols/headless_rows(headless_size)。

6.2 run_server() 的启动顺序

run_server(headless.rs:5051)是 herdr server 的实际入口,顺序是有讲究的:

run_server()
├─ init_logging() → herdr-server.log(不是 herdr.log)
├─ raise_server_nofile_limit → 一堆 PTY 要吃 fd
├─ 若 argv[2] == --handoff-import → 改走热交接导入路径(见第 6 章)
├─ Config::load()
├─ api::start_server_with_stop_control → 绑 herdr.sock;AddrInUse 就报"已在运行"并退出
└─ tokio 多线程 runtime.block_on:
├─ App::new(...) 创建 AppState / 恢复会话
├─ seed_startup_workspace_if_empty 用 HERDR_STARTUP_CWD 播种首个 workspace
├─ 关闭本地通知副作用(见下)
├─ HeadlessServer::new(...) 绑 herdr-client.sock;AddrInUse 就报错退出
├─ print_ready_message(...)
└─ server.run().await 进事件循环

先绑 JSON socket、后绑客户端 socket,两处都用 AddrInUse 作为"已经有一个服务端了"的判据——这就是重复启动的兜底。

HeadlessServer::new(headless.rs:477)绑 socket 的四步同样值得记:

prepare_socket_path(&client_path)?; // 清陈旧文件;活着的直接 AddrInUse
let listener = bind_local_listener(&client_path)?;
restrict_socket_permissions(&client_path)?; // 0600
let client_socket_identity = socket_file_identity(&client_path)?;

第四步记下 socket 文件的 dev+ino,是为了退出时只删"自己创建的那个文件"(见 §9.3)。

6.3 服务端不发声

run_server 里有三行很容易一扫而过,但是理解服务端定位的关键:

app.state.local_sound_playback = false;
app.local_terminal_notifications = false;
app.local_input_source_switch = false;

服务端没有终端也没有扬声器,所以它不自己播声音、不自己弹通知、不自己切输入法,而是把这些统统包成 ServerMessage::Notify / ServerMessage::PrefixInputSource 转发给客户端,由客户端在它自己的终端和 OS 上执行。这条"副作用在客户端落地"的边界贯穿全项目。

6.4 播种第一个 workspace

seed_startup_workspace_if_empty(headless.rs:5147)解决的是一个很具体的体验问题:你在 ~/projherdr,期待打开的是 ~/proj;但服务端是被 spawn 出来的后台进程,它的 cwd 未必是那儿。

所以 §5.2 里那个 HERDR_STARTUP_CWD 在这里被消费:

let Some(cwd) = take_startup_cwd() else { return; }; // 读一次就 remove_var
if !app.state.workspaces.is_empty() {return; } // 恢复出来的会话优先,不覆盖
app.create_workspace_with_options(cwd.clone(), true)

两个保护:只用一次(take_startup_cwd 读完就删环境变量),只在空状态下用(有恢复出来的 workspace 就忽略 cwd)。

6.5 print_ready_message:写给走错路的人

print_ready_message(headless.rs:5283)往 stderr 打五行:先一句"服务端在跑了,你可以在另一个终端里用任何 herdr CLI 命令",然后是 api socket、client socket、日志路径,最后一句是:

did you mean to open the Herdr TUI? run herdr; you do not need herdr server.

正常自动拉起时这些字都进了 /dev/null(stdio 被 null 掉了),它真正的受众是手动敲了 herdr server 然后盯着一个不动的黑屏发懵的人

6.6 事件循环与关机

HeadlessServer::run(headless.rs:551)的注释把它类比成"没有真实终端的 App::run()",一圈干这些事:排干内部事件 → 排干 API 请求 → accept 新客户端 → 读客户端消息并路由输入 → 跑定时任务(会话保存、元数据过期)→ 虚拟渲染 → 推帧。渲染那半边属于 渲染管线

关机走 complete_shutdown(headless.rs:4864):拒掉还在 backlog 里的新连接 → 给所有客户端发 ServerMessage::ServerShutdown → 睡 50ms 等写线程 flush → 关连接 → 删 socket 文件。先通知再删文件,客户端才能打印一句人话而不是"连接被重置"。


7. 客户端主体:瘦到什么程度

src/client/mod.rs 顶部的模块注释列了客户端的全部职责——注意里面没有任何一条和"状态"或"业务逻辑"有关:

  • herdr-client.sock,发 Hello(带终端尺寸和协议版本)
  • 设置真实终端(raw mode、鼠标捕获、键盘增强)
  • Frame,和上一帧 diff 后写到终端
  • 读 stdin,打包成 ClientMessage::Input 发走
  • 检测 resize,发 ClientMessage::Resize
  • 退出时(正常或异常)恢复终端
  • 优雅处理 ServerShutdown 和"连不上服务端"
  • 转发服务端来的 OSC 52 剪贴板、声音/toast 通知

一句话:客户端不知道什么是 workspace、什么是 agent。 它只知道字节和帧。

7.1 握手成功之后才动终端

run_client(client/mod.rs:929)只是 run_client_with_mode(client/mod.rs:1217)的一层薄包装。后者的步骤顺序里藏着一个很值得抄的细节:

connect → 读终端几何 → do_handshake() → ✅成功 → 才 setup_terminal()(进 raw mode)
→ ❌失败 → eprintln + exit(1),终端从未被动过

源码里的注释写得明明白白:"必须在握手成功之后再设置终端,这样服务端拒绝我们时不会把终端遗留在 raw 模式"。协议版本对不上时,你得到的是一行清楚的错误,而不是一个乱码的、需要 reset 的终端。

do_handshake(client/mod.rs:834)本身很短:发 Hello,给 socket 装上读超时(本地 5s,--remote 因为要吃 SSH 冷连接的延迟给到 60s),读一条 Welcome,error 字段非空就当拒绝。

Hello 里几个字段是客户端"声明自己是谁":

字段取值来源
requested_encodingrequested_render_encoding(),client/mod.rs:704;默认 SemanticFrame,环境变量可切 TerminalAnsi
keybindingsrequested_keybindings(),client/mod.rs:770;默认用服务端键位,--remote --remote-keybindings local 时上传本地 [keys]
launch_modeclient_launch_mode(),client/mod.rs:811;App / AppDirectGraphics / TerminalAttach

7.2 三个方向的搬运

进循环后客户端就是个三向搬运工:stdin 字节 → Input;终端 resize → Resize;socket 上来的 Frame/Terminal → 写 stdout。退出前会主动补一条 ClientMessage::Detach(client/mod.rs:1008),让服务端立刻知道"这个客户端是主动走的",而不是等 EOF。


8. 线协议

8.1 帧格式与版本

传输格式极简:[u32 小端长度][bincode 载荷](write_message,wire.rs:910)。读端 read_message(wire.rs:933)做三件防御:

  1. 声明长度 > max_frame_size 直接报 Oversized,不预分配(默认上限 MAX_FRAME_SIZE = 2MB,wire.rs:20;只有 Kitty 图形走 MAX_GRAPHICS_FRAME_SIZE = 32MB,wire.rs:25)
  2. 正确重组半包读
  3. 解码后校验"消费字节数 == 声明长度",有尾巴就判协议违规

版本是一个单调整数:PROTOCOL_VERSION(wire.rs:16),当前值 20

8.2 消息表

ClientMessage(wire.rs:343)与 ServerMessage(wire.rs:661)的核心成员:

方向消息作用
C→SHello握手:协议版本、行列数、单元格像素、编码/键位/启动模式
C→SInputstdin 原始字节(可能是多字节转义序列)
C→SResize终端尺寸变化
C→SDetach优雅断开
C→SAttachTerminal把这条连接切成"直连某个 pane 终端"模式,可抢占写权
C→SObserveTerminal切成只读观察模式(给 agent 看别的 agent)
C→SControlTerminal切成可写控制模式,可抢占
S→CWelcome握手应答;error: Some(..) 即拒绝
S→CFrame(FrameData)语义帧(SemanticFrame 编码)
S→CTerminal(TerminalFrame)已经 diff 好的 ANSI 字节(TerminalAnsi 编码)
S→CNotify声音 / toast / 系统通知,交给客户端落地
S→CServerShutdown服务端要退了,客户端优雅收场

三个终端类 C→S 消息(AttachTerminal / ObserveTerminal / ControlTerminal)是"agent 指挥 agent"的传输层底座,语义在 控制面

8.3 双向版本拒绝

check_client_version(wire.rs:1003)的规则非常保守——只有完全相等才放行:

client_version == 0 → 拒:pre-persistence 客户端
client_version == PROTOCOL_VER → 放行
client_version < PROTOCOL_VER → 拒:请升级你的 herdr 客户端
client_version > PROTOCOL_VER → 拒:请升级 herdr 服务端

注意两个方向都拒,而且错误消息分别指向该升级的那一端。项目暂时不做向后兼容,理由写在函数文档里(backward compatibility is not yet supported)。

服务端在 handle_client_handshake(src/server/client_transport.rs:519)里执行这条规则:读到 Hello 后先 check_client_version(调用点在 client_transport.rs:578),不兼容就回一条带 errorWelcome 然后关连接——不是直接断开,这样客户端能打印原因。第一条消息不是 Hello 同样被拒。整个握手有 4s 超时(HANDSHAKE_TIMEOUT,client_transport.rs:38)。

握手通过后:起一个写线程(控制消息可靠、渲染帧可丢),向主循环发 ServerEvent::ClientConnected,自己进读循环。每个客户端两个线程,accept 侧则由 accept_pending_client_connections(src/server/client_accept.rs:12)在事件循环里非阻塞地捞。


9. 会话与 socket 命名:你到底连的是哪个服务端

9.1 会话 = 一个目录 + 一对 socket

概念位置
环境变量HERDR_SESSIONsession.rs:10(SESSION_ENV_VAR)
默认会话名"default"session.rs:11(DEFAULT_SESSION_NAME)
默认会话数据目录配置目录本身data_dir_for(None)
具名会话数据目录<config>/sessions/<name>/data_dir_for(Some(name))
JSON API socket<数据目录>/herdr.socksession.rs:169(api_socket_path_for)
客户端 socket<数据目录>/herdr-client.socksession.rs:183(client_socket_path_for)

"default" 是个哨兵名而非真目录:normalize_name 把它归一化成 None,active_name() 也把它过滤掉,于是默认会话用的就是配置根目录,不会多出一层 sessions/default/。会话名只允许 ASCII 字母数字加 . _ -,长度 ≤64,且不能是 . / ..(validate_name,session.rs:425)——因为这个名字要当路径分量用。

configure_from_args 除了剥参数,还维护一个进程级标志 EXPLICIT_SESSION_REQUESTED(session.rs:18)。它区分的是"用户这次显式点名了会话"和"只是继承了环境里的 HERDR_SESSION",后续解析 socket 路径时优先级不同。

9.2 客户端 socket 路径的四级优先级

client_socket_path()(src/server/socket_paths.rs:23)的注释就是规则本身:

1. 本次 CLI 显式 --session <name> → 用该会话的 client socket (最高)
2. 设了 HERDR_SOCKET_PATH → 从它派生:herdr.sock → herdr-client.sock
3. 设了 HERDR_CLIENT_SOCKET_PATH → 直接用(legacy / 测试用)
4. 都没有 → 用当前活动会话的数据目录 (最低)

第 2 条那个"派生"是 derive_client_socket_from_api_socket(socket_paths.rs:50):取 file stem 拼上 -client.sock。这样你只需要覆盖一个变量,两条 socket 就自动保持在一起——测试和多实例开发靠的就是它。

9.3 绑定时的三道手续

手续函数作用
清场ipc::prepare_socket_path(ipc.rs:81)建父目录;文件存在就先 connect 试探:连得上 → AddrInUse 拒绝启动,连不上 → 认定陈旧并删除
绑定ipc::bind_local_listener(ipc.rs:54)Unix 走文件系统名,Windows 走命名管道 + 写一个 marker 文件;两边都 reclaim_name(false),即不自动抢占已有名字
收权限restrict_socket_permissions(ipc.rs:336)Unix chmod 0600,只有属主能连(常量在 socket_paths.rs:12)

还有一道退出时的手续:socket_file_identity(ipc.rs:287)在绑定后记下 socket 文件的 dev+ino(Windows 记 marker 内容),remove_socket_file_if_owned(ipc.rs:305)在关机时先比对身份再删。为什么要这么小心? 因为热交接场景下新旧服务端会短暂并存,老服务端退出时绝不能删掉新服务端刚建的那个同名文件。细节见 活得久

9.4 HERDR_ENV=1:防止在 herdr 里再开 herdr

服务端给每个 pane 进程注入 HERDR_ENV=1(apply_pane_launch_env,src/pane.rs:134)。于是在 pane 里敲 herdr 时:

fn should_block_nested_for_env(config, herdr_env) -> bool {
!config.experimental.allow_nested && herdr_env == Some("1")
}

exit_if_nested_disabled(src/main.rs:496)命中就打印错误退出,并从 NESTED_HERDR_MESSAGES(src/main.rs:13,六条盗梦空间/侏罗纪公园梗)里随机挑一句。挑法本身也挺有趣:用当前纳秒和 PID 异或后取模,不引依赖就得到够用的随机性(random_nested_message,main.rs:485)。

拦截点覆盖两条会开 TUI 的路:自动拉起前(main.rs:804)和隐藏 herdr client(main.rs:586)。想要嵌套就在配置里开 [experimental] allow_nested = true


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

  • "connect 成功才算在跑"。 用连接而不是文件存在性判活,让崩溃遗留的 socket 自动被识别为陈旧并清掉,进程模型天然自愈(autodetect.rs:52,is_server_listening_at)。
  • 版本闸门放在 JSON API,而不是二进制握手。 因为前者能返回结构化的 version/protocol,于是能给出"跑这两条命令"级别的可执行错误(autodetect.rs:150)。
  • 握手成功之后才进 raw mode。 失败路径完全不碰终端,永远不会把用户的 shell 留在乱码状态(client/mod.rs:1293 一带的注释)。
  • 拒绝也要好好说话。 版本不兼容时服务端先回一条带 errorWelcome 再关连接;关机时先广播 ServerShutdown 再删 socket 文件(client_transport.rs:581-588headless.rs:4864)。
  • socket 文件按 inode 身份删除。 只删自己创建的那一个,让新旧服务端并存的热交接成为可能(ipc.rs:305)。
  • 一个环境变量,两条 socket 自动同步。 HERDR_SOCKET_PATH 派生出客户端 socket 路径,避免"只改了一半"的诡异半连接状态(socket_paths.rs:50)。
  • 副作用全部下沉到客户端。 服务端显式关掉声音/通知/输入法开关,改为发消息;这条边界让"服务端没有终端"从口号变成可验证的事实(headless.rs:5110 一带三行赋值)。

11. 边界与局限

  • 协议不做向后兼容。 客户端和服务端的 PROTOCOL_VERSION 必须完全相等,新旧都拒。所以 herdr update 之后,老服务端里的会话必须显式 stop 才能用新版本接上(除非走热交接,见 活得久)。
  • --no-session 不持久化。 单进程模式传给 App::newno_session = true,注释明说"永不保存/恢复会话";它也会占用 herdr.sock,和真服务端互斥。
  • 热交接是 Unix-only。 非 Unix 上 run_handoff_import_server 直接返回 "live handoff is only supported on Unix"(headless.rs:5279-5280),导出侧的 perform_live_handoff 同样(headless.rs:1451-1455)。
  • 远端 host 必须是 Unix。 remote-client-bridge 在 Windows 编译成一个直接报错的 stub(src/remote.rs:10)。
  • 直连终端 attach 暂不支持 Windows。 run_terminal_attach 的 Windows 版返回 Unsupported(client/mod.rs:951)。
  • 服务端 15s 起不来就放弃。 超时后客户端只是提示"再试一次 / 去看日志",不会自动重试(autodetect.rs:265)。
  • 无客户端时用配置里的虚拟尺寸。 [server] headless_cols/rows 决定没人看的时候 pane 有多大;它和你下次接上来的真实终端尺寸不一致时会触发一次 resize。

12. 代码地图

用法: 符号名比行号抗上游漂移,可以直接 grep。

主题文件符号
进程总分流src/main.rsmain
嵌套保护src/main.rsexit_if_nested_disabledshould_block_nested_for_envNESTED_HERDR_MESSAGESHERDR_ENV_VAR
CLI 分发src/cli.rsmaybe_runCommandOutcome
server 前缀命令src/cli/server.rsrun_server_command
探活src/server/autodetect.rsis_server_listeningis_server_listening_at
拉起守护进程src/server/autodetect.rsspawn_server_daemonbuild_server_daemon_commandSTARTUP_CWD_ENV_VAR
等就绪src/server/autodetect.rswait_for_server_socketSERVER_READY_TIMEOUT
启动编排src/server/autodetect.rsauto_detect_launchvalidate_running_server_compatibility
服务端结构src/server/headless.rsHeadlessServerHeadlessServer::newHeadlessServer::run
服务端入口src/server/headless.rsrun_serverseed_startup_workspace_if_emptytake_startup_cwdprint_ready_message
服务端关机src/server/headless.rscomplete_shutdowninitiate_shutdown
热交接导入侧src/server/headless.rsrun_handoff_import_serverperform_live_handoff
接受连接src/server/client_accept.rsaccept_pending_client_connectionsreject_pending_client_connections
服务端握手src/server/client_transport.rshandle_client_handshakeclamp_terminal_sizeHANDSHAKE_TIMEOUT
客户端入口src/client/mod.rsrun_clientrun_client_with_modedo_handshake
客户端声明src/client/mod.rsrequested_render_encodingrequested_keybindingsclient_launch_mode
线协议常量src/protocol/wire.rsPROTOCOL_VERSIONMAX_FRAME_SIZEMAX_GRAPHICS_FRAME_SIZE
消息定义src/protocol/wire.rsClientMessageServerMessageRenderEncodingClientLaunchMode
帧读写与版本src/protocol/wire.rswrite_messageread_messagecheck_client_versionVersionCheck
会话解析src/session.rsconfigure_from_argsapply_explicit_nameexplicit_session_requestedvalidate_name
会话路径src/session.rsdata_dir_forapi_socket_path_forclient_socket_path_forSESSION_ENV_VARDEFAULT_SESSION_NAME
客户端 socket 命名src/server/socket_paths.rsclient_socket_pathclient_socket_path_from_overridesderive_client_socket_from_api_socket
socket 绑定与权限src/ipc.rsbind_local_listenerprepare_socket_pathrestrict_socket_permissionssocket_file_identityremove_socket_file_if_owned
JSON API socket 路径src/api/mod.rssocket_pathSOCKET_PATH_ENV_VAR
SSH 桥src/remote/host_unix.rsrun_remote_client_bridgeensure_remote_server_running
pane 环境注入src/pane.rsapply_pane_launch_env

继续读: 终端内核 讲服务端里那些 PTY 和内嵌 VT 到底怎么组织;渲染管线 讲本章一笔带过的 Frame / Terminal 两种编码是怎么算出来的。