日常写代码用一台 MacBook Pro(M5),另外还有一台 Mac Studio 和一台 MacBook Pro(M2)。想让后两台也跑 Claude Code 这类 coding agent 分担任务,最麻烦的是看状态:各开一个 SSH / tmux 窗口,哪个 agent 停在确认上、哪个已经做完,只能挨个切过去看。herdr 是专门给 coding agent 设计的终端复用器,能识别 pane 中 agent 的运行状态,把多台机器的 server 汇总到同一个侧栏,并通过返回 JSON 的 CLI 远程管理 pane、启动 agent、下发任务与读取输出。

三台机器上的 herdr 都是 0.9.5(2026-10-14 发布),机器之间走 Tailscale,studio 和 m2 是 ~/.ssh/config 里的主机别名。

herdr 的五层对象:session、workspace、tab、pane、agent#

herdr 与 tmux 类似,由后台 server 持有终端进程,前台 client 仅负责渲染,client 断开不影响后台进程运行(tmux 的做法见这篇)。对象分五层:

  • session:独立的 server 命名空间。默认连接 default session。
  • workspace:项目级容器,通常以仓库或任务为粒度创建,侧栏按 workspace 汇总状态。
  • tab:workspace 内的窗口布局。
  • pane:具体的终端窗格,支持切分。
  • agent:herdr 在 pane 中识别出的 coding agent 进程及其状态。

agent 的状态分为五种(Concepts):

状态 含义
working 正在执行任务
blocked 在等输入、权限确认或决策
done 做完了,还没人看
idle 空闲或在等待,已经看过
unknown 认不出状态

对 CLI 来说,done 和 idle 都表示可以接新指令。

server 持有终端,CLI 走 API socket#

server 启动时会在 ~/.config/herdr/ 下创建两个 socket:herdr-client.sock 供 TUI client 使用,herdr.sock 则是 API socket。在 macOS 与 Linux 下,server 默认忽略 SIGHUP,退出 TUI(ctrl+b q)或关闭终端均不会中断 pane 进程;停止需执行 herdr server stop 或发送 SIGTERM(Persistence and remote access)。

herdr workspace、herdr pane、herdr agent 这些子命令走 API socket、返回 JSON,后面命令要用的 ID 从输出里取。herdr 还给每个 pane 设了 HERDR_ENV=1、HERDR_SOCKET_PATH、HERDR_PANE_ID 这几个环境变量,pane 里的进程靠它们找到自己所在的 pane 和要连的 socket(Socket API)。

server 重启就不一样了:布局按 session.json 恢复,原来的进程已经没了,pane 里起的是新 shell;装了 integration 的 agent 能按保存的会话 ID 接上之前的对话(Session state and restore)。

Claude Code 的状态靠屏幕识别,integration 只报会话 ID#

herdr 靠前台进程和 screen manifest 认 agent(Concepts):拿预置的界面特征去匹配 pane 底部的纯文本快照(快照用 herdr pane read --source detection 看,生效的 manifest 用 herdr server agent-manifests 看)。

integration 是 herdr 写进各 agent 配置的 hook 脚本,用来报会话 ID,Pi、OpenCode 等少数几个还上报状态。herdr integration install claude 在 ~/.claude/hooks/ 写入脚本、注册到 SessionStart,会话启动时把会话 ID 报给 herdr,server 重启后靠它恢复对话(Integrations)。脚本开头先检查环境,不在 herdr pane 里就直接退出,在 herdr 外面用 Claude Code 不受影响:

[ "${HERDR_ENV:-}" = "1" ] || exit 0
[ -n "${HERDR_SOCKET_PATH:-}" ] || exit 0
[ -n "${HERDR_PANE_ID:-}" ] || exit 0

saved machine:几台机器进同一个侧栏#

每台机器各跑一个 server,控制端只存连接信息(saved machine),TUI 和 CLI 都经 SSH 连过去:

flowchart TB subgraph LAPTOP["MacBook Pro M5(日常用)"] TUI["herdr TUI
侧栏:Local、studio、m2"] CLI["herdr --machine
子命令"] end SSH["OpenSSH 共享连接
ControlPersist 600"] subgraph STUDIO["Mac Studio"] LA1["LaunchAgent 启动器
登录时跑一次"] S1["herdr server
detached"] P1["pane 里的 agent"] LA1 -->|"拉起"| S1 S1 --> P1 end subgraph MB2["MacBook Pro M2"] LA2["LaunchAgent 启动器
登录时跑一次"] S2["herdr server
detached"] P2["pane 里的 agent"] LA2 -->|"拉起"| S2 S2 --> P2 end TUI -->|"client 协议"| SSH CLI -->|"JSON API"| SSH SSH --> S1 SSH --> S2

SSH 用系统的 OpenSSH,~/.ssh/config 照常写:

Host studio
    HostName <Tailscale 地址>
    User <远端用户>
    IdentityFile ~/.ssh/<私钥>
    IdentitiesOnly yes

herdr machine add 会通过 SSH 检查远端的安装和 server 状态,检查通过才把这台机器存到本地(非交互命令默认复用远端 default session,详见 Connecting machines):

herdr machine add studio --label studio
herdr machine add m2 --label m2
herdr machine status
c09b4232705b31594642fa64e264c160	studio	reachable
da1265acdddb8d04ef0bd8183a96b173	m2	reachable

profile 里不存密码和私钥。侧栏和 --machine 命令共用一条 OpenSSH 连接,空闲 10 分钟断开(ControlPersist 600,Connecting machines)。加好之后,TUI 侧栏在 Local 下面列出 studio 和 m2,agent 列表里会标出每个 agent 在哪台机器上。

不开 TUI 时,herdr --machine <标签> <子命令> 把 workspace、pane、agent 这些 API 调用经非交互 SSH 转到远端,参数里的 ID 都是远端的。--machine 不会启动远端 server(CLI reference 原话 never installs, starts, or restarts a server),远端 server 没在跑就直接失败;TUI 侧栏重连时走的 remote-client-bridge 会把 server 拉起来。

远端 server 用 launchd 常驻:直接跑 herdr server 会被拒#

远端机器重启后没有 TUI 发起重连,--machine 就连不上,所以 server 得开机自己起来。直接让 LaunchAgent 跑 herdr server,server 能起来,但控制端 herdr machine add 报错:

remote herdr server on studio (session default) is still running v0.9.3; it will use 0.9.3 after it restarts.
error: remote server is not ready for saved machines; machine was not saved

这段是升级到 0.9.5 之前抓的。server 缺 detached-daemon 支持、machine add 又不在交互终端里跑时,herdr 先打第一行这句版本提示,再报第二行的错(attach.rs)。远端 herdr status server --json 的 capabilities 里,detached_server_daemon 是 false:

{"live_handoff":true,"detached_server_daemon":false,"endpoint_protocol_generation":1,"surface_interest":true,"health_check":true,"ssh_agent_registration":true}

saved machine 对远端 server 的检查在 src/remote/restart_policy.rs:协议版本匹配、有 surface_interest、健康检查通过、detached_server_daemon 为真,四条都要满足。最后一条的判定在 src/platform/mod.rs:

#[cfg(any(target_os = "linux", target_os = "macos"))]
pub fn current_process_is_detached_server_daemon() -> bool {
    unsafe { libc::getsid(0) == libc::getpid() }
}

也就是 server 进程得是自己 session 的 session leader。herdr 平时起 server,是 fork 出子进程再在子进程里 setsid()(同一文件的 detach_server_daemon_command),子进程不是进程组组长,这一步能成;herdr server 自己不调 setsid()。launchd 托管的进程本身就是进程组组长(PGID 等于 PID),按 POSIX,进程组组长调 setsid() 返回 EPERM,在这个进程里也没法原地 detach。

解决办法是让 LaunchAgent 跑 saved machine 自己经 SSH 调的那条命令:src/remote/attach.rs 里 saved_bridge_command 拼出来的是 exec herdr remote-client-bridge </dev/null。bridge 发现 server 没在跑就起一个 detached server,stdin 读到 EOF 后 bridge 退出,server 留着。LaunchAgent 就成了登录时跑一次的启动器(完整 plist 模板,由 Ansible playbook 部署):

    <key>ProgramArguments</key>
    <array>
        <string>{{ herdr_bin }}</string>
        <string>remote-client-bridge</string>
    </array>
    <key>StandardInPath</key>
    <string>/dev/null</string>
    <key>RunAtLoad</key>
    <true/>
    <key>AbandonProcessGroup</key>
    <true/>

换成启动器之后,detached_server_daemon 变成 true,machine add 通过。代价有两个:remote-client-bridge 是内部命令,herdr --help 里没有,升级后最好再看一眼 detached_server_daemon;这个 job 只管启动,server 崩了 launchd 不会再拉起来,要等侧栏重连,或者手动 launchctl kickstart gui/$(id -u)/com.meirongdev.herdr-server。

升级时 herdr update 只换二进制,等 agent 空闲后 herdr server stop,再 kickstart 一次拉起新版本;integration 显示 outdated 就重跑 herdr integration install。

远端 pane 里的环境:PATH、钥匙串、凭据、提交身份#

远端 pane 继承的是远端 server 启动时的环境,herdr 不会把本地的环境变量、可执行文件或密钥同步过去,下面四项要自己补。

launchd 环境变量补充#

LaunchAgent 默认的 PATH 只有 /usr/bin:/bin:/usr/sbin:/sbin,也不设 LANG。pane 在 macOS 上默认是 login shell(Configuration 里 shell_mode = "auto" 的行为),会跑 ~/.zprofile 里的 brew shellenv,但它只补路径,LANG 仍然没有,中文输出会乱码。所以在 plist 的 EnvironmentVariables 里写上 LANG=en_US.UTF-8,同时把 Homebrew 和 ~/.local/bin 加进 PATH,server 自己也用这一份。

钥匙串访问#

herdr 的 server 在 macOS 上跑在按用户的 Background 上下文里(pane 里 launchctl managername 返回 Background)。远端开了自动登录时,登录钥匙串(login keychain)在登录时就解锁了,pane 里的 agent 能读到存在钥匙串里的凭据,比如 Claude Code 的登录态。Troubleshooting 提到经 SSH 或后台任务拉起的 server 可能用不了交互式 Keychain(herdr#966),遇到了就从 GUI 终端重启 server。

凭据同步与权限边界#

凭据用 chezmoi 加 age 同步。kubeconfig、gcloud 这类客户端自己会刷新的凭据,同步过去就能用;本地 Terraform state 这类能改生产的东西最好别同步,免得远端的 agent 误跑部署。

Git 提交身份配置#

远端没配 Git 身份时,Git 会拿系统用户名和主机名拼一个提交者身份。useConfigOnly = true 让 Git 在没配置时直接报错,身份再按 remote URL 引入:

[user]
	useConfigOnly = true

[includeIf "hasconfig:remote.*.url:[email protected]:meirongdev/**"]
	path = ~/.gitconfig-meirongdev
[includeIf "hasconfig:remote.*.url:https://github.com/meirongdev/**"]
	path = ~/.gitconfig-meirongdev

日常派活:TUI 看状态,CLI 发任务#

创建远程 workspace:

herdr --machine studio workspace create --cwd /Users/<远端用户>/projects/homelab --label verify --no-focus

返回的 JSON 里有 workspace_id 和 root_pane.pane_id(这次是 w2 和 w2:p1)。--cwd 写远端的绝对路径,仓库要先在远端 clone 好。

在目标 pane 中执行前置检查:

herdr --machine studio pane run w2:p1 "zsh /tmp/agent-verify.sh"
herdr --machine studio pane wait-output w2:p1 --match VERIFY-DONE --timeout 150000
herdr --machine studio pane read w2:p1 --source recent-unwrapped --lines 40

检查要在 pane 里跑,环境才和 agent 一致。ssh studio '<command>' 这种非交互 SSH 不加载 ~/.zprofile,PATH 不全,测出来的不是 pane 里的情况。

启动 agent 并下发指令:

herdr --machine studio agent start studio-check --kind claude --pane w2:p1
herdr --machine studio agent prompt studio-check "不要调用任何工具,只回复一行:studio-ok + 你当前工作目录的完整路径 + 你看到的 git 分支名" --wait --timeout 120000
herdr --machine studio agent read studio-check --source recent-unwrapped --lines 30

agent start 要求 pane 停在 shell 提示符。agent prompt --wait 等 agent 进入 idle、done 或 blocked 就返回,它只看状态,不管任务成没成(CLI reference)。返回正常时,输出里也可能是一条配额告警:

⏺ Usage limit reached · continuing automatically at 10pm · esc to cancel

所以发完任务每次都用 agent read 看一眼。做完后 herdr --machine studio workspace close w2,workspace 连同里面的 pane 和 agent 一起关掉。

agent 处于 blocked 时,agent prompt 直接返回 agent_blocked,不写入任何输入(CLI reference)。这时先 agent read 看它停在哪,再用 agent send-keys 发确认按键,或者到 TUI 里处理。要让本机的 agent 去调度几台机器,herdr --skill 会打印一份 herdr CLI 的 skill 说明(Agent skill file),可以交给它读。

小结#

herdr 把几台 Mac 上的 coding agent 汇到一个侧栏:saved machine 只存连接信息,认证交给 OpenSSH,--machine 把 CLI 调用转到远端。

远端 server 不能直接交给 launchd 跑,LaunchAgent 改调 herdr remote-client-bridge </dev/null,由 herdr 自己 detach 出 server。LANG、凭据和 Git 身份要在远端补齐,并在 pane 里验证;派完任务用 agent read 看实际输出,别只看 --wait 的返回。

参考资料#

相关文章#