feat(backend): 支持 ZMX 持久会话后端 - #458
Open
LucasIcarus wants to merge 25 commits into
Open
Conversation
LucasIcarus
marked this pull request as draft
July 14, 2026 00:24
LucasIcarus
force-pushed
the
feat/support_zmx
branch
4 times, most recently
from
July 17, 2026 06:30
ed9e9e1 to
1f908f5
Compare
LucasIcarus
force-pushed
the
feat/support_zmx
branch
from
July 23, 2026 00:23
1f908f5 to
4db5ac2
Compare
LucasIcarus
marked this pull request as ready for review
July 23, 2026 00:34
LucasIcarus
marked this pull request as draft
July 23, 2026 06:19
LucasIcarus
force-pushed
the
feat/support_zmx
branch
4 times, most recently
from
July 28, 2026 10:01
7af19fb to
9592f7a
Compare
LucasIcarus
marked this pull request as ready for review
July 28, 2026 14:35
LucasIcarus
force-pushed
the
feat/support_zmx
branch
from
July 28, 2026 14:43
7439ac9 to
8cccf6d
Compare
Contributor
Author
对抗性复审 follow-up(
|
LucasIcarus
force-pushed
the
feat/support_zmx
branch
from
July 28, 2026 17:09
552f4de to
0a29a56
Compare
upstream/master 把 `idleDetector.onIdle(async () =>` 改成了 `async (evidenceSource) =>`,导致 worker-pipe-initial-screen-order 的 源码切片锚点匹配不到、slice 出空串,断言随之失败。 锚点改为不绑定参数列表的正则,并对 idleStart/idleEnd 显式断言, 避免以后再出现「锚点失效 → 静默切出空串」这种假绿/假红。 Claude-Session: https://claude.ai/code/session_01E5sDdnZiHp9t1PP91UfLso
zmx 上游已在 2026-07-23 发布 v0.7.0,其中包含 commit 8ba312d7 "fix(send): preserve client leadership"(issue deepcoldy#201 的修复,已通过 GitHub compare 确认包含在该 tag 内)。`zmx send` 改用独立的 `.Send` IPC tag,只把输入排进 PTY 队列,不再抢 leader、不再改写终端尺寸。 原实现按「0.7.1 是首个包含该修复的版本」这一**假设**写死门禁: compareVersion(parsed, [0,7,1]) < 0 即拒绝。但上游从未发布 0.7.1, 实际修复随 0.7.0 落地 —— 于是这条门禁会拒绝**所有已发布版本**, zmx 后端在任何机器上都起不来。本机装的 0.7.0 也被拒。 改动: - ensure-zmx.ts: 抽出 ZMX_MIN_VERSION = [0,7,0] 常量,门禁与错误 文案统一引用它,不再散落硬编码 - probeZmxVersion 返回规范化的单行 "zmx x.y.z"。`zmx version` 实际 会打印 ghostty_vt / socket_dir / log_dir 四行,而这个字符串被 dashboard-ipc-server.ts:2832 原样回给 Dashboard API —— 原来会把 本机 socket / log 路径泄漏出去,并且相对 "tmux 3.5" 这类同侪显示 成多行糊字 - 门禁失败文案从「等待上游发布正式版」改成可操作的安装指引 (brew / release binary / mise) - zmx-backend.ts 里引用 "PR deepcoldy#202" 的注释改为引用已发布行为:该 PR 是 closed-not-merged,维护者用自己的 commit 落地了同一设计,继续 引用一个没合并的 PR 会误导后来者。注释描述的约束本身经核对 v0.7.0 源码仍然成立(ipc 4096 字节帧、PTY_WRITE_BUF_MAX = 256 KiB、 queuePtyInput 溢出静默丢弃且无 ACK),故 1 KiB 分片与 64 KiB 上限 保留不变 - README / docs-site 中英文同步:去掉「上游尚未发布、请勿启用」的 前置警告,补上 0.6 daemon 因 Tag 枚举 non-exhaustive 而静默丢弃 `.Send`(`zmx send` 仍退出 0)的具体机制 验证: - pnpm build 通过 - 相关单测全绿(zmx-backend-helpers / backend-gate / backend-availability / zmx-backend-recovery / backend-capabilities) - 用本机真实 zmx 0.7.0 跑 probeZmxFunctional():改前被拒,改后 ok=true、version 规范化为 "zmx 0.7.0" Claude-Session: https://claude.ai/code/session_01E5sDdnZiHp9t1PP91UfLso
`resize()` 原本只有一句「send 不当 leader 所以不能 resize」,没说清
两件对使用者有实际影响的事:
1. ZMX 压根没有「不当 leader 也能 resize」的接口(`zmx --help` 里
attach/run/send/print/write/detach/list/get/set/clear/kill/history/
wait/tail/completions/version 全集里没有 resize)。所以这个 no-op
是结构性的,不是偷懒,也不会随上游修 send 而变得可以去掉 —— 0.7.0
让 send 不再抢 leader,恰恰使它更不可能顺带 resize。
2. 那么受管会话到底跑在多大?`createFreshSession` 用
`stdio: ['ignore','ignore','pipe']` 建会话,没有 TTY,于是落到 ZMX
`ipc.zig` `getTerminalSize` 的兜底分支 `.{ .rows = 24, .cols = 120 }`。
即 botmux 建的每个 zmx 会话都固定 120x24,CLI 的 TUI 按 120 列折行,
这正是飞书侧看到的宽度。
把这两点写进注释和中英文文档的「显示、输入与终端尺寸边界」一节,
免得后来者把 no-op 误当成待修的 TODO 再去翻一遍上游源码。
Claude-Session: https://claude.ai/code/session_01E5sDdnZiHp9t1PP91UfLso
upstream 把 worker 的后端选择重构成 `selectBackend()` thunk:先选一次,
每个 gate 杀掉陈旧 pane 后再 `selectedBackend = selectBackend()` 重选,
并由 `selectedBackend.isReattach` 推导 willReattachPersistent。
原断言锚定的是本分支旧写法「`const selectedBackend = selectSessionBackend({`
必须出现在 gate 之后」,重构后该字面量已不存在,切片取到空串 / -1。
不变量本身变了形状,所以不是简单改锚点:现在要保的是「任何杀 pane 的
gate 之后必须重选,且必须同步刷新 isReattach」—— 否则 gate 刚删掉 pane,
陈旧的 isReattach=true 会让新后端 reattach 到已被销毁的 pane。断言改为
对 read-isolation 与 mcp-gateway 两个 gate 分别校验这两点。
第二条断言的探针名同步更新:合并后 ZMX 走 probeOwnedZmxSession(按冻结
PID 校验归属),其它后端走 probePersistentBackendTarget(Herdr 可能持有
的是 agent 而非整个 session),fail-closed 的 unknown / postKillProbe /
resolvedZmxSessionProbe 断言保持不变。
验证:pnpm build 通过;pnpm test 10592 passed / 23 skipped / 0 failed;
真实 zmx 0.7.0 E2E 4/4 通过且无孤儿会话。
Claude-Session: https://claude.ai/code/session_01E5sDdnZiHp9t1PP91UfLso
自检轮次里两条经对抗验证成立的问题。 **1. probeZmxVersion 把所有异常都归因为「不在 PATH 上」** 裸 catch + `stdio` 第三位 'ignore',真实 stderr 直接丢弃。但 `zmx version` 并不是纯打印——它会解析并触碰 socket dir,因此 ZMX_DIR / XDG_RUNTIME_DIR 只读或不可创建时会非零退出(本机实测 `ZMX_DIR=/nonexistent-ro-xyz/nope zmx version` → `error: ReadOnlyFileSystem`, exit 1)。daemon 实际跑在 Linux,systemd --user 未开 lingering 时 /run/user/$UID 会在登录会话结束后消失,正好命中。 结果是:zmx 装得好好的,飞书卡片却说「zmx 二进制不在 PATH 上, 请 brew install …」,用户反复重装无效,真实原因被 'ignore' 吞掉。 本仓库其实已经修过同一类问题并固化了约定:ensure-tmux.ts 的 `childFailureReason` 明确注释「Only ENOENT proves absence,超时/EACCES 不得转成 misleading not-on-PATH」。zmx 探针绕过了这条约定,现按同样的 分支补齐(ENOENT / EACCES / EMFILE / 超时 / stderr / exit code), stderr 改为 'pipe',并在 reason 里附上生效的 socket dir 来源, 让无头部署有可操作信息。 **2. waitForTailClient 用全局 clients 计数差值判断自己的 tail** zmx 的 `clients=` 是聚合值——main.zig 只扣掉发起 `zmx list` 的那条 连接,用户的 `zmx attach`(本集成正在文档里主推)和 botmux 自己每次 list/get/history/send 的瞬时连接,与我们的 tail 完全等价计数。本机实测 起 1 个 tail → clients=1,起 2 个 → 2,杀掉 → 0。 于是差值判定会产生**假阴性**:用户在这 3 秒窗口内 detach,净增量为 0, 一个完全健康、history 可读的会话直接 `ZMX tail 未能连接会话 …` 恢复失败。 改为 `clients >= 1`,把错误方向反过来。残留的假阳性(别人占着客户端而 我们的 tail 挂了)代价很小且能自愈:tail 只是唤醒信号、从不作为字节来源, 权威屏幕来自 history 轮询(根本不依赖 tail),且 scheduleTailRecovery 会重连。挡住恢复才是贵得多的错误。 顺带去掉 `?? 0` 这个会把 baseline 静默归零的兜底。 注:这里无法改为直接判定自己的子进程——等待是同步的(sleepSync), 子进程的 'error'/'close' 回调在返回前根本不会执行;会话身份校验保留。 验证: - pnpm build 通过;pnpm test 10596 passed / 23 skipped / 0 failed - 新增 tail 回归用例先在旧差值语义下复现失败、改后通过(非空转断言) - 新增三条探测归因用例(socket dir 失败 / 真 ENOENT / 超时) - 真实 zmx 0.7.0 E2E 4/4 通过,无孤儿会话 Claude-Session: https://claude.ai/code/session_01E5sDdnZiHp9t1PP91UfLso
LucasIcarus
force-pushed
the
feat/support_zmx
branch
from
July 28, 2026 18:46
effeab7 to
208a1d8
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
背景
在现有 PTY / Tmux / Herdr / Zellij 之外,增加 ZMX 作为显式 opt-in 的轻量持久会话后端。目标是让 CLI 在 botmux daemon 重启后继续存活,同时保留用户从本机
zmx attach进入同一会话的能力。本实现不再通过
node-pty常驻一个假的 leader client。ZMX 的tail只承担低延迟变化/存活信号,history是 botmux 唯一权威的纯文本屏幕,输入通过修复后的send注入;因此飞书侧行为尽量贴近 tmux 的持久会话生命周期,但不伪装成 raw ANSI 终端镜像。上游前置
本集成要求 zmx >= 0.7.0。该下限对应上游 issue #201 的修复 —— commit
8ba312d7fix(send): preserve client leadership,已随 v0.7.0(2026-07-23)正式发布:zmx send改用独立的.SendIPC tag,只把输入排进 PTY 队列,不抢占 attach leader,也不改写终端尺寸。ZMX 仍是显式选择:botmux 不自动安装、不因
PATH中存在 zmx 就自动选用,版本/控制面不满足时 fail closed,也不会静默回落 PTY。0.6 的既有逐会话 daemon 需要升级后手动关闭并重建;本 PR 不做自动冷迁移,单独重启 botmux 不能替换旧 daemon —— ZMX 的 IPCTag枚举是 non-exhaustive 的,0.6 daemon 收到新的.Sendtag 会直接丢弃,而zmx send仍退出 0,表现为命令成功但输入从未送达。改动
ZmxBackend及完整生命周期:确定性bmx-*会话名、私有启动握手、创建/恢复、探活、挂起、转移、关闭和 ownership 校验。tail + history + send传输:tail的字节不进入 worker,避开上游 ANSI 过滤丢失 UTF-8(中文/emoji)的缺陷;history采用异步 single-flight、dirty latch、热/冷错峰轮询和 idle 定稿屏障,重连后按权威快照重建;send以 1 KiB 有序分片,单次输入在写入前限制为 64 KiB;协议没有 ACK 时不对不确定结果盲目重试,括号粘贴部分失败会尽力闭合。C-c/ Escape 等状态关键输入走严格失败通道;中断发送失败会重试一次并给用户可见通知,避免 UI 显示“已停止”而 CLI 仍在运行。pnpm test;默认 unit project 不运行*.e2e.ts,普通贡献者和 CI 不需要安装 zmx。能力边界
zmx history为准botmux list/zmx attach;macOS 可走“本机 CLI 直开”sandbox: true/ 全局 sandbox / macOS 有效 read isolationcodex-app/mira/mir隐藏 OSC final/thread runner上游
send当前没有 PTY 级 ACK/backpressure,history也受 ZMX/ghostty scrollback 上限约束;这些限制在实现和文档中都显式保留,没有用“成功返回”伪装成强投递保证。对外只读查询与安全边界
同步补齐 Dashboard 对外会话观测字段:
backendType:实际 spawn 时记录的后端;backendSessionName:受管持久后端的确定性会话名;titleUpdatedAt/titleSource:标题更新时间与信息性来源标签。这些字段会随
GET /api/sessions和对外GET /eventsSSE 的 session row/update 输出,均为可选字段,兼容旧会话/旧 daemon。backendSessionName只用于定位,不构成 socket/进程存活证明;titleSource也不是可信审计身份。publicReadOnly开启时,上述只读元数据可无 token 获取;全部写操作、非白名单 GET、原始 PTY 和诊断日志仍要求当前 Dashboard token。只读白名单保持 fail closed,新增 GET 不会自动暴露;跨 bot 文件回退只用于读取,变更端点只接受当前 daemon 实际拥有的 session。影响面
backendType: "zmx"/BACKEND_TYPE=zmx的部署行为不变。新增 session/query 字段全部可选,旧记录可继续读取。本分支已 rebase 到
upstream/master@d21d159f。该轮变基跨越 32 个上游提交,其中与本 PR 重叠最深的是 upstream 对持久后端寻址的重构(persistentSessionName→persistentBackendTarget/probePersistentBackendTarget/killPersistentBackendTarget,以支持 Herdr 在共享 host 内持有 agent)。冲突解法与其中几个变基才会出现的集成缺陷,见下方「变基集成」。验证
pnpm build:通过(含 public-domain audit、TypeScript、Dashboard bundle、dist audit)。pnpm test:674 files / 10596 passed / 23 skipped / 0 failed。真实 ZMX E2E:首次可对着正式发布版跑。此前只能自建 PR [codex] add HD2D office dashboard tab #202 分支 + 用 wrapper 伪造版本号,现在直接用官方 0.7.0:
结果:1 file / 4 tests passed(10.1–10.4s,三轮复验一致),结束后
zmx list无残留bmx-e2e-*会话。覆盖 fresh/reattach、UTF-8/emoji、纯中文无 tail 信号安全轮询、tail 崩溃恢复、C-c、多 chunk 原始输入、立即退出尾段采集和 kill 无孤儿。门禁实测:用本机真实 zmx 0.7.0 跑
probeZmxFunctional()—— 修正前被拒(门禁要求 >= 0.7.1),修正后ok=true、version 规范化为zmx 0.7.0。git diff --check:通过。默认
pnpm test只跑 mock/纯函数 unit,不探测或启动本机 zmx;真实 zmx 仅在显式 E2E 命令中参与,BOTMUX_E2E_REQUIRE_ZMX=1会让缺失/版本不符直接失败而不是静默跳过。变基集成
上游的持久后端寻址重构与本 PR 的 ZMX 支持在同一批函数上重叠,机械合并会引入几个静默缺陷,已逐个修掉并补测:
killPersistentBackendTarget不传 sessionId:ZMX 的销毁是按 botmux 标签做身份校验的,killPersistentSession对 zmx 显式throw拒绝 name-only kill。upstream 的新 target helper 不传 sessionId,会让每一次 zmx kill 都抛错。已给 helper 加上 sessionId 并在四个调用点透传。selectSessionBackend的 zmx 分支缺isReattach:upstream 改为从selectedBackend.isReattach推导willReattachPersistent,而 zmx 是唯一没在返回对象上带该字段的后端 —— 合并后每个 zmx 会话都会被当成全新冷启。同时补上persistentSessionName/persistentBackendTarget。PersistentBackendType丢掉'zmx':git 把该行静默并成了 upstream 的收紧版(Extract<…, 'tmux'|'herdr'|'zellij'>),与同文件isSuspendableBackendType自相矛盾却没有冲突标记。zmx list分类所有会话,避免重启时 O(N²))按 session 名寻址,无法表达 Herdr 的 agent 级 target。合并后按目标类型分流:agent 级走probePersistentBackendTarget,其余仍走批量快照。selectBackend()thunk 在每个 gate 杀掉陈旧 pane 后重选,本 PR 原本是把选择整体后移。两者解决同一问题,已收敛到 upstream 写法,并把冷启顺序断言改为校验新形状的不变量(任何杀 pane 的 gate 之后必须重选并刷新isReattach)。本轮自检
在变基之后又做了一轮针对性自检(多路并行审计 + 对抗性验证,37 条原始发现 → 34 条被驳回、3 条成立),成立项已修:
probeZmxVersion裸 catch + 丢弃 stderr,把任何失败都说成「zmx 二进制不在 PATH 上」。但zmx version会触碰 socket dir,ZMX_DIR/XDG_RUNTIME_DIR只读或不可创建时非零退出(实测error: ReadOnlyFileSystem)—— daemon 跑在 Linux、systemd--user未开 lingering 时正好命中,用户会照着提示反复重装 zmx。本仓库ensure-tmux.ts的childFailureReason早已固化「只有 ENOENT 才算不存在」的约定,zmx 探针绕过了它,现按同一套分支补齐并附上生效的 socket dir 来源。waitForTailClient用zmx list的clients=差值判断自己的 tail 是否连上,但该计数是聚合值 —— 用户的zmx attach(本集成正在文档里主推)与 botmux 自己每次list/get/history/send的瞬时连接完全等价计数(实测起 1 个 tail →clients=1,2 个 → 2,杀掉 → 0)。用户在这 3 秒窗口内 detach 会让净增量为 0,一个健康会话直接恢复失败。改为clients >= 1,把错误方向反过来:残留的假阳性代价很小且自愈(tail 只是唤醒信号,权威屏幕来自不依赖 tail 的 history 轮询,且scheduleTailRecovery会重连)。新增回归用例已确认在旧差值语义下失败、改后通过。另外补文档:受管会话固定跑在 120×24。
createFreshSession用非 TTY 客户端建会话,落到 ZMXgetTerminalSize的兜底值;而 ZMX 没有提供任何「不当 leader 也能 resize」的接口,所以resize()是结构性 no-op,不会随上游修 send 而变得可去掉 —— 0.7.0 让 send 不再抢 leader,恰恰使它更不可能顺带 resize。界面
Dashboard 的 Bot 配置页可显式选择 ZMX,并直接说明
tail + history + send契约与无 Web TUI 边界。Review
通过 bot 链路邀请 Claude 做了三轮对抗性 review:
最终小 delta
a9711a28..4db5ac23进一步关闭了 busy-pattern idle 绕过 ZMX settle、可能从陈旧快照提前定稿的路径;Claude 逐行复核确认非 ZMX 后端保持原行为。其余非阻断边缘项按退出窄窗、维护路径 teardown 可见性、probe 鲁棒性/性能分组,合入后另行跟进。