diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a03b2edf4..4df998b4c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,4 +27,5 @@ jobs: - run: npm install -g pnpm@9.5.0 - run: pnpm install --frozen-lockfile + - run: pnpm test - run: pnpm build diff --git a/docs-site/docs/en/bots-json.md b/docs-site/docs/en/bots-json.md index a14320b63..fc4669457 100644 --- a/docs-site/docs/en/bots-json.md +++ b/docs-site/docs/en/bots-json.md @@ -134,6 +134,8 @@ You can also add it to the corresponding bot entry directly (manual `bots.json` | `sandboxReadonlyPaths` | Extra existing paths mounted read-only inside the sandbox, useful for shared source snapshots, reference repos, or generated docs the bot should inspect but not modify | | `sandboxNetwork` | Network policy for sandboxed sessions. Omitted / `true` keeps current network and proxy access; `false` adds `--unshare-net` and blocks normal network egress | +> ZMX cannot enforce the file sandbox or effective read isolation, so configurations that enable those boundaries fail closed; see [ZMX backend boundaries](/en/zmx#unsupported-combinations). + ## Cards and terminal | Field | Description | diff --git a/docs-site/docs/en/cli-commands.md b/docs-site/docs/en/cli-commands.md index a5665d468..4c70a5a95 100644 --- a/docs-site/docs/en/cli-commands.md +++ b/docs-site/docs/en/cli-commands.md @@ -11,7 +11,7 @@ Manage the daemon and sessions from the terminal. | `botmux logs [--lines N]` | View logs | | `botmux status` | View daemon status | | `botmux upgrade` | Upgrade to the latest version | -| `botmux list` (alias `ls`) | List all active sessions | +| `botmux list` (alias `ls`) | Interactively list active sessions; select a managed tmux / ZMX session and press Enter to attach (use `--plain` in scripts) | | `botmux delete ` (aliases `del`/`rm`) | Close the specified session, with ID prefix matching | | `botmux delete all` | Close all active sessions | | `botmux delete stopped` | Clean up zombie sessions whose processes have exited | diff --git a/docs-site/docs/en/dashboard.md b/docs-site/docs/en/dashboard.md index 52d43ce5e..2bf9ea3f9 100644 --- a/docs-site/docs/en/dashboard.md +++ b/docs-site/docs/en/dashboard.md @@ -22,6 +22,30 @@ botmux dashboard > **Two things live outside the Dashboard**: a v3 workflow's **humanGate approve / reject** happens on a **Lark approval card** (not clicked in the Dashboard); triggering a workflow with parameters currently goes through the **connector (Webhook)** path (see [Connectors](/en/webhook)) — there is no "Workflow Catalog + parameterized trigger" page in the Dashboard. The Dashboard's Workflows panel focuses on observation and cancel. +## External read-only queries + +The Dashboard HTTP service exposes two session read surfaces for the board and external observers: + +- `GET /api/sessions`: the current aggregate of active + closed session rows. +- `GET /events`: the Dashboard's external SSE stream. For session events, `session.spawned` carries the full values in `body.session`, while `session.update` carries changes in `body.patch`. Each daemon also has a loopback-only `/api/events` endpoint for Dashboard aggregator IPC; it is not the external URL. + +The following fields are all **optional**. Consumers must handle older sessions/daemons that omit them: + +| Field | Meaning | +|------|---------| +| `backendType` | The effective backend recorded for the latest worker spawn (`pty` / `tmux` / `herdr` / `zellij` / `zmx`), suitable for filtering/display; it may change after a cold resume | +| `backendSessionName` | Present only for managed persistent-backend sessions; currently `bmx-`. PTY, adopted, and some legacy rows omit it. It is deterministic locator metadata and **does not prove that the process/socket is currently live** | +| `titleUpdatedAt` | ISO-8601 timestamp of the last title update | +| `titleSource` | Title-source tag: `initial` / `user` / `agent` / `cli` / `dashboard` / `system`. It is display/debug metadata, **not a trusted identity or audit field** | + +### `publicReadOnly` and the token boundary + +`publicReadOnly` is on by default. While it is enabled, `GET /api/sessions` and `GET /events` are reachable **without a token** on the Dashboard listener, so session names, titles, backends, and the other row metadata must be treated as public to that network. + +- Every POST / PATCH / DELETE mutation, every GET outside the read-only allow-list, and every raw PTY / diagnostic log still requires the current token issued by `botmux dashboard`. The allow-list is fail-closed: a newly added GET endpoint does not become public merely because public read-only mode is enabled. +- Each `botmux dashboard` invocation rotates the token and invalidates the previous link. The token is application-layer Dashboard access, not a replacement for host firewall, VPN, or reverse-proxy authentication. +- If tokenless observation is unnecessary, turn off **Public read-only** under Dashboard Settings. You can also start with `BOTMUX_DASHBOARD_PUBLIC_READONLY=false`; once the setting has been saved in the UI, the persisted value in `~/.botmux/config.json` takes precedence over the environment variable. + ## Deployment details The dashboard runs as a separate pm2 process `botmux-dashboard`, starting and stopping together with the daemon. Each daemon exposes an internal IPC on `127.0.0.1` (local only), and the dashboard process acts as a reverse proxy + HMAC auth: the secret file `~/.botmux/.dashboard-secret` (mode 0600) is the internal daemon↔dashboard signing key and is **never sent down to the browser** (the browser side uses the rotating login token above). diff --git a/docs-site/docs/en/env.md b/docs-site/docs/en/env.md index 7dd8c531d..d30b9624c 100644 --- a/docs-site/docs/en/env.md +++ b/docs-site/docs/en/env.md @@ -32,6 +32,7 @@ Most configuration goes through `bots.json` / the dashboard — you **usually do | `BOTMUX_PUBLIC_URL` | _(unset)_ | Self-hosted reverse-proxy base (`scheme://host[:port]`). Set it when you don't use the central platform but front the dashboard with your own nginx etc. on a single public/intranet domain; dashboard and card terminal links then emit `/…` and `/s/` through the dashboard front door, with no per-bot port. Unset falls back to the local `host:port`. Must be written into `~/.botmux/.env` (a restart launched from inside a session reads only the file, it does not inherit the shell) | | `BOTMUX_DAEMON_IPC_BASE_PORT` | `7892` | Each daemon's IPC port = base + botIndex | | `BOTMUX_WORKFLOW_RUNS_DIR` | `~/.botmux/workflow-runs` | Workflow run storage directory | +| `BOTMUX_DASHBOARD_PUBLIC_READONLY` | `true` | Allow tokenless access to the Dashboard's allow-listed read-only APIs / SSE. Once this switch has been saved in Dashboard Settings, the value persisted in `~/.botmux/config.json` takes precedence over this environment variable | ## File locations diff --git a/docs-site/docs/en/web-terminal.md b/docs-site/docs/en/web-terminal.md index 882cec26d..133bf4246 100644 --- a/docs-site/docs/en/web-terminal.md +++ b/docs-site/docs/en/web-terminal.md @@ -1,6 +1,6 @@ # Web Terminal (interactive) -Every session comes with an xterm.js-based Web Terminal, at an address like `http://:`. +Every session except a ZMX-backed one comes with an xterm.js-based Web Terminal, at an address like `http://:`; for ZMX, use `botmux list` or `zmx attach` to enter a local terminal and see the [ZMX backend guide](/en/zmx). ![Web Terminal](https://magic-builder.tos-cn-beijing.volces.com/uploads/1780033301701_web_terminal.gif) diff --git a/docs-site/docs/en/zmx.md b/docs-site/docs/en/zmx.md new file mode 100644 index 000000000..f1eb1151b --- /dev/null +++ b/docs-site/docs/en/zmx.md @@ -0,0 +1,101 @@ +# ZMX Session Backend + +[ZMX](https://github.com/neurosnap/zmx) is an optional persistent-session backend for botmux. It is intended for macOS and Linux hosts that want a lightweight session daemon to keep the CLI alive, with a native local attach available whenever the complete terminal experience is needed. + +ZMX is an **explicit opt-in** backend. botmux does not install ZMX, and merely finding it on `PATH` never makes botmux select it automatically. + +## Install and probe + +botmux requires **zmx >= 0.7.0**. That floor is the fix for upstream [issue #201](https://github.com/neurosnap/zmx/issues/201) — commit [`8ba312d7` *fix(send): preserve client leadership*](https://github.com/neurosnap/zmx/commit/8ba312d7), shipped in [v0.7.0](https://github.com/neurosnap/zmx/releases/tag/v0.7.0) (2026-07-23): `zmx send` now uses a dedicated `.Send` IPC tag that only queues input into the PTY buffer, without claiming the leader or rewriting terminal size. ZMX officially supports macOS and Linux. + +```bash +# Homebrew (macOS / Linuxbrew) +brew install neurosnap/tap/zmx + +# or mise +mise use -g github:neurosnap/zmx@latest + +# Verify the daemon user's PATH and control plane +zmx version +zmx list +``` + +On other hosts, download the prebuilt binary for your architecture from the [official ZMX installation instructions](https://github.com/neurosnap/zmx#install), then put `zmx` on the `PATH` of the same system user that runs the botmux daemon. + +Before creating a new ZMX-backed session, botmux checks the executable, version, and `zmx list` control plane. Any failure **fails closed** with an actionable session error; botmux never silently falls back to PTY. + +> **⚠️ Upgrading from 0.6**: replacing the `zmx` binary on disk does not replace per-session daemons that are already running; after upgrading to 0.7.0+, manually stop and recreate every session launched by 0.6, then restart botmux. botmux performs **no automatic cold migration**, and `botmux restart` alone is insufficient. ZMX's IPC `Tag` enum is non-exhaustive (unknown tags fall through the `_` arm and are ignored), so a 0.6 daemon **discards** the new `.Send` tag outright while `zmx send` still exits 0 — the command reports success and the input never arrives. + +For contributors, the default `pnpm test` command runs mocked/pure unit tests and **does not require ZMX to be installed**. Coverage that launches a real `zmx` binary lives in `*.e2e.ts`, runs only when E2E is requested explicitly, and skips automatically when ZMX is unavailable—the same pattern already used by the tmux and Herdr E2E suites. + +## Enable ZMX + +Prefer enabling it only for the bots that need it in `~/.botmux/bots.json`: + +```json +{ + "name": "codex-zmx", + "cliId": "codex", + "backendType": "zmx" +} +``` + +To make ZMX the deployment-wide default backend, set this in `~/.botmux/.env` instead: + +```bash +BACKEND_TYPE=zmx +``` + +Run `botmux restart` after editing. A per-bot `backendType` overrides the deployment default. + +## Runtime model + +botmux assigns each managed session the deterministic name `bmx-`. The ZMX daemon owns the CLI's PTY. Instead of keeping a fake attach leader alive, botmux uses three leaderless interfaces: + +- `zmx tail` is used only as a low-latency change/liveness signal. botmux drains stdout but never gives its payload bytes to the worker: the current upstream `zmx tail` ANSI filter deletes multi-byte UTF-8, so Chinese and emoji cannot rely on this stream. +- `zmx send` queues raw input bytes into the PTY without attaching, changing the leader, or resizing it. +- `zmx history` is the sole authoritative plain-text screen source. Tail/send wake asynchronous capture immediately; a staggered 250ms hot poll and at-most roughly 1.5s cold safety poll also catches pure Unicode that produces no tail event. Before idle completion, botmux forces one post-call capture (bounded retries, then the last successful snapshot). + +A new session briefly uses one non-interactive client for creation only; output and input then use the interfaces above. A local `zmx attach` can therefore become the real leader and let the local terminal control size and the full TUI. Input sent from Lark through botmux does not steal that leadership. + +| Event | ZMX session / CLI | botmux behavior | +|------|-------------------|-----------------| +| `botmux restart` | Stays alive | Rebuilds workers and `tail` observers in staggered batches without restarting the CLI | +| Backing session is missing during restore | Original process is gone | Retains the active/transcript record instead of destroying it as a zombie; lazily resumes on the next message | +| Worker / `tail` observer disconnects | Stays alive | Rebuilds the observer after confirming that the ZMX session still exists | +| `/close` or close button | Destroyed / terminated | Force-kills the backing session instead of leaving an unmanaged process behind | + +## Display, input, and terminal-size boundary + +This integration deliberately uses eventually consistent plain-text screen semantics. Its persistent-session lifecycle is close to tmux, but it is not a complete terminal mirror: + +- ZMX exposes an **eventually consistent plain-text screen** from `history`; it does not preserve color, cursor state, OSC, or the alternate screen. Capture is single-flight per session and a dirty latch forces one follow-up when activity arrives during a capture. +- The ZMX backend does not provide botmux's interactive Web TUI or resize the backing PTY. Use local `zmx attach` when you need raw ANSI, a fullscreen TUI, or terminal-size negotiation. +- A local attach leader controls terminal size. Without one, the ZMX session keeps its existing size; botmux's `send` does not change it. ZMX exposes no leaderless resize primitive, so botmux's `resize()` is a deliberate no-op. botmux creates sessions from a non-TTY client, which lands on ZMX's `getTerminalSize` fallback, so **managed sessions run at a fixed 120×24** and the CLI wraps its TUI at 120 columns — the width you see in Lark. Use a local `zmx attach` to take leadership if you need a different size. +- Upstream `send` currently provides no delivery ACK or backpressure. botmux sends 1 KiB chunks and rejects any single backend input over 64 KiB before writing a prefix. It never retries an ambiguous result automatically because the input may already be queued and a retry could duplicate it; the backend reports failure to its caller instead of hiding a retry internally. +- `zmx history` can restore only the bounded scrollback still retained by ZMX / ghostty; older output is evicted once the upstream scrollback budget is exceeded. It reconstructs the eventually consistent observable state rather than a lossless transcript or terminal recording; transient output after the daemon has exited cannot be recovered. Workflow raw PTY replay logs therefore do not have tmux's lossless semantics. + +## Enter the same session locally + +```bash +botmux list +``` + +`botmux list` shows the effective backend for every session. Select a ZMX row and press Enter to attach safely to its existing `bmx-*` session. If the backing session has disappeared, the command refuses to create an empty shell that could masquerade as the original CLI. + +When the daemon runs on macOS, you can also explicitly enable **Native CLI opening** under Dashboard Settings and keep **Attach current session** mode selected. The Lark card's **Open CLI** button will then attach iTerm2 / Terminal to the same ZMX session instead of starting a second CLI. This feature is off by default and requires operate permission. + +## Unsupported combinations + +- **Adopt**: ZMX is not scanned or accepted as a `/adopt` source. Use the supported tmux / Herdr / Zellij path when adopting an existing external session. +- **Runners that depend on hidden OSC completion events**: `codex-app`, `mira`, and `mir` lose their final/thread events in plain history, so these combinations fail closed at startup. Use tmux / PTY for those CLIs. +- **File sandbox and read isolation**: the child PTY belongs to the ZMX session daemon, so botmux cannot currently apply its bwrap / Seatbelt filesystem boundary. Combining `backendType: "zmx"` with `sandbox: true`, global `BOTMUX_SANDBOX=1`, or the standalone effective `readIsolation: true` mode on macOS therefore **fails closed**; the worker posts an actionable session notification before refusing to start. On Linux, the bare legacy `readIsolation` flag is a no-op under the unified worker semantics: it neither provides isolation nor incorrectly gates ZMX. When isolation is required, enable the sandbox and switch to tmux / PTY; otherwise explicitly disable the corresponding isolation setting. + +## Troubleshooting + +1. Run `zmx version` and `zmx list` as the same user that runs the daemon to verify version 0.7.0 or newer, `PATH`, and the socket directory. +2. After an upgrade from 0.6, manually stop and recreate old session daemons; botmux does not cold-migrate them automatically, and restarting botmux alone does not replace them. Check this first if `zmx send` succeeds but the CLI receives nothing. +3. If you set `ZMX_DIR`, make sure the daemon and the shell used for local attach share the same value. botmux preserves `ZMX_DIR`, but strips inherited `ZMX_SESSION` / `ZMX_SESSION_PREFIX` so nested sessions and prefixes cannot rewrite the deterministic `bmx-*` target. +4. Inspect `botmux logs`. When a probe is inconclusive, botmux conservatively refuses to start/recreate a session so it cannot launch a duplicate CLI or delete a still-live one. + +Dashboard session queries can report the ZMX backend and deterministic session name, but those fields are not liveness checks. See [Dashboard external read-only queries and security boundary](/en/dashboard#external-read-only-queries). diff --git a/docs-site/docs/zh/bots-json.md b/docs-site/docs/zh/bots-json.md index c8e44abc2..8e146a38a 100644 --- a/docs-site/docs/zh/bots-json.md +++ b/docs-site/docs/zh/bots-json.md @@ -134,6 +134,8 @@ | `sandboxReadonlyPaths` | 在沙盒内额外只读挂载的已存在路径,适合共享源码快照、参考仓库或生成文档等只允许查看、不允许修改的输入 | | `sandboxNetwork` | 沙盒会话的网络策略。缺省 / `true` 保留当前网络和代理访问;`false` 添加 `--unshare-net`,阻断普通网络出口 | +> ZMX 无法执行文件沙盒或实际生效的读隔离,开启这些边界的配置组合会 fail closed,详见 [ZMX 后端边界](/zmx#不支持的组合)。 + ## 卡片与终端 | 字段 | 说明 | diff --git a/docs-site/docs/zh/cli-commands.md b/docs-site/docs/zh/cli-commands.md index 8615591f5..da1d7de41 100644 --- a/docs-site/docs/zh/cli-commands.md +++ b/docs-site/docs/zh/cli-commands.md @@ -11,7 +11,7 @@ | `botmux logs [--lines N]` | 查看日志 | | `botmux status` | 查看 daemon 状态 | | `botmux upgrade` | 升级到最新版本 | -| `botmux list` (别名 `ls`) | 列出所有活跃会话 | +| `botmux list` (别名 `ls`) | 交互式列出活跃会话;选中受管 tmux / ZMX 会话后按 Enter 可 attach(脚本使用 `--plain`) | | `botmux delete ` (别名 `del`/`rm`) | 关闭指定会话,支持 ID 前缀匹配 | | `botmux delete all` | 关闭所有活跃会话 | | `botmux delete stopped` | 清理进程已退出的僵尸会话 | diff --git a/docs-site/docs/zh/dashboard.md b/docs-site/docs/zh/dashboard.md index e2ece1f8c..008644c1b 100644 --- a/docs-site/docs/zh/dashboard.md +++ b/docs-site/docs/zh/dashboard.md @@ -22,6 +22,30 @@ botmux dashboard > **两件事在 Dashboard 之外**:v3 workflow 的 **humanGate 批准 / 拒绝** 走**飞书审批卡**(不在 Dashboard 上点);带参触发 workflow 目前是**接入点(Webhook)** 那条路径(见 [接入点](/webhook)),Dashboard 没有「Workflow Catalog 带参触发」页。Dashboard 的 Workflows 面板专注观测与 cancel。 +## 对外只读查询 + +Dashboard HTTP 服务提供两个可供看板或外部观测端消费的会话读接口: + +- `GET /api/sessions`:当前聚合的 active + closed session rows。 +- `GET /events`:Dashboard 对外 SSE 流,其中 `session.spawned` 的 `body.session` 和 `session.update` 的 `body.patch` 会携带对应的完整值/变更值。每个 daemon 内部还有只绑定 loopback 的 `/api/events`,这是 Dashboard 聚合器的 IPC,不是对外地址。 + +会话输出中的下列字段都是**可选字段**,消费者必须兼容旧会话/旧 daemon 不返回它们: + +| 字段 | 语义 | +|------|------| +| `backendType` | 最近一次 worker spawn 时记录的有效后端(`pty` / `tmux` / `herdr` / `zellij` / `zmx`),用于过滤/展示;cold resume 后可能随配置切换 | +| `backendSessionName` | 仅受管的持久后端会话才有,当前规则为 `bmx-`;PTY、adopt 会话和部分 legacy row 没有该字段。它是确定性定位信息,**不代表对应进程/socket 当前存活** | +| `titleUpdatedAt` | 标题最后更新的 ISO-8601 时间字符串 | +| `titleSource` | 标题来源标签:`initial` / `user` / `agent` / `cli` / `dashboard` / `system`。仅供展示和调试,**不是可信的身份/审计字段** | + +### `publicReadOnly` 与 token 边界 + +`publicReadOnly` 默认开启。开启时,`GET /api/sessions` 和 `GET /events` 在 Dashboard 监听地址上可以**无 token** 访问,因此会话名称、标题、后端和 row 中的其它元数据都应按可公开信息对待。 + +- 全部 POST / PATCH / DELETE 写操作、不在只读白名单中的 GET,以及原始 PTY / 诊断日志,始终需要 `botmux dashboard` 生成的当前 token。白名单是 fail-closed 的:新增 GET 不会因公开只读开启就自动暴露。 +- 每次运行 `botmux dashboard` 都会轮换 token,之前的链接失效。token 只提供 Dashboard 应用层访问权,不代替主机防火墙、VPN 或反向代理鉴权。 +- 不需要无 token 观测时,在 Dashboard 「设置」中关闭「公开只读」。也可先设 `BOTMUX_DASHBOARD_PUBLIC_READONLY=false`;但设置页一旦保存过该开关,`~/.botmux/config.json` 的持久值会优先于环境变量。 + ## 部署细节 dashboard 走单独 pm2 进程 `botmux-dashboard`,跟 daemon 一起起停。每个 daemon 在 `127.0.0.1` 暴露内部 IPC(仅本机),dashboard 进程做反向代理 + HMAC 鉴权:密钥文件 `~/.botmux/.dashboard-secret`(mode 0600),是 daemon↔dashboard 的内部签名密钥,**不下发给浏览器**(浏览器侧走上面的轮换登录 token)。 diff --git a/docs-site/docs/zh/env.md b/docs-site/docs/zh/env.md index 40c94a1b3..04c3ce75b 100644 --- a/docs-site/docs/zh/env.md +++ b/docs-site/docs/zh/env.md @@ -32,6 +32,7 @@ | `BOTMUX_PUBLIC_URL` | _(未设置)_ | 自建反代对外基址(`scheme://host[:port]`)。没接中心平台、但自己用 nginx 等把 dashboard 反代到单一公网/内网域名时设它,dashboard / 卡片终端链接改吐 `<基址>/…`、`<基址>/s/`,走 dashboard 前门、无需 per-bot 端口。未设回退本地 `host:port`。必须写在 `~/.botmux/.env`(会话内发起的重启只读文件、不继承 shell) | | `BOTMUX_DAEMON_IPC_BASE_PORT` | `7892` | 每个 daemon 的 IPC 端口 = base + botIndex | | `BOTMUX_WORKFLOW_RUNS_DIR` | `~/.botmux/workflow-runs` | workflow run 存储目录 | +| `BOTMUX_DASHBOARD_PUBLIC_READONLY` | `true` | 是否允许无 token 访问 Dashboard 白名单只读 API / SSE;一旦在 Dashboard 设置页保存过该开关,`~/.botmux/config.json` 中的值优先于本环境变量 | ## 文件位置 diff --git a/docs-site/docs/zh/web-terminal.md b/docs-site/docs/zh/web-terminal.md index 0b63d0c9c..ecf8e3a74 100644 --- a/docs-site/docs/zh/web-terminal.md +++ b/docs-site/docs/zh/web-terminal.md @@ -1,6 +1,6 @@ # Web 终端(可交互) -每个会话都带一个基于 xterm.js 的 Web 终端,地址形如 `http://:<端口>`。 +除 ZMX 后端外,每个会话都带一个基于 xterm.js 的 Web 终端,地址形如 `http://:<端口>`;ZMX 请通过 `botmux list` 或 `zmx attach` 进入本机终端,详见 [ZMX 后端](/zmx)。 ![Web 终端](https://magic-builder.tos-cn-beijing.volces.com/uploads/1780033301701_web_terminal.gif) diff --git a/docs-site/docs/zh/zmx.md b/docs-site/docs/zh/zmx.md new file mode 100644 index 000000000..8174b322f --- /dev/null +++ b/docs-site/docs/zh/zmx.md @@ -0,0 +1,101 @@ +# ZMX 会话后端 + +[ZMX](https://github.com/neurosnap/zmx) 是 botmux 的一个可选持久会话后端。它适合希望用轻量会话 daemon 保住 CLI,并在需要完整终端体验时从本机原生 attach 的 macOS / Linux 主机。 + +ZMX 是**显式 opt-in** 后端:botmux 不会自动安装 ZMX,也不会因为它已在 `PATH` 中就自动选用。 + +## 安装与探测 + +botmux 要求 **zmx >= 0.7.0**。这个版本下限对应上游 [issue #201](https://github.com/neurosnap/zmx/issues/201) 的修复 —— commit [`8ba312d7` *fix(send): preserve client leadership*](https://github.com/neurosnap/zmx/commit/8ba312d7),随 [v0.7.0](https://github.com/neurosnap/zmx/releases/tag/v0.7.0)(2026-07-23)发布:`zmx send` 改用独立的 `.Send` IPC tag,只把输入排进 PTY 队列,不再抢占 leader、也不再改写终端尺寸。ZMX 官方支持 macOS 和 Linux。 + +```bash +# Homebrew(macOS / Linuxbrew) +brew install neurosnap/tap/zmx + +# 或 mise +mise use -g github:neurosnap/zmx@latest + +# 验证 daemon 用户的 PATH 与控制面 +zmx version +zmx list +``` + +其它环境可从 [ZMX 官方安装说明](https://github.com/neurosnap/zmx#install) 下载对应架构的预编译二进制,并将 `zmx` 放进运行 botmux daemon 的同一系统用户的 `PATH`。 + +每次启动新 ZMX 会话前,botmux 都会校验可执行文件、版本和 `zmx list` 控制面。任意一项失败都会 **fail closed** 并向会话返回可操作的错误;绝不会悄悄降级到 PTY。 + +> **⚠️ 从 0.6 升级**:替换磁盘上的 `zmx` 二进制不会替换已经运行的逐会话 daemon;升级到 0.7.0+ 后,请手动关闭并重新创建所有 0.6 会话,再重启 botmux。botmux **不会自动冷迁移**旧会话,只运行 `botmux restart` 也不够。ZMX 的 IPC `Tag` 枚举是 non-exhaustive 的(未知 tag 走 `_` 分支被忽略),所以 0.6 daemon 收到新的 `.Send` tag 会**直接丢弃**,而 `zmx send` 仍然退出码 0 —— 表现为命令成功但输入从未送达。 + +开发时,默认 `pnpm test` 只跑 mock / 纯函数单测,**不要求本机安装 ZMX**。会启动真实 `zmx` 的覆盖位于 `*.e2e.ts`,只在显式运行 E2E 时参与,并在 ZMX 不可用时自动跳过;这与仓库现有 tmux / Herdr E2E 的处理方式一致。 + +## 开启 ZMX + +推荐只为需要的 bot 在 `~/.botmux/bots.json` 中配置: + +```json +{ + "name": "codex-zmx", + "cliId": "codex", + "backendType": "zmx" +} +``` + +若要让本部署的默认后端都改为 ZMX,也可在 `~/.botmux/.env` 中设置: + +```bash +BACKEND_TYPE=zmx +``` + +修改后运行 `botmux restart`。单 bot 的 `backendType` 会覆盖部署默认值。 + +## 运行模型 + +botmux 为每个受管会话使用确定性名称 `bmx-`。ZMX daemon 持有 CLI 的 PTY;botmux 不再常驻一个假的 attach leader,而是使用三个无 leader 的接口: + +- `zmx tail`:只作为低延迟的变化 / 存活信号;botmux 会排空其 stdout,但**不会把正文交给 worker**。当前上游 `zmx tail` 的 ANSI 过滤会删除 UTF-8 多字节,中文 / emoji 不能以这里的字节为准。 +- `zmx send`:把原始输入字节排进 PTY,不 attach、不切换 leader、也不 resize。 +- `zmx history`:唯一权威的纯文本屏幕源。tail / send 会立即唤醒异步采集;即使 tail 对纯中文完全无事件,也有热态 250ms、稳态最迟约 1.5s 的错峰安全轮询。每次 idle 定稿前还会强制补拉一轮(失败时有界重试后使用最后成功快照)。 + +新会话只在创建时短暂启动一次非交互客户端,随后输出和输入都走上面的接口。这样本地用户执行 `zmx attach` 时可以成为真正的 leader,由本地终端控制尺寸和完整 TUI;botmux 发送飞书输入不会把 leader 抢走。 + +| 事件 | ZMX session / CLI | botmux 行为 | +|------|-------------------|-------------| +| `botmux restart` | 保持存活 | 恢复时分批重建 worker 与 `tail` 观察者,不重起 CLI | +| 恢复时 backing session 已不存在 | 原进程已不在 | 保留 active / transcript 记录,不当作僵尸销毁;下一条消息时 lazy resume | +| worker / `tail` 观察者断开 | 保持存活 | 确认 ZMX session 仍在后重建观察者 | +| `/close` 或关闭按钮 | 销毁 / 终止 | 执行强制 kill,不会只留一个脱管会话 | + +## 显示、输入与终端尺寸边界 + +这条集成刻意选择最终一致的纯文本屏幕语义,行为更接近 tmux 的持久会话生命周期,但不是 tmux 的完整终端镜像: + +- ZMX 向 botmux 提供的是 `history` 的**最终一致纯文本屏幕**,不保留颜色、光标状态、OSC 或 alternate screen。采集单会话 single-flight,并在采集中出现新活动时强制补拉,避免并发 history 风暴或漏掉飞行中的尾段。 +- ZMX 后端不提供 botmux 的交互式 Web TUI,也不向 backing PTY 发送 resize。需要 raw ANSI、全屏 TUI 或尺寸协商时,请使用本机 `zmx attach`。 +- 本机 attach 的 leader 负责终端尺寸;没有本机 leader 时沿用 ZMX 会话的既有尺寸。botmux 的 `send` 不会改变它。ZMX 没有提供任何「不当 leader 也能 resize」的接口,所以 botmux 的 `resize()` 是刻意的 no-op。botmux 建会话时用的是非 TTY 客户端,落到 ZMX `getTerminalSize` 的兜底值,因此**受管会话固定跑在 120×24**,CLI 的 TUI 按 120 列折行 —— 这也是飞书侧看到的宽度。需要别的尺寸时用本机 `zmx attach` 接管 leader。 +- 上游 `send` 目前没有投递 ACK / backpressure。botmux 以 1 KiB 分片发送,并在写入任何前缀前拒绝超过 64 KiB 的单次后端输入;结果不确定时不会自动重试,以免把已经入队的输入重复提交。后端会向调用方返回失败,而不是在内部隐藏重试。 +- `zmx history` 只能恢复 ZMX / ghostty 当时仍保留的有界 scrollback;超过上游滚动缓冲预算后,较早输出会被淘汰。它构成最终一致的当前可观察状态,不是无损 transcript 或终端录屏;进程退出后才出现且未被最后一次采集命中的瞬态输出也无法补回。Workflow 的 raw PTY replay log 因此不具备 tmux 的无损语义。 + +## 在本机进入同一会话 + +```bash +botmux list +``` + +`botmux list` 会显示每条会话的实际后端。选中 ZMX 会话并按 Enter 会安全 attach 到现有的 `bmx-*` 会话;如果 backing session 已消失,命令会拒绝创建一个空 shell 来冒充原 CLI。 + +当 daemon 运行在 macOS 上时,还可在 Dashboard 的「设置」中显式开启「本机 CLI 直开」,并保持「附加当前会话」模式。此时飞书卡片的「打开 CLI」按钮会在 iTerm2 / Terminal 中 attach 到同一个 ZMX 会话,而不是启动第二个 CLI。该功能默认关闭,且只允许有操作权限的用户触发。 + +## 不支持的组合 + +- **Adopt**:ZMX 不是 `/adopt` 的扫描/接入源;需要 adopt 现有外部会话时,使用 tmux / Herdr / Zellij 支持的路径。 +- **依赖隐藏 OSC 完成事件的 runner**:`codex-app`、`mira`、`mir` 的 final / thread 事件会被纯文本 history 消费掉,因此该组合启动时 fail closed;请为这些 CLI 使用 tmux / PTY。 +- **文件沙盒与读隔离**:ZMX 子 PTY 属于会话 daemon,当前无法套用 botmux 的 bwrap / Seatbelt 文件边界。因此 `sandbox: true`、全局 `BOTMUX_SANDBOX=1`,或 macOS 上独立生效的 `readIsolation: true` 与 `backendType: "zmx"` 同时出现时会 **fail closed**;worker 会先向会话返回可操作提示,再拒绝启动。Linux 上单独设置旧 `readIsolation` 标志按统一 worker 语义是 no-op,不代表会话已隔离,也不会误拦 ZMX。需要真实隔离时,请启用 sandbox 并改用 tmux / PTY;否则明确关闭相应隔离配置。 + +## 排错 + +1. 以运行 daemon 的同一用户执行 `zmx version` 和 `zmx list`,确认版本至少为 0.7.0、`PATH` 和 socket 目录可用。 +2. 如果刚从 0.6 升级,手动关闭并重新创建旧会话 daemon;botmux 不做自动冷迁移,仅重启 botmux 不会替换它们。出现 `zmx send` 返回成功但 CLI 没收到输入,优先检查这一项。 +3. 如果显式设了 `ZMX_DIR`,确保 daemon 和本地 attach 的 shell 使用同一值。botmux 会保留 `ZMX_DIR`,但会清掉继承的 `ZMX_SESSION` / `ZMX_SESSION_PREFIX`,避免嵌套会话和名称前缀改写 `bmx-*` 目标。 +4. 查看 `botmux logs`。探测结果不确定时,botmux 会保守拒绝启动/重建,避免重复启动 CLI 或误删仍存活的会话。 + +Dashboard 的会话查询可返回 ZMX 后端与确定性会话名,但这些字段不等于存活检查。见 [Dashboard 对外只读查询与安全边界](/dashboard#对外只读查询)。 diff --git a/docs-site/rspress.config.ts b/docs-site/rspress.config.ts index d31f6bad1..b4d4f85e6 100644 --- a/docs-site/rspress.config.ts +++ b/docs-site/rspress.config.ts @@ -38,6 +38,7 @@ const zhSidebar = [ { text: '本地白板', link: '/whiteboard' }, { text: '角色与团队', link: '/roles' }, { text: 'tmux 会话常驻', link: '/tmux' }, + { text: 'ZMX 会话后端', link: '/zmx' }, { text: '会话接入 Adopt', link: '/adopt' }, { text: '会话接力 Relay', link: '/relay' }, { text: '一键建会话群', link: '/group' }, @@ -112,6 +113,7 @@ const enSidebar = [ { text: 'Local Whiteboard', link: '/en/whiteboard' }, { text: 'Roles & Teams', link: '/en/roles' }, { text: 'tmux Session Persistence', link: '/en/tmux' }, + { text: 'ZMX Session Backend', link: '/en/zmx' }, { text: 'Adopt a Session', link: '/en/adopt' }, { text: 'Relay a Session', link: '/en/relay' }, { text: 'One-Click Session Groups', link: '/en/group' }, diff --git a/docs/assets/pr-458/zmx-backend-dashboard.jpg b/docs/assets/pr-458/zmx-backend-dashboard.jpg new file mode 100644 index 000000000..51b17fe3f Binary files /dev/null and b/docs/assets/pr-458/zmx-backend-dashboard.jpg differ diff --git a/src/adapters/backend/capabilities.ts b/src/adapters/backend/capabilities.ts new file mode 100644 index 000000000..6efd68a10 --- /dev/null +++ b/src/adapters/backend/capabilities.ts @@ -0,0 +1,28 @@ +import type { BackendType } from './types.js'; +import type { CliId } from '../cli/types.js'; + +/** + * Whether botmux can expose its xterm.js Web Terminal for this backend. + * + * ZMX exposes only its authoritative plain-text `history` screen to botmux; + * `tail` is merely a wakeup signal. That cannot reconstruct the raw ANSI TUI. + * Native `zmx attach` remains available through the local-terminal entrypoint. + */ +export function backendSupportsWebTerminal(backendType: BackendType): boolean { + return backendType !== 'zmx'; +} + +/** + * Runner CLIs emit their authoritative final/thread events as hidden OSC. + * ZMX history is plain terminal state after control-sequence consumption, so + * those events cannot be recovered through its screen-only transport. + */ +export function backendCliCompatibilityError( + backendType: BackendType, + cliId: CliId, +): string | undefined { + if (backendType === 'zmx' && ['codex-app', 'mira', 'mir'].includes(cliId)) { + return `backend "zmx" cannot carry ${cliId}'s hidden OSC final/thread events; use tmux/pty`; + } + return undefined; +} diff --git a/src/adapters/backend/critical-control-key.ts b/src/adapters/backend/critical-control-key.ts new file mode 100644 index 000000000..8309b480d --- /dev/null +++ b/src/adapters/backend/critical-control-key.ts @@ -0,0 +1,34 @@ +/** + * Minimum spacing between botmux-generated Ctrl+C writes. + * + * Oh My Pi treats two Ctrl+C events within 500 ms as an exit gesture. Keep a + * small margin so backend recovery and adapter cleanup cannot accidentally + * terminate the CLI when consecutive transport failures happen quickly. + */ +export const TERMINAL_CANCEL_COOLDOWN_MS = 550; + +export function isCriticalInterruptKey(key: string): boolean { + return key === 'ctrlc' || key === 'esc'; +} + +/** + * Deliver an interrupt-class terminal key with one bounded retry. + * + * Duplicate C-c / Escape is safer than silently claiming a stopped CLI while + * an ambiguous transport keeps it running. Other navigation keys deliberately + * stay outside this helper and retain best-effort semantics. + */ +export async function sendCriticalControlKey( + sendOnce: () => void | boolean, + wait: (ms: number) => Promise = ms => new Promise(resolve => setTimeout(resolve, ms)), +): Promise { + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + if (sendOnce() !== false) return true; + } catch { + // A synchronous transport failure is retryable for interrupt keys only. + } + if (attempt === 0) await wait(100); + } + return false; +} diff --git a/src/adapters/backend/session-backend-selector.ts b/src/adapters/backend/session-backend-selector.ts index 4ea0b5bad..4d6a44c92 100644 --- a/src/adapters/backend/session-backend-selector.ts +++ b/src/adapters/backend/session-backend-selector.ts @@ -4,6 +4,7 @@ import { RiffBackend, type RiffBackendConfig } from './riff-backend.js'; import { TmuxBackend } from './tmux-backend.js'; import { TmuxPipeBackend } from './tmux-pipe-backend.js'; import { ZellijBackend } from './zellij-backend.js'; +import { ZmxBackend } from './zmx-backend.js'; import type { BackendType, PersistentBackendTarget, SessionBackend } from './types.js'; export type BackendGateDecision = @@ -11,7 +12,7 @@ export type BackendGateDecision = | { action: 'gate'; reason: string }; /** - * Hard gate (PTY 退役): a requested *persistent* backend (tmux/herdr/zellij) + * Hard gate (PTY 退役): a requested *persistent* backend (tmux/herdr/zellij/zmx) * that isn't functional on this host no longer silently degrades to raw PTY. * That silent fallback was the root of the "secretly running on PTY, then * hitting all of PTY's problems (no survival across daemon restart, etc.)" @@ -22,12 +23,11 @@ export type BackendGateDecision = * and is always allowed straight through. * * `hasExistingSession` lets an already-running persistent session reattach - * regardless of a transient probe failure (a disposable "can we start a new - * server?" probe is far less authoritative than a live session — see PR#249): + * regardless of a transient functional-probe failure (the known live session + * is more authoritative than a separate capability check — see PR#249): * abandoning it would spawn a duplicate CLI and orphan the real conversation. - * The caller computes it only for backends whose probe is a disposable - * session (tmux, zellij); herdr's probe is a cheap non-destructive - * `herdr --version`, so it passes `hasExistingSession: false`. + * tmux/zellij capability probes use disposable sessions; ZMX checks its + * version and full-list control plane; Herdr uses `herdr --version`. */ export function decideBackendGate(opts: { requested: BackendType; @@ -45,6 +45,8 @@ export function backendGateUserMessage(backend: BackendType, reason: string): st const installHint = backend === 'tmux' ? 'macOS: brew install tmux | Debian/Ubuntu: sudo apt-get install -y tmux | 其它发行版用对应包管理器安装 tmux' + : backend === 'zmx' + ? '需要 zmx >= 0.7.0(send 不再抢占 client leadership)|macOS: brew install neurosnap/tap/zmx | Linux: 装官方 release binary | mise: mise use -g github:neurosnap/zmx@latest' : `请确认 ${backend} 已正确安装并可用`; return [ `⚠️ 本机 ${backend} 不可用,无法启动会话。`, @@ -55,6 +57,38 @@ export function backendGateUserMessage(backend: BackendType, reason: string): st ].join('\n'); } +/** + * ZMX owns the child PTY inside its per-session daemon, outside botmux's + * bwrap/Seatbelt launch wrapper. Until ZMX can enforce the same filesystem + * boundary, a requested sandbox must fail closed instead of silently running + * the CLI with broader access than configured. + */ +export function backendSandboxCompatibilityError(opts: { + backendType: BackendType; + fileSandboxRequested: boolean; + /** Compatibility input for callers that still model standalone read + * isolation. The worker passes false because its unified sandbox request + * already folds in the legacy readIsolation flag on every host. */ + effectiveReadIsolationRequested: boolean; +}): string | undefined { + if ( + opts.backendType === 'zmx' && + (opts.fileSandboxRequested || opts.effectiveReadIsolationRequested) + ) { + return 'backend "zmx" does not support file/read isolation; use tmux/pty or disable sandbox for this bot'; + } + return undefined; +} + +/** Actionable card shown before an incompatible ZMX/isolation launch fails. */ +export function backendSandboxCompatibilityUserMessage(reason: string): string { + return [ + '⚠️ ZMX 当前无法执行 botmux 的文件沙盒或读隔离,已拒绝启动以避免未隔离运行。', + `原因:${reason}`, + '请将该 bot 的 backendType 改为 tmux / pty,或关闭 sandbox(含全局 BOTMUX_SANDBOX)及 legacy readIsolation 后重试。', + ].join('\n'); +} + export interface SelectedSessionBackend { backend: SessionBackend; isTmuxMode: boolean; @@ -78,6 +112,7 @@ export function selectSessionBackend(opts: { /** Migration compatibility for sessions previously placed in a shared user host. */ reuseRecordedHerdrTarget?: boolean; persistentBackendTarget?: PersistentBackendTarget; + hasExistingSession?: boolean; }): SelectedSessionBackend { if (opts.backendType === 'riff') { if (!opts.backendConfig) { @@ -91,6 +126,27 @@ export function selectSessionBackend(opts: { }; } + if (opts.backendType === 'zmx') { + const sessionName = ZmxBackend.sessionName(opts.sessionId); + const reattach = opts.hasExistingSession ?? ZmxBackend.hasSession(sessionName); + return { + backend: new ZmxBackend(sessionName, { + ownsSession: true, + isReattach: reattach, + sessionId: opts.sessionId, + }), + isTmuxMode: false, + // ZMX is observed out-of-band (`zmx tail`) and driven independently + // (`zmx send`), matching the worker's pipe-backend data path rather than + // a bidirectional PTY attach client. + isPipeMode: true, + isZellijMode: false, + persistentSessionName: sessionName, + persistentBackendTarget: { backendType: 'zmx', sessionName }, + isReattach: reattach, + }; + } + if (opts.backendType === 'zellij') { const sessionName = ZellijBackend.sessionName(opts.sessionId); const reattach = ZellijBackend.hasSession(sessionName); diff --git a/src/adapters/backend/types.ts b/src/adapters/backend/types.ts index c709e470c..1702db49e 100644 --- a/src/adapters/backend/types.ts +++ b/src/adapters/backend/types.ts @@ -1,4 +1,4 @@ -export type BackendType = 'pty' | 'tmux' | 'herdr' | 'zellij' | 'riff'; +export type BackendType = 'pty' | 'tmux' | 'herdr' | 'zellij' | 'zmx' | 'riff'; /** * Durable identity of the backing resource owned by one Botmux session. @@ -9,7 +9,7 @@ export type BackendType = 'pty' | 'tmux' | 'herdr' | 'zellij' | 'riff'; * untouched on restore/close paths that run without a live worker. */ export type PersistentBackendTarget = - | { backendType: 'tmux' | 'zellij'; sessionName: string } + | { backendType: 'tmux' | 'zellij' | 'zmx'; sessionName: string } | { backendType: 'herdr'; sessionName: string; agentName?: string }; /** @@ -34,7 +34,7 @@ export interface SpawnOpts { env: Record; /** * Per-bot env (bots.json `env`) to inject into the CLI process ONLY. Kept - * separate from `env` on purpose: the persistent backends (tmux/zellij) must + * separate from `env` on purpose: the persistent backends (tmux/zellij/zmx) must * NOT put these into the shared backing-server global env — they inject them * via the per-pane `/usr/bin/env KEY=VAL` prefix so one bot's provider creds * can't leak into another bot's panes. The pty backend (no shared server) @@ -43,7 +43,7 @@ export interface SpawnOpts { injectEnv?: Record; /** * Per-bot shell override (BotConfig.launchShell). When set, the persistent - * backends (tmux/zellij) launch the CLI under this shell instead of `$SHELL` + * backends (tmux/zellij/zmx) launch the CLI under this shell instead of `$SHELL` * — the escape hatch for a login `$SHELL` whose rcfile `exec`-trampolines into * another shell. Bare name (`zsh`) or absolute path; see resolveUserShell. * Ignored by the pty backend (no shell wrapper). @@ -54,8 +54,28 @@ export interface SpawnOpts { export interface SessionBackend { spawn(bin: string, args: string[], opts: SpawnOpts): void; write(data: string): void; + /** + * Snapshot the backend's text-write failure generation before one logical + * adapter submission. Backends without ambiguous transport writes omit it. + */ + captureAmbiguousSubmissionFence?(): number; + /** + * Best-effort cleanup for a text write that became ambiguous after `fence`. + * The backend owns deduplication against any frame-level recovery it already + * injected. Control/navigation-key failures are deliberately excluded. + */ + cancelAmbiguousSubmission?(fence: number): void; resize(cols: number, rows: number): void; onData(cb: (data: string) => void): void; + /** + * Replace the worker's derived screen state with an authoritative snapshot. + * + * Live-only observers use this after reconnecting: output may have been + * produced while the observer was offline, so replaying only subsequent + * chunks would leave idle detection and cards permanently stale. This is a + * reset/rebase signal, not another incremental PTY chunk. + */ + onScreenResync?(cb: (snapshot: string) => void): void; onExit(cb: (code: number | null, signal: string | null) => void): void; kill(): void; /** Permanently destroy the backing session (e.g. kill tmux session). @@ -64,6 +84,12 @@ export interface SessionBackend { /** PID of the CLI process running inside the backend. */ getChildPid?(): number | null; captureCurrentScreen?(): string; + /** + * Complete one authoritative screen refresh that starts after this call. + * Snapshot-only backends use this as a completion fence before the worker + * declares a turn idle, so a final burst cannot be lost to polling phase. + */ + settleCurrentScreen?(): Promise; captureViewport?(): string; getPaneSize?(): { cols: number; rows: number } | null; /** diff --git a/src/adapters/backend/zmx-backend.ts b/src/adapters/backend/zmx-backend.ts new file mode 100644 index 000000000..5bb4b0729 --- /dev/null +++ b/src/adapters/backend/zmx-backend.ts @@ -0,0 +1,2058 @@ +import { + execFile, + execFileSync, + spawn, + spawnSync, + type ChildProcess, +} from 'node:child_process'; +import { randomBytes } from 'node:crypto'; +import { + chmodSync, + closeSync, + fstatSync, + mkdtempSync, + openSync, + readFileSync, + readSync, + renameSync, + rmSync, + rmdirSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { SessionBackend, SpawnOpts, SessionProbe } from './types.js'; +import { zmxEnv, probeZmxFunctional } from '../../setup/ensure-zmx.js'; +import { + buildBotmuxEnvAssignments, + buildDebugKeepShellScript, + resolveUserShell, + SHELL_WRAPPER_SCRIPT, +} from './tmux-backend.js'; +import { TERMINAL_CANCEL_COOLDOWN_MS } from './critical-control-key.js'; +import { logger } from '../../utils/logger.js'; + +const EARLY_BUFFER_MAX = 1024 * 1024; +const HISTORY_TAIL_DEBOUNCE_MS = 50; +const HISTORY_HOT_POLL_MS = 250; +// Keep the worst-case safety poll below IdleDetector's 2s quiescence window: +// otherwise a pure-Unicode burst (which broken upstream tail may not signal) +// could be observed only after the worker had already declared the turn idle. +const HISTORY_COLD_POLL_MS = 1250; +const HISTORY_COLD_JITTER_MAX_MS = 250; +const HISTORY_STABLE_POLLS_BEFORE_COLD = 3; +const ZMX_HISTORY_TIMEOUT_MS = 3000; +const ZMX_HISTORY_SETTLE_TIMEOUT_MS = 4000; +const TAIL_RECOVERY_DELAY_MAX_MS = 2000; +const FRESH_READY_TIMEOUT_MS = 5000; +const FRESH_RELEASE_TIMEOUT_MS = 12_000; +const FRESH_CLI_PID_TIMEOUT_MS = 3000; +// ZMX removes history as soon as the PTY root exits. Keep the private launch +// shell alive briefly after the real CLI finishes so the history-only output +// path can publish the final bytes before the daemon unlinks the session. +const ZMX_EXIT_HISTORY_GRACE_SECONDS = 3; +const TAIL_CONNECT_TIMEOUT_MS = 3000; +const MANAGED_KILL_TIMEOUT_MS = 5000; +const ZMX_COMMAND_TIMEOUT_MS = 5000; +const ZMX_HISTORY_MAX_BYTES = 16 * 1024 * 1024; +// ZMX's daemon reads one 4096-byte IPC frame at a time and may observe HUP from +// the short-lived `send` client in the same poll iteration. Keep header+payload +// comfortably below that boundary so it can parse the complete message before +// closing the client (`zmx send` has no ACK/drain handshake as of v0.7.0). +const ZMX_SEND_CHUNK_BYTES = 1024; +// v0.7.0's daemon queues send input into a 256 KiB pty_write_buf and silently +// DROPS any payload that would overflow it (daemon-side log only, no ACK, so +// `zmx send` still exits 0). Reject +// one-shot payloads well below that ceiling before writing any prefix; adapters +// that intentionally stream larger input already split and throttle their calls. +const ZMX_SEND_MAX_BYTES = 64 * 1024; +const BRACKETED_PASTE_START_BYTES = Buffer.from('\x1b[200~'); +const BRACKETED_PASTE_END = '\x1b[201~'; +const BRACKETED_PASTE_END_BYTES = Buffer.from(BRACKETED_PASTE_END); +const ZMX_TRANSPORT_LABEL = 'botmux.transport'; +const ZMX_TRANSPORT = 'tail-send-v1'; +const ZMX_SESSION_LABEL = 'botmux.session'; +const ZMX_LAUNCH_PID_LABEL = 'botmux.launch_pid'; + +type BackendState = 'idle' | 'observing' | 'recovering' | 'stopped' | 'exited'; +type ZmxSendIntent = 'text' | 'control'; + +function bytesMatchAt(bytes: Buffer, marker: Buffer, cursor: number, boundary: number): boolean { + if (cursor + marker.length > boundary) return false; + for (let i = 0; i < marker.length; i += 1) { + if (bytes[cursor + i] !== marker[i]) return false; + } + return true; +} + +function bracketedPasteOpenStatesAtChunkBoundaries(bytes: Buffer): boolean[] { + const states = [false]; + let openPastes = 0; + let cursor = 0; + const shortestMarkerLength = Math.min( + BRACKETED_PASTE_START_BYTES.length, + BRACKETED_PASTE_END_BYTES.length, + ); + + for ( + let boundary = Math.min(ZMX_SEND_CHUNK_BYTES, bytes.length); + boundary > 0; + boundary = Math.min(boundary + ZMX_SEND_CHUNK_BYTES, bytes.length) + ) { + while (cursor + shortestMarkerLength <= boundary) { + if (bytesMatchAt(bytes, BRACKETED_PASTE_START_BYTES, cursor, boundary)) { + openPastes += 1; + cursor += BRACKETED_PASTE_START_BYTES.length; + continue; + } + if (bytesMatchAt(bytes, BRACKETED_PASTE_END_BYTES, cursor, boundary)) { + if (openPastes > 0) openPastes -= 1; + cursor += BRACKETED_PASTE_END_BYTES.length; + continue; + } + cursor += 1; + } + states.push(openPastes > 0); + if (boundary === bytes.length) break; + } + + return states; +} + +type BackingIdentityProbe = + | { state: 'compatible'; clients: number | null } + | { state: 'missing' } + | { state: 'unknown'; reason: string } + | { state: 'replaced'; reason: string }; + +interface ZmxSessionProbeResult { + ok: true; + sessions: string[]; + unhealthySessions: string[]; + raw: string; +} + +interface ZmxSessionDetails { + name: string; + pid: number; + clients: number | null; + command: string | null; +} + +export type ZmxManagedSessionProbe = + | { state: 'missing' } + | { state: 'unknown'; reason: string } + | { state: 'compatible'; pid: number; clients: number | null } + | { + state: 'incompatible'; + pid: number; + clients: number | null; + reason: 'transport-label' | 'session-label'; + }; + +interface ZmxLaunchPayload { + dir: string; + bootstrapPath: string; + readyPath: string; + readyNonce: string; + releasePath: string; + releaseTempPath: string; + cliPidPath: string; + releaseToken: string; + cleanup: () => void; +} + +const syncSleepCell = new Int32Array(new SharedArrayBuffer(4)); + +function sleepSync(ms: number): void { + Atomics.wait(syncSleepCell, 0, 0, ms); +} + +/** Cross-platform direct-parent probe used only during the private fresh + * handshake and warm reattach. ZMX itself is Unix-only, so Linux `/proc` plus + * BSD-compatible `ps` covers its supported hosts without shell interpolation. */ +function readProcessParentPid(pid: number): number | null { + if (!Number.isSafeInteger(pid) || pid <= 0) return null; + if (process.platform === 'linux') { + try { + const raw = readFileSync(`/proc/${pid}/stat`, 'utf8'); + const closeParen = raw.lastIndexOf(')'); + if (closeParen < 0) return null; + const fields = raw.slice(closeParen + 2).trim().split(/\s+/); + const parent = Number(fields[1]); + return Number.isSafeInteger(parent) && parent > 0 ? parent : null; + } catch { return null; } + } + for (const ps of ['/usr/bin/ps', '/bin/ps']) { + try { + const raw = execFileSync(ps, ['-o', 'ppid=', '-p', String(pid)], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 2000, + env: { PATH: '/usr/bin:/bin', LANG: 'C' }, + }).trim(); + const parent = Number(raw); + if (Number.isSafeInteger(parent) && parent > 0) return parent; + } catch { /* try the other standard BSD/GNU location */ } + } + return null; +} + +/** Convert plain line feeds into terminal-safe CRLF without doubling CRLF. */ +export function normaliseZmxHistory(text: string): string { + // ZMX history emits LF while tail can expose CRLF, and high-throughput tail + // chunking can occasionally repeat the CR at a boundary. All represent the + // same terminal line break in this plain-text transport. + return text.replace(/\r*\n/g, '\r\n'); +} + +/** + * Persistent ZMX backend built from three non-leader primitives: + * + * - `zmx tail` is a low-latency change/liveness signal (never a byte source); + * - `zmx send` injects input without taking client leadership or resizing; + * - `zmx history` is the sole authoritative plain-text screen source. + * + * A one-shot `zmx attach ... /bin/sh ` is used only as the + * race-safe create primitive. Its stdin is /dev/null, so the client exits + * immediately and never remains as a synthetic leader. The private bootstrap + * does not launch the CLI until botmux has proved ownership, stamped the + * transport protocol labels and observed the tail client connected. + */ +export class ZmxBackend implements SessionBackend { + private tailProcess: ChildProcess | null = null; + private readonly dataCbs: Array<(data: string) => void> = []; + private readonly screenResyncCbs: Array<(snapshot: string) => void> = []; + private readonly exitCbs: Array<(code: number | null, signal: string | null) => void> = []; + private reattaching: boolean; + private intentionalExit = false; + private exited = false; + private state: BackendState = 'idle'; + private tailEpoch = 0; + private reconnectAttempt = 0; + private reconnectTimer: NodeJS.Timeout | null = null; + private stableTailTimer: NodeJS.Timeout | null = null; + private historyTimer: NodeJS.Timeout | null = null; + private historyTimerDueAt = 0; + private historyProcess: ChildProcess | null = null; + private historyGeneration = 0; + private historyCaptureSerial = 0; + private historyInFlight = false; + private historyAgain = false; + private historyAgainActivity = false; + private historyAgainForceResync = false; + private tailActivitySinceCapture = false; + private forceResyncOnNextSnapshot = false; + private stableHistoryPolls = 0; + private snapshotCache = ''; + private hasSnapshot = false; + private pendingScreenResyncReplay = false; + private readonly historyColdJitterMs: number; + private readonly historySettleWaiters: Array<{ + targetSerial: number; + resolve: (success: boolean) => void; + timer: NodeJS.Timeout; + }> = []; + /** A same-name session failed ownership checks and must never be killed. */ + private preserveSessionOnDestroy = false; + private pendingExit: { code: number | null; signal: string | null } | null = null; + private earlyBuffer = ''; + private lastOpts: SpawnOpts | null = null; + /** Frozen ZMX PTY-root PID for the session generation we may control. */ + private backingPid: number | null = null; + /** Stable direct child of the PTY root; wrapper resolution may refine cliPid. */ + private launchPid: number | null = null; + /** Monotonic generation of compatible, transport-ambiguous text writes. */ + private ambiguousTextFailureSequence = 0; + /** Highest ambiguous text generation covered by any Ctrl+C attempt. */ + private cancelledAmbiguousTextFailureSequence = 0; + /** Completion-time timestamp: a timed-out Ctrl+C may have landed just before return. */ + private lastInjectedCancelAtMs = 0; + + claudeJsonlPath?: string; + cliPid?: number; + cliCwd?: string; + + constructor( + private readonly sessionName: string, + private readonly opts: { + ownsSession?: boolean; + isReattach?: boolean; + sessionId?: string; + } = {}, + ) { + this.reattaching = opts.isReattach ?? false; + let hash = 0; + for (const char of sessionName) hash = ((hash * 33) + char.charCodeAt(0)) >>> 0; + this.historyColdJitterMs = hash % (HISTORY_COLD_JITTER_MAX_MS + 1); + } + + static isAvailable(): boolean { + return probeZmxFunctional().ok; + } + + static sessionName(sessionId: string): string { + return `bmx-${sessionId.slice(0, 8)}`; + } + + /** + * Pair the authoritative healthy-name surface (`list --short`) with the full + * list's `err=` rows. The full command field is not a line protocol (literal + * newlines in argv spill onto continuation lines), so it must never be the + * sole source of truth for a healthy session name. + */ + static probeSessions(env: NodeJS.ProcessEnv = zmxEnv()): ZmxSessionProbeResult | { ok: false } { + try { + const shortOut = execFileSync('zmx', ['list', '--short'], { + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 3000, + env, + }); + const out = execFileSync('zmx', ['list'], { + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 3000, + env, + }); + const short = parseZmxShortList(shortOut); + const parsed = parseZmxList(out); + if (short.malformedLines.length > 0 || parsed.malformedLines.length > 0) return { ok: false }; + if (short.sessions.length > 0 && parsed.sessions.length + parsed.unhealthySessions.length === 0) { + return { ok: false }; + } + + const healthy = new Set(short.sessions); + // A healthy-looking full row absent from --short is ambiguous (the row + // can have been forged by a multiline cmd field). Preserve it as unknown, + // never as authoritative existence or absence. Conversely --short wins + // over a forged err= continuation for a genuinely healthy name. + const unhealthy = new Set([ + ...parsed.unhealthySessions.filter(name => !healthy.has(name)), + ...parsed.sessions.filter(name => !healthy.has(name)), + ]); + return { + ok: true, + sessions: short.sessions, + unhealthySessions: [...unhealthy], + raw: out, + }; + } catch { + return { ok: false }; + } + } + + static hasSession(name: string, env: NodeJS.ProcessEnv = zmxEnv()): boolean { + return ZmxBackend.probeSession(name, env) === 'exists'; + } + + static probeSession(name: string, env: NodeJS.ProcessEnv = zmxEnv()): SessionProbe { + const probe = ZmxBackend.probeSessions(env); + if (!probe.ok) return 'unknown'; + if (probe.sessions.includes(name)) return 'exists'; + if (probe.unhealthySessions.includes(name)) return 'unknown'; + return 'missing'; + } + + /** + * Verify that a name still resolves to the botmux-owned transport for the + * complete session UUID. The PTY-root PID is sampled on both sides of the label + * reads so a same-name replacement cannot be mistaken for the process whose + * labels we just inspected. + */ + static probeManagedSession( + name: string, + expectedSessionId: string | undefined, + env: NodeJS.ProcessEnv = zmxEnv(), + ): ZmxManagedSessionProbe { + const beforeSnapshot = ZmxBackend.probeSessions(env); + if (!beforeSnapshot.ok) { + return { state: 'unknown', reason: `无法读取 ZMX 会话 ${name} 的进程信息` }; + } + if (!beforeSnapshot.sessions.includes(name)) { + return beforeSnapshot.unhealthySessions.includes(name) + ? { state: 'unknown', reason: `ZMX 会话 ${name} 当前无响应` } + : { state: 'missing' }; + } + const before = sessionDetailsFromSnapshot(beforeSnapshot, name); + if (!before) return { state: 'unknown', reason: `ZMX 会话 ${name} 的进程信息不唯一` }; + + let transport: string; + let sessionId = ''; + try { + transport = execFileSync('zmx', ['get', name, ZMX_TRANSPORT_LABEL], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 3000, + env, + }).trim(); + if (expectedSessionId) { + sessionId = execFileSync('zmx', ['get', name, ZMX_SESSION_LABEL], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 3000, + env, + }).trim(); + } + } catch (err) { + return { + state: 'unknown', + reason: `无法读取 ZMX 会话 ${name} 的所有权标签:${err instanceof Error ? err.message : String(err)}`, + }; + } + + const afterSnapshot = ZmxBackend.probeSessions(env); + const after = afterSnapshot.ok ? sessionDetailsFromSnapshot(afterSnapshot, name) : null; + if (!after || after.pid !== before.pid) { + return { state: 'unknown', reason: `ZMX 会话 ${name} 在所有权校验期间发生变化` }; + } + if (transport !== ZMX_TRANSPORT) { + return { + state: 'incompatible', + pid: after.pid, + clients: after.clients, + reason: 'transport-label', + }; + } + if (expectedSessionId && sessionId !== expectedSessionId) { + return { + state: 'incompatible', + pid: after.pid, + clients: after.clients, + reason: 'session-label', + }; + } + return { state: 'compatible', pid: after.pid, clients: after.clients }; + } + + /** ZMX has one daemon per session rather than one shared server. */ + static serverState(): 'running' | 'down' | 'unknown' { + const probe = ZmxBackend.probeSessions(); + if (!probe.ok) return 'unknown'; + if (probe.unhealthySessions.some(s => s.startsWith('bmx-'))) return 'unknown'; + return probe.sessions.some(s => s.startsWith('bmx-')) ? 'running' : 'down'; + } + + static killSession(name: string): void { + try { + execFileSync('zmx', ['kill', name, '--force'], { + stdio: 'ignore', + timeout: ZMX_COMMAND_TIMEOUT_MS, + env: zmxEnv(), + }); + } catch { /* already gone or daemon unavailable */ } + } + + /** Kill only a session whose full botmux identity is still authoritative. */ + static killManagedSession( + name: string, + expectedSessionId: string, + expectedPid?: number, + env: NodeJS.ProcessEnv = zmxEnv(), + ): void { + const probe = ZmxBackend.probeManagedSession(name, expectedSessionId, env); + if (probe.state === 'missing') return; + if (probe.state !== 'compatible') { + throw new Error( + probe.state === 'unknown' + ? probe.reason + : `ZMX 会话 ${name} 的所有权标签不匹配,已拒绝删除`, + ); + } + if (expectedPid !== undefined && probe.pid !== expectedPid) { + throw new Error(`ZMX 会话 ${name} 的 PTY root PID 已变化,已拒绝删除`); + } + execFileSync('zmx', ['kill', name, '--force'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: ZMX_COMMAND_TIMEOUT_MS, + env, + }); + // Successful ZMX kills print `killed session ` (and forced stale + // cleanup also prints a status line). The exit status plus the generation- + // aware convergence probe below are authoritative, not empty stdout. + + const deadline = Date.now() + MANAGED_KILL_TIMEOUT_MS; + let lastUnknownReason: string | null = null; + while (Date.now() < deadline) { + const after = ZmxBackend.probeManagedSession(name, expectedSessionId, env); + if (after.state === 'missing') return; + // A just-removed socket may briefly remain visible while its control + // endpoint already rejects get/list. Treat that as convergence, not as a + // replacement; an incompatible label or changed PID still fails at once. + if (after.state === 'unknown') { + lastUnknownReason = after.reason; + sleepSync(25); + continue; + } + if (after.state === 'incompatible' || after.pid !== probe.pid) { + throw new Error(`ZMX 会话 ${name} 在删除确认期间被同名会话替换`); + } + sleepSync(25); + } + throw new Error( + `ZMX 会话 ${name} 删除确认超时` + + (lastUnknownReason ? `:${lastUnknownReason}` : ''), + ); + } + + static listBotmuxSessions(): string[] { + const probe = ZmxBackend.probeSessions(); + return probe.ok ? probe.sessions.filter(s => s.startsWith('bmx-')) : []; + } + + static listDetails(): string { + const probe = ZmxBackend.probeSessions(); + return probe.ok ? probe.raw : ''; + } + + get isReattach(): boolean { + return this.reattaching; + } + + get lastInjectedCancelAt(): number { + return this.lastInjectedCancelAtMs; + } + + captureAmbiguousSubmissionFence(): number { + return this.ambiguousTextFailureSequence; + } + + cancelAmbiguousSubmission(fence: number): void { + const failedThrough = this.ambiguousTextFailureSequence; + if ( + failedThrough <= fence + || failedThrough <= this.cancelledAmbiguousTextFailureSequence + ) { + return; + } + this.abortPartialSend(false); + } + + /** + * Space terminal writes themselves so two valid cancellation debts cannot + * become OMP's double-Ctrl+C exit gesture. + */ + private waitForInjectedCancelCooldown(): void { + if (this.lastInjectedCancelAtMs > 0) { + const elapsed = Math.max(0, Date.now() - this.lastInjectedCancelAtMs); + const waitMs = TERMINAL_CANCEL_COOLDOWN_MS - elapsed; + if (waitMs > 0) sleepSync(waitMs); + } + } + + /** Record which ambiguous generation the imminent Ctrl+C can clean. */ + private markAmbiguousGenerationCovered(): void { + this.cancelledAmbiguousTextFailureSequence = Math.max( + this.cancelledAmbiguousTextFailureSequence, + this.ambiguousTextFailureSequence, + ); + } + + /** Start the next terminal-side cooldown after this attempt has settled. */ + private noteInjectedCancelAttemptSettled(): void { + this.lastInjectedCancelAtMs = Date.now(); + } + + spawn(bin: string, args: string[], opts: SpawnOpts): void { + const frozenOpts: SpawnOpts = { + ...opts, + env: { ...opts.env }, + injectEnv: opts.injectEnv ? { ...opts.injectEnv } : undefined, + }; + const controlEnv = zmxControlEnv(frozenOpts); + const probe = ZmxBackend.probeManagedSession( + this.sessionName, + this.opts.sessionId, + controlEnv, + ); + if (probe.state === 'unknown') throw new Error(probe.reason); + if (probe.state === 'incompatible') { + this.preserveSessionOnDestroy = true; + throw new Error( + probe.reason === 'transport-label' + ? `ZMX 会话 ${this.sessionName} 缺少 botmux 传输标签(tail 信号 + history 屏幕 + send 输入);已保留,请手动关闭旧会话后重试` + : `ZMX 会话 ${this.sessionName} 属于另一个完整 botmux session;已保留该会话`, + ); + } + + if (probe.state === 'compatible') { + // Reattach/fresh is frozen by worker before plugin, startup-command and + // isolation decisions. A late same-name winner must not silently turn a + // fresh launch into an attach to a process created under another policy. + if (!this.reattaching) { + this.preserveSessionOnDestroy = true; + throw new Error(`ZMX 会话 ${this.sessionName} 在启动前已出现;已保留并拒绝改变 fresh 决策`); + } + this.backingPid = probe.pid; + this.lastOpts = frozenOpts; + this.launchPid = this.readManagedLaunchPid(frozenOpts, probe.pid); + this.cliPid = this.launchPid; + } else if (this.reattaching) { + // The daemon selected reattach from an earlier probe. Never turn that + // stale decision into a new CLI after the backing session disappears. + throw new Error(`ZMX 会话 ${this.sessionName} 在重连前已消失`); + } else { + this.lastOpts = frozenOpts; + } + + logger.debug( + `[zmx:${this.sessionName}] spawn ${this.reattaching ? 'reattach' : 'fresh'} ` + + `bin=${bin} args=${JSON.stringify(args)} cwd=${opts.cwd}`, + ); + + if (this.reattaching) { + this.startTail(); + if (!this.waitForTailClient()) { + this.stopTailAfterLaunchFailure(); + this.preserveSessionOnDestroy = true; + throw new Error(`ZMX tail 未能连接会话 ${this.sessionName}`); + } + this.requestHistoryCapture(0, true, true); + return; + } + + this.createFreshSession(bin, args, opts); + } + + write(data: string): void { + if (!this.sendText(data)) throw new Error(`ZMX 会话 ${this.sessionName} 输入发送失败`); + } + + sendText(text: string): boolean { + return this.sendBytes(Buffer.from(text, 'utf8'), false, 'text'); + } + + sendSpecialKeys(...keys: string[]): boolean { + return this.sendBytes( + Buffer.from(keys.map(tmuxKeyToBytes).join(''), 'utf8'), + false, + 'control', + ); + } + + pasteText(text: string): void { + if (!this.sendBytes( + Buffer.from(`\x1b[200~${text}\x1b[201~`, 'utf8'), + true, + 'text', + )) { + throw new Error(`ZMX 会话 ${this.sessionName} 粘贴发送失败`); + } + } + + /** + * No-op by construction: zmx exposes no leaderless resize primitive. Size is + * set only by an attached client's TIOCGWINSZ, and `send` deliberately never + * becomes that client (upstream 8ba312d7). Since `createFreshSession` creates + * the session with a non-TTY stdio, zmx's `getTerminalSize` fallback applies + * and every botmux-owned session runs at a fixed 120x24 until a local + * `zmx attach` takes leadership. The CLI therefore wraps its TUI at 120 + * columns, which is the width reflected in the history screen we relay. + */ + resize(_cols: number, _rows: number): void {} + + onData(cb: (data: string) => void): void { + this.dataCbs.push(cb); + if (this.earlyBuffer) { + const buffered = this.earlyBuffer; + this.earlyBuffer = ''; + try { cb(buffered); } catch { /* listener failure must not kill transport */ } + } + } + + onScreenResync(cb: (snapshot: string) => void): void { + this.screenResyncCbs.push(cb); + if (this.pendingScreenResyncReplay) { + this.pendingScreenResyncReplay = false; + try { cb(this.snapshotCache); } catch { /* listener failure must not kill transport */ } + } + } + + onExit(cb: (code: number | null, signal: string | null) => void): void { + this.exitCbs.push(cb); + if (this.pendingExit) { + const exit = this.pendingExit; + try { cb(exit.code, exit.signal); } catch { /* listener failure must not block teardown */ } + } + } + + getChildPid(): number | null { + if (this.cliPid) return this.cliPid; + const pid = this.backingPid ?? findSessionPid(this.sessionName); + if (pid) this.cliPid = pid; + return pid; + } + + /** Best-effort plain-text terminal snapshot supplied by ZMX history. */ + captureCurrentScreen(): string { + return this.hasSnapshot ? this.snapshotCache : ''; + } + + settleCurrentScreen(): Promise { + if (this.exited || this.intentionalExit || !this.lastOpts) return Promise.resolve(false); + // If a capture is already in flight, wait for the dirty-latch follow-up, + // not the older sample that may have started before the final output. + const targetSerial = this.historyCaptureSerial + 1; + return new Promise(resolve => { + const timer = setTimeout(() => { + const index = this.historySettleWaiters.findIndex(waiter => waiter.resolve === resolve); + if (index >= 0) this.historySettleWaiters.splice(index, 1); + resolve(false); + }, ZMX_HISTORY_SETTLE_TIMEOUT_MS); + timer.unref?.(); + this.historySettleWaiters.push({ targetSerial, resolve, timer }); + this.requestHistoryCapture(0); + }); + } + + captureViewport(): string { + return this.captureCurrentScreen(); + } + + getPaneSize(): null { + return null; + } + + isPaneAlive(): boolean { + return this.verifyBackingGeneration('liveness').state === 'compatible'; + } + + /** Detach the read-only observer while leaving the ZMX daemon and CLI alive. */ + kill(): void { + if (this.state === 'stopped' || this.state === 'exited') return; + this.intentionalExit = true; + this.state = 'stopped'; + this.tailEpoch += 1; + this.clearReconnectTimer(); + this.clearStableTailTimer(); + this.stopHistoryPolling(); + const tail = this.tailProcess; + this.tailProcess = null; + try { tail?.kill('SIGTERM'); } catch { /* already gone */ } + } + + destroySession(): void { + this.kill(); + if (!(this.opts.ownsSession ?? true) || this.preserveSessionOnDestroy) return; + if (!this.opts.sessionId || !this.lastOpts || this.backingPid == null) return; + try { + ZmxBackend.killManagedSession( + this.sessionName, + this.opts.sessionId, + this.backingPid, + zmxControlEnv(this.lastOpts), + ); + } catch (err) { + this.preserveSessionOnDestroy = true; + logger.warn( + `[zmx:${this.sessionName}] refused unsafe destroy: ` + + `${err instanceof Error ? err.message : String(err)}`, + ); + } + } + + private createFreshSession(bin: string, args: string[], opts: SpawnOpts): void { + const launch = createZmxLaunchPayload(bin, args, opts); + let released = false; + try { + const result = spawnSync('zmx', buildFreshAttachArgs(this.sessionName, launch.bootstrapPath), { + cwd: opts.cwd, + // /dev/null makes this a one-shot create client: it never remains as a + // fake terminal leader and never controls the backing PTY dimensions. + stdio: ['ignore', 'ignore', 'pipe'], + timeout: ZMX_COMMAND_TIMEOUT_MS, + env: zmxControlEnv(opts), + }); + + const ready = this.waitForFreshReady(launch); + if (!ready) { + const details = sessionDetails(this.sessionName, zmxControlEnv(opts)); + if (!details?.command?.includes(launch.bootstrapPath)) { + this.preserveSessionOnDestroy = true; + } + const stderr = result.stderr?.toString('utf8').trim(); + throw new Error( + `ZMX 会话 ${this.sessionName} 启动握手超时` + (stderr ? `:${stderr}` : ''), + ); + } + + const created = sessionDetails(this.sessionName, zmxControlEnv(opts)); + if (!created || !created.command?.includes(launch.bootstrapPath)) { + this.preserveSessionOnDestroy = true; + throw new Error(`ZMX 会话 ${this.sessionName} 的 fresh 所有权握手失效;已保留同名会话`); + } + this.backingPid = created.pid; + const launchPid = this.waitForFreshLaunchPid(launch); + if (launchPid == null) { + throw new Error(`ZMX 会话 ${this.sessionName} 未能确认稳定的 CLI launch 子进程`); + } + this.launchPid = launchPid; + this.cliPid = launchPid; + + this.stampProtocolLabels(opts, launchPid); + const managed = ZmxBackend.probeManagedSession( + this.sessionName, + this.opts.sessionId, + zmxControlEnv(opts), + ); + if (managed.state !== 'compatible' || managed.pid !== this.backingPid) { + this.preserveSessionOnDestroy = true; + throw new Error(`ZMX 会话 ${this.sessionName} 在 release 前未通过完整身份校验`); + } + this.startTail(); + if (!this.waitForTailClient()) { + throw new Error(`ZMX tail 未能连接会话 ${this.sessionName}`); + } + + // The bootstrap performs one read after observing the release file. A + // direct write has an open-before-content race, so publish a complete + // token atomically within the same private directory. + writeFileSync(launch.releaseTempPath, `${launch.releaseToken}\n`, { mode: 0o600, flag: 'wx' }); + renameSync(launch.releaseTempPath, launch.releasePath); + released = true; + // The bootstrap can now launch the CLI. Polling is required even when + // tail emits no bytes: upstream currently drops pure UTF-8 output while + // stripping ANSI, whereas history preserves it. + this.requestHistoryCapture(0, true, true); + } catch (err) { + this.stopTailAfterLaunchFailure(); + launch.cleanup(); + // Before release, never kill by name: the bounded private bootstrap exits + // on its own and a same-name race winner must be preserved. After release + // the CLI may already be running, so tear down only through the frozen + // full-session identity and PTY-root PID. + if (released && this.opts.sessionId && this.backingPid != null) { + try { + ZmxBackend.killManagedSession( + this.sessionName, + this.opts.sessionId, + this.backingPid, + zmxControlEnv(opts), + ); + } catch (killErr) { + this.preserveSessionOnDestroy = true; + logger.warn( + `[zmx:${this.sessionName}] failed to tear down released launch after handshake error: ` + + `${killErr instanceof Error ? killErr.message : String(killErr)}`, + ); + } + } + this.lastOpts = null; + this.backingPid = null; + this.launchPid = null; + this.cliPid = undefined; + throw err; + } + } + + private waitForFreshReady(launch: ZmxLaunchPayload): boolean { + const deadline = Date.now() + FRESH_READY_TIMEOUT_MS; + while (Date.now() < deadline) { + try { + const value = readFileSync(launch.readyPath, 'utf8').trim(); + if (value === launch.readyNonce) return true; + } catch { /* bootstrap has not reached the gate yet */ } + sleepSync(25); + } + return false; + } + + private waitForFreshLaunchPid(launch: ZmxLaunchPayload): number | null { + const deadline = Date.now() + FRESH_CLI_PID_TIMEOUT_MS; + while (Date.now() < deadline) { + try { + const pid = Number(readFileSync(launch.cliPidPath, 'utf8').trim()); + if ( + Number.isSafeInteger(pid) + && pid > 0 + && this.backingPid != null + && readProcessParentPid(pid) === this.backingPid + ) { + return pid; + } + } catch { /* bootstrap has not published a complete pid yet */ } + sleepSync(25); + } + return null; + } + + private readManagedLaunchPid(opts: SpawnOpts, expectedBackingPid: number): number { + let raw: string; + try { + raw = execFileSync('zmx', ['get', this.sessionName, ZMX_LAUNCH_PID_LABEL], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 3000, + env: zmxControlEnv(opts), + }).trim(); + } catch (err) { + throw new Error( + `ZMX 会话 ${this.sessionName} 缺少 launch PID 标签:` + + `${err instanceof Error ? err.message : String(err)}`, + ); + } + const pid = Number(raw); + if ( + !Number.isSafeInteger(pid) + || pid <= 0 + || readProcessParentPid(pid) !== expectedBackingPid + ) { + throw new Error(`ZMX 会话 ${this.sessionName} 的 launch PID 标签无效或已脱离 PTY root`); + } + const after = ZmxBackend.probeManagedSession( + this.sessionName, + this.opts.sessionId, + zmxControlEnv(opts), + ); + if (after.state !== 'compatible' || after.pid !== expectedBackingPid) { + throw new Error(`ZMX 会话 ${this.sessionName} 在 launch PID 校验期间发生变化`); + } + return pid; + } + + /** + * Wait until the session reports at least one connected client. + * + * Deliberately NOT a differential against a pre-tail baseline. zmx's + * `clients=` is an aggregate — `main.zig` reports `clients.items.len - 1`, + * subtracting only the `zmx list` connection that asked. A user's + * `zmx attach` (which this integration actively encourages), and every + * transient `zmx list`/`get`/`history`/`send` botmux itself issues, are all + * counted identically to our `tail`. A differential therefore produces false + * NEGATIVES: a user detaching inside this window keeps the net delta at 0 and + * a perfectly healthy session fails to restore with "tail 未能连接". + * + * `>= 1` inverts the error direction. The residual false POSITIVE — someone + * else holds a client while our tail failed — is cheap and self-correcting: + * tail is only a wakeup signal (never a byte source), the authoritative + * screen comes from `history` polling which does not need tail at all, and + * `scheduleTailRecovery` reconnects a dead observer. Blocking a restore is + * the far more expensive mistake. + * + * Note this cannot instead watch our own child: the wait is synchronous + * (`sleepSync`), so the child's 'error'/'close' callbacks cannot run until it + * returns. Session identity is still verified below. + */ + private waitForTailClient(): boolean { + const deadline = Date.now() + TAIL_CONNECT_TIMEOUT_MS; + while (Date.now() < deadline) { + const details = sessionDetails( + this.sessionName, + this.lastOpts ? zmxControlEnv(this.lastOpts) : zmxEnv(), + ); + if (details && this.backingPid != null && details.pid !== this.backingPid) { + this.preserveSessionOnDestroy = true; + return false; + } + if (details?.clients != null && details.clients >= 1) return true; + if (!details && ZmxBackend.probeSession( + this.sessionName, + this.lastOpts ? zmxControlEnv(this.lastOpts) : zmxEnv(), + ) === 'missing') return false; + sleepSync(25); + } + return false; + } + + private stampProtocolLabels(opts: SpawnOpts, launchPid: number): void { + const labels = [`${ZMX_TRANSPORT_LABEL}=${ZMX_TRANSPORT}`]; + if (this.opts.sessionId) labels.push(`${ZMX_SESSION_LABEL}=${this.opts.sessionId}`); + labels.push(`${ZMX_LAUNCH_PID_LABEL}=${launchPid}`); + const stdout = execFileSync('zmx', ['set', this.sessionName, ...labels], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: ZMX_COMMAND_TIMEOUT_MS, + env: zmxControlEnv(opts), + }); + if (stdout.trim()) { + throw new Error(`ZMX 协议标签写入返回异常:${stdout.trim()}`); + } + } + + + private verifyBackingIdentity(context: string): BackingIdentityProbe { + if (!this.lastOpts || !this.opts.sessionId || this.backingPid == null) { + return { state: 'unknown', reason: `ZMX ${context} 缺少已冻结的会话身份` }; + } + const wasAlive = this.isBackingProcessAlive(); + let transport: string; + let sessionId: string; + try { + const env = zmxControlEnv(this.lastOpts); + transport = execFileSync('zmx', ['get', this.sessionName, ZMX_TRANSPORT_LABEL], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 3000, + env, + }).trim(); + sessionId = execFileSync('zmx', ['get', this.sessionName, ZMX_SESSION_LABEL], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 3000, + env, + }).trim(); + } catch (err) { + if (!wasAlive || !this.isBackingProcessAlive()) { + this.fireExit(0, null); + return { state: 'missing' }; + } + return { + state: 'unknown', + reason: `无法读取 ZMX 会话 ${this.sessionName} 的所有权标签:` + + `${err instanceof Error ? err.message : String(err)}`, + }; + } + if (transport !== ZMX_TRANSPORT || sessionId !== this.opts.sessionId) { + return this.rejectBackingReplacement(context, '完整 session / transport 标签已变化'); + } + if (!wasAlive || !this.isBackingProcessAlive()) { + this.fireExit(0, null); + return { state: 'missing' }; + } + return { state: 'compatible', clients: null }; + } + + /** Full list sampling is reserved for lifecycle edges that need client count. + * Hot send/history paths use target-scoped labels + the frozen PTY-root PID, + * so one unrelated unhealthy ZMX socket cannot freeze every session. */ + private verifyBackingIdentityWithClients(context: string): BackingIdentityProbe { + if (!this.lastOpts || !this.opts.sessionId || this.backingPid == null) { + return { state: 'unknown', reason: `ZMX ${context} 缺少已冻结的会话身份` }; + } + const probe = ZmxBackend.probeManagedSession( + this.sessionName, + this.opts.sessionId, + zmxControlEnv(this.lastOpts), + ); + if (probe.state === 'missing') { + this.fireExit(0, null); + return probe; + } + if (probe.state === 'unknown') return probe; + if (probe.state === 'incompatible' || probe.pid !== this.backingPid) { + const reason = probe.state === 'incompatible' + ? '完整 session / transport 标签已变化' + : `PTY root PID 已从 ${this.backingPid} 变化为 ${probe.pid}`; + return this.rejectBackingReplacement(context, reason); + } + return { state: 'compatible', clients: probe.clients }; + } + + /** Fast generation check for high-frequency read-only snapshots. */ + private verifyBackingGeneration(context: string): BackingIdentityProbe { + return this.verifyBackingIdentity(context); + } + + private isBackingProcessAlive(): boolean { + if (this.backingPid == null) return false; + try { + process.kill(this.backingPid, 0); + return true; + } catch (err) { + return !!err && typeof err === 'object' && 'code' in err && err.code === 'EPERM'; + } + } + + private rejectBackingReplacement(context: string, reason: string): BackingIdentityProbe { + this.preserveSessionOnDestroy = true; + logger.error(`[zmx:${this.sessionName}] ${context} refused: ${reason}`); + this.fireExit(75, null); + return { state: 'replaced', reason }; + } + + private labelsMatchBacking(raw: string): boolean { + const labels = new Map(); + for (const pair of raw.trim().split(/\s+/)) { + if (!pair) continue; + const equals = pair.indexOf('='); + if (equals <= 0) continue; + labels.set(pair.slice(0, equals), pair.slice(equals + 1)); + } + return labels.get(ZMX_TRANSPORT_LABEL) === ZMX_TRANSPORT + && labels.get(ZMX_SESSION_LABEL) === this.opts.sessionId; + } + + private readLabelsAsync(generation: number): Promise { + return new Promise((resolve, reject) => { + if (!this.lastOpts || generation !== this.historyGeneration) { + reject(new Error('history capture generation changed')); + return; + } + const child = execFile('zmx', ['get', this.sessionName], { + encoding: 'utf8', + timeout: ZMX_HISTORY_TIMEOUT_MS, + maxBuffer: 64 * 1024, + env: zmxControlEnv(this.lastOpts), + }, (err, stdout, stderr) => { + if (this.historyProcess === child) this.historyProcess = null; + if (generation !== this.historyGeneration) { + reject(new Error('history capture generation changed')); + return; + } + const errorText = stderr?.toString().trim() ?? ''; + if (err || errorText) { + reject(err ?? new Error(errorText)); + return; + } + resolve(stdout.toString()); + }); + this.historyProcess = child; + }); + } + + private captureHistoryFileAsync(generation: number): Promise { + return new Promise((resolve, reject) => { + if (!this.lastOpts || generation !== this.historyGeneration) { + reject(new Error('history capture generation changed')); + return; + } + let historyDir: string | undefined; + let historyPath: string | undefined; + let historyFd: number | undefined; + let child: ChildProcess | null = null; + let timeout: NodeJS.Timeout | null = null; + let settled = false; + let stderrTail = ''; + + const cleanup = () => { + if (timeout) clearTimeout(timeout); + timeout = null; + if (this.historyProcess === child) this.historyProcess = null; + if (historyFd !== undefined) { + try { closeSync(historyFd); } catch { /* best effort */ } + } + if (historyPath) { + try { rmSync(historyPath, { force: true }); } catch { /* best effort */ } + } + if (historyDir) { + try { rmdirSync(historyDir); } catch { /* best effort */ } + } + }; + const finish = (err?: Error) => { + if (settled) return; + settled = true; + try { + if (err) throw err; + if (generation !== this.historyGeneration || historyFd === undefined) { + throw new Error('history capture generation changed'); + } + const size = fstatSync(historyFd).size; + const length = Math.min(size, ZMX_HISTORY_MAX_BYTES); + const data = Buffer.allocUnsafe(length); + let bytesRead = 0; + while (bytesRead < length) { + const count = readSync( + historyFd, + data, + bytesRead, + length - bytesRead, + size - length + bytesRead, + ); + if (count === 0) break; + bytesRead += count; + } + let bounded = data.subarray(0, bytesRead); + if (size > length) { + const firstLf = bounded.indexOf(0x0a); + if (firstLf >= 0) bounded = bounded.subarray(firstLf + 1); + } + resolve(normaliseZmxHistory(bounded.toString('utf8'))); + } catch (error) { + reject(error); + } finally { + cleanup(); + } + }; + + try { + historyDir = mkdtempSync(join(tmpdir(), 'botmux-zmx-history-')); + chmodSync(historyDir, 0o700); + historyPath = join(historyDir, 'history.txt'); + historyFd = openSync(historyPath, 'wx+', 0o600); + // Keep transcript bytes reachable only through the open fd. A private + // regular file also avoids `zmx history`'s single-write pipe truncation. + rmSync(historyPath); + child = spawn('zmx', ['history', this.sessionName], { + stdio: ['ignore', historyFd, 'pipe'], + env: zmxControlEnv(this.lastOpts), + }); + this.historyProcess = child; + child.stderr?.on('data', (chunk: Buffer | string) => { + stderrTail = (stderrTail + chunk.toString()).slice(-4096); + }); + child.once('error', error => finish(error)); + child.once('close', (code, signal) => { + const stderr = stderrTail.trim(); + if (code !== 0 || signal || stderr) { + finish(new Error(stderr || `zmx history exited status=${code} signal=${signal}`)); + return; + } + finish(); + }); + timeout = setTimeout(() => { + try { child?.kill('SIGKILL'); } catch { /* already gone */ } + finish(new Error(`zmx history timed out after ${ZMX_HISTORY_TIMEOUT_MS}ms`)); + }, ZMX_HISTORY_TIMEOUT_MS); + timeout.unref?.(); + } catch (err) { + finish(err instanceof Error ? err : new Error(String(err))); + } + }); + } + + private async readHistorySnapshotAsync(generation: number): Promise { + if (this.exited || this.intentionalExit || !this.lastOpts) return null; + try { + if (!this.isBackingProcessAlive()) { + this.fireExit(0, null); + return null; + } + const before = await this.readLabelsAsync(generation); + if (!this.labelsMatchBacking(before)) { + this.rejectBackingReplacement('history', '完整 session / transport 标签已变化'); + return null; + } + const snapshot = await this.captureHistoryFileAsync(generation); + const after = await this.readLabelsAsync(generation); + if (!this.labelsMatchBacking(after) || !this.isBackingProcessAlive()) { + if (!this.isBackingProcessAlive()) this.fireExit(0, null); + else this.rejectBackingReplacement('history completion', '完整 session / transport 标签已变化'); + return null; + } + return snapshot; + } catch (err) { + if (generation !== this.historyGeneration || this.intentionalExit || this.exited) return null; + logger.warn( + `[zmx:${this.sessionName}] history capture failed: ` + + `${err instanceof Error ? err.message : String(err)}`, + ); + if (!this.isBackingProcessAlive()) this.fireExit(0, null); + return null; + } + } + + private requestHistoryCapture( + delayMs: number, + activity = false, + forceResync = false, + ): void { + if (this.exited || this.intentionalExit || !this.lastOpts) return; + if (activity) { + this.tailActivitySinceCapture = true; + this.stableHistoryPolls = 0; + } + if (forceResync) this.forceResyncOnNextSnapshot = true; + if (this.historyInFlight) { + this.historyAgain = true; + this.historyAgainActivity ||= activity; + this.historyAgainForceResync ||= forceResync; + return; + } + const dueAt = Date.now() + Math.max(0, delayMs); + if (this.historyTimer && this.historyTimerDueAt <= dueAt) return; + if (this.historyTimer) clearTimeout(this.historyTimer); + this.historyTimerDueAt = dueAt; + this.historyTimer = setTimeout(() => { + this.historyTimer = null; + this.historyTimerDueAt = 0; + void this.runHistoryCapture(); + }, Math.max(0, delayMs)); + this.historyTimer.unref?.(); + } + + private async runHistoryCapture(): Promise { + if (this.historyInFlight || this.exited || this.intentionalExit || !this.lastOpts) return; + this.historyInFlight = true; + const captureSerial = ++this.historyCaptureSerial; + const generation = this.historyGeneration; + const activity = this.tailActivitySinceCapture; + const forceResync = this.forceResyncOnNextSnapshot; + this.tailActivitySinceCapture = false; + this.forceResyncOnNextSnapshot = false; + let snapshot: string | null = null; + let captureUsable = false; + try { + snapshot = await this.readHistorySnapshotAsync(generation); + if (snapshot !== null && generation === this.historyGeneration) { + captureUsable = this.publishHistorySnapshot(snapshot, activity, forceResync); + } else { + this.stableHistoryPolls = 0; + } + if (!captureUsable) { + // A failed/ambiguous capture must not consume the wakeup that caused + // it. Preserve both obligations for the next usable history sample. + this.tailActivitySinceCapture ||= activity; + this.forceResyncOnNextSnapshot ||= forceResync; + } + } finally { + if (generation !== this.historyGeneration) return; + const again = this.historyAgain; + const againActivity = this.historyAgainActivity; + const againForceResync = this.historyAgainForceResync; + this.historyAgain = false; + this.historyAgainActivity = false; + this.historyAgainForceResync = false; + // A capture dirtied while in flight predates output that arrived after + // it started. Settle waiters must remain pending for the latched follow- + // up rather than finalizing from this stale-but-otherwise-usable sample. + if (!again) this.resolveHistorySettleWaiters(captureSerial, captureUsable); + this.historyInFlight = false; + if (this.exited || this.intentionalExit || !this.lastOpts) return; + if (again) { + // Preserve the dirty-latch obligation, but give the single-threaded + // daemon a short breathing window before serializing history again. + // Continuous tail chunks otherwise turn a long transcript into a + // back-to-back history loop that can starve concurrent `send` probes. + this.requestHistoryCapture( + HISTORY_TAIL_DEBOUNCE_MS, + againActivity, + againForceResync, + ); + return; + } + const nextDelay = this.stableHistoryPolls >= HISTORY_STABLE_POLLS_BEFORE_COLD + ? HISTORY_COLD_POLL_MS + this.historyColdJitterMs + : HISTORY_HOT_POLL_MS; + this.requestHistoryCapture(nextDelay); + } + } + + private publishHistorySnapshot(snapshot: string, activity: boolean, forceResync: boolean): boolean { + // Current ZMX history failures can exit 0 with empty stdout. Never let an + // ambiguous empty capture erase a previously non-empty authoritative view. + if (snapshot.length === 0 && this.hasSnapshot && this.snapshotCache.length > 0) { + this.stableHistoryPolls = 0; + return false; + } + if (!this.hasSnapshot) { + this.hasSnapshot = true; + this.snapshotCache = snapshot; + this.stableHistoryPolls = snapshot.length === 0 ? 1 : 0; + if (forceResync) this.emitScreenResync(snapshot); + else if (snapshot) this.emitData(snapshot); + return true; + } + + const previous = this.snapshotCache; + if (snapshot === previous) { + this.stableHistoryPolls += 1; + // Tail can signal a cursor-only redraw that leaves plain history equal. + // Rebase derived state so idle detection observes the activity without + // trusting any (possibly UTF-8-corrupted) tail payload bytes. + if (activity) this.emitScreenResync(snapshot); + return true; + } + + this.snapshotCache = snapshot; + this.stableHistoryPolls = 0; + if (!forceResync && snapshot.startsWith(previous)) { + const delta = snapshot.slice(previous.length); + if (delta) this.emitData(delta); + return true; + } + this.emitScreenResync(snapshot); + return true; + } + + private stopHistoryPolling(): void { + this.historyGeneration += 1; + if (this.historyTimer) clearTimeout(this.historyTimer); + this.historyTimer = null; + this.historyTimerDueAt = 0; + const child = this.historyProcess; + this.historyProcess = null; + try { child?.kill('SIGKILL'); } catch { /* already gone */ } + this.historyInFlight = false; + this.historyAgain = false; + this.historyAgainActivity = false; + this.historyAgainForceResync = false; + this.tailActivitySinceCapture = false; + this.forceResyncOnNextSnapshot = false; + for (const waiter of this.historySettleWaiters.splice(0)) { + clearTimeout(waiter.timer); + waiter.resolve(false); + } + } + + private resolveHistorySettleWaiters(captureSerial: number, success: boolean): void { + for (let i = this.historySettleWaiters.length - 1; i >= 0; i -= 1) { + const waiter = this.historySettleWaiters[i]!; + if (waiter.targetSerial > captureSerial) continue; + this.historySettleWaiters.splice(i, 1); + clearTimeout(waiter.timer); + waiter.resolve(success); + } + } + + private emitScreenResync(snapshot: string): void { + if (this.screenResyncCbs.length === 0) { + this.pendingScreenResyncReplay = true; + return; + } + for (const cb of this.screenResyncCbs) { + try { cb(snapshot); } catch { /* listener failure must not kill recovery */ } + } + } + + private sendBytes( + bytes: Buffer, + bracketedPaste = false, + intent: ZmxSendIntent = 'text', + ): boolean { + if (bytes.length === 0) return true; + if (this.exited || this.intentionalExit || !this.lastOpts) return false; + if (bytes.length > ZMX_SEND_MAX_BYTES) { + throw new Error( + `ZMX 单次输入 ${bytes.length} 字节超过 ${ZMX_SEND_MAX_BYTES} 字节安全上限;` + + '当前 send 协议没有 ACK/backpressure,已在写入任何前缀前拒绝', + ); + } + const identity = this.verifyBackingIdentity('send'); + if (identity.state !== 'compatible') { + if (identity.state === 'unknown') { + logger.warn(`[zmx:${this.sessionName}] send blocked: ${identity.reason}`); + } + return false; + } + + const explicitPasteOpenAtBoundary = bracketedPaste + ? null + : bracketedPasteOpenStatesAtChunkBoundaries(bytes); + for (let offset = 0; offset < bytes.length; offset += ZMX_SEND_CHUNK_BYTES) { + const chunk = bytes.subarray(offset, Math.min(offset + ZMX_SEND_CHUNK_BYTES, bytes.length)); + // ZMX strips exactly one trailing LF from piped stdin. Appending our own + // framing LF therefore preserves the caller's original bytes exactly, + // including an original trailing LF, while keeping secrets out of argv. + const input = Buffer.concat([chunk, Buffer.from('\n')]); + // A failed send has no PTY-level ACK: the current chunk may or may not + // have reached the CLI. Track both possible states. Looking only at the + // confirmed prefix misses an opening marker in a first ambiguous frame; + // looking only through the current frame misses an opening marker whose + // matching close sits in that ambiguous frame. + const chunkIndex = offset / ZMX_SEND_CHUNK_BYTES; + const mayHaveOpenPasteAfterAmbiguousSend = bracketedPaste + || explicitPasteOpenAtBoundary?.[chunkIndex] === true + || explicitPasteOpenAtBoundary?.[chunkIndex + 1] === true; + try { + let injectedCancelAttempt = false; + if (intent === 'control' && chunk.includes(0x03)) { + this.waitForInjectedCancelCooldown(); + const postCooldownIdentity = this.verifyBackingIdentity('control-key cooldown'); + if (postCooldownIdentity.state !== 'compatible') { + logger.warn( + `[zmx:${this.sessionName}] skipped Ctrl+C after cooldown: ` + + `backing identity is ${postCooldownIdentity.state}`, + ); + return false; + } + this.markAmbiguousGenerationCovered(); + injectedCancelAttempt = true; + } + let stdout: string; + try { + stdout = execFileSync('zmx', ['send', this.sessionName], { + input, + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + timeout: ZMX_COMMAND_TIMEOUT_MS, + maxBuffer: 1024 * 1024, + env: zmxControlEnv(this.lastOpts), + }); + } finally { + if (injectedCancelAttempt) this.noteInjectedCancelAttemptSettled(); + } + // Several ZMX control-plane failures are reported on stdout with exit + // status 0. Empty stdout is part of the transport contract. + if (stdout.trim()) { + logger.warn(`[zmx:${this.sessionName}] send rejected: ${stdout.trim()}`); + const probe = this.verifyBackingIdentity('send rejection'); + const closeOpenPaste = mayHaveOpenPasteAfterAmbiguousSend; + if (probe.state === 'compatible' && intent === 'text') { + this.ambiguousTextFailureSequence += 1; + } + if (closeOpenPaste || offset > 0) { + if (probe.state === 'compatible') { + this.abortPartialSend(closeOpenPaste); + } else { + logger.warn( + `[zmx:${this.sessionName}] skipped partial-send recovery: ` + + `backing identity is ${probe.state}`, + ); + } + } + return false; + } + } catch (err) { + const probe = this.verifyBackingIdentity('send failure'); + logger.warn( + `[zmx:${this.sessionName}] send failed (${probe.state}): ` + + `${err instanceof Error ? err.message : String(err)}`, + ); + // Never retry an ambiguous send: ZMX has no PTY-level ACK, so retrying + // can duplicate a prompt that the daemon already queued. + const closeOpenPaste = mayHaveOpenPasteAfterAmbiguousSend; + if (probe.state === 'compatible' && intent === 'text') { + this.ambiguousTextFailureSequence += 1; + } + if (closeOpenPaste || offset > 0) { + if (probe.state === 'compatible') { + this.abortPartialSend(closeOpenPaste); + } else { + logger.warn( + `[zmx:${this.sessionName}] skipped partial-send recovery: ` + + `backing identity is ${probe.state}`, + ); + } + } + return false; + } + } + this.requestHistoryCapture(HISTORY_TAIL_DEBOUNCE_MS, true); + return true; + } + + private abortPartialSend(closeOpenPaste: boolean): void { + if (!this.lastOpts) return; + // A failed multi-frame paste may have delivered the opening marker but not + // its close. Best-effort close it first, then cancel the partial composer + // so a later retry cannot append to/trivially submit truncated input. + const recovery = closeOpenPaste ? `${BRACKETED_PASTE_END}\x03` : '\x03'; + try { + this.waitForInjectedCancelCooldown(); + const postCooldownIdentity = this.verifyBackingIdentity('partial-send recovery cooldown'); + if (postCooldownIdentity.state !== 'compatible') { + logger.warn( + `[zmx:${this.sessionName}] skipped partial-send recovery after cooldown: ` + + `backing identity is ${postCooldownIdentity.state}`, + ); + return; + } + this.markAmbiguousGenerationCovered(); + let stdout: string; + try { + stdout = execFileSync('zmx', ['send', this.sessionName], { + input: Buffer.concat([Buffer.from(recovery), Buffer.from('\n')]), + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + timeout: ZMX_COMMAND_TIMEOUT_MS, + maxBuffer: 1024 * 1024, + env: zmxControlEnv(this.lastOpts), + }); + } finally { + // A timeout is ambiguous: the Ctrl+C may have landed at any point + // before the command returned, so start downstream cooldown now. + this.noteInjectedCancelAttemptSettled(); + } + if (stdout.trim()) throw new Error(stdout.trim()); + logger.warn(`[zmx:${this.sessionName}] cancelled a partially delivered input sequence`); + } catch (err) { + logger.error( + `[zmx:${this.sessionName}] unable to cancel partially delivered input; ` + + `manual session recovery may be required: ${err instanceof Error ? err.message : String(err)}`, + ); + } + this.requestHistoryCapture(0, true, true); + } + + private startTail(): void { + this.clearReconnectTimer(); + const epoch = ++this.tailEpoch; + const child = spawn('zmx', ['tail', this.sessionName], { + cwd: this.lastOpts?.cwd, + stdio: ['ignore', 'pipe', 'pipe'], + env: this.lastOpts ? zmxControlEnv(this.lastOpts) : zmxEnv(), + }); + this.tailProcess = child; + this.state = 'observing'; + this.clearStableTailTimer(); + this.stableTailTimer = setTimeout(() => { + this.stableTailTimer = null; + if (epoch === this.tailEpoch && this.tailProcess === child && this.state === 'observing') { + this.reconnectAttempt = 0; + } + }, 5000); + this.stableTailTimer.unref?.(); + let settled = false; + let stderrTail = ''; + + child.stdout?.on('data', (chunk: Buffer | string) => { + if (epoch !== this.tailEpoch || this.tailProcess !== child || this.intentionalExit || this.exited) return; + // Drain the stream, but never expose its bytes. Upstream's ANSI stripper + // currently deletes UTF-8 and can even consume following ASCII; only the + // existence of a chunk is trustworthy enough to wake history capture. + if ((typeof chunk === 'string' ? chunk.length : chunk.byteLength) > 0) { + this.requestHistoryCapture(HISTORY_TAIL_DEBOUNCE_MS, true); + } + }); + child.stderr?.on('data', (chunk: Buffer | string) => { + stderrTail = (stderrTail + chunk.toString()).slice(-4096); + }); + + const finish = (code: number | null, signal: NodeJS.Signals | null, err?: Error) => { + if (settled) return; + settled = true; + if (epoch !== this.tailEpoch || this.tailProcess !== child || this.intentionalExit || this.exited) return; + this.tailProcess = null; + this.state = 'recovering'; + this.clearStableTailTimer(); + if (err || stderrTail.trim()) { + logger.warn( + `[zmx:${this.sessionName}] tail ended: ` + + `${err?.message ?? stderrTail.trim()}`, + ); + } + this.scheduleTailRecovery(code, signal); + }; + child.once('error', err => finish(null, null, err)); + child.once('close', (code, signal) => finish(code, signal)); + } + + private scheduleTailRecovery(code: number | null, signal: string | null): void { + if (this.intentionalExit || this.exited || !this.lastOpts) return; + const delay = Math.min(50 * (2 ** this.reconnectAttempt), TAIL_RECOVERY_DELAY_MAX_MS); + this.reconnectAttempt += 1; + this.clearReconnectTimer(); + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + if (this.intentionalExit || this.exited || !this.lastOpts) return; + const probe = this.verifyBackingIdentityWithClients('tail recovery'); + if (probe.state === 'missing' || probe.state === 'replaced') return; + if (probe.state === 'unknown') { + this.scheduleTailRecovery(code, signal); + return; + } + try { + logger.warn(`[zmx:${this.sessionName}] tail observer exited while session lives; reconnecting`); + this.startTail(); + if (!this.waitForTailClient()) { + throw new Error('replacement tail did not become a connected client'); + } + this.requestHistoryCapture(0, true, true); + } catch (err) { + if (this.intentionalExit || this.exited) return; + this.stopTailForRecovery(); + logger.warn( + `[zmx:${this.sessionName}] tail restart failed: ` + + `${err instanceof Error ? err.message : String(err)}`, + ); + this.scheduleTailRecovery(code, signal); + } + }, delay); + this.reconnectTimer.unref?.(); + } + + private stopTailAfterLaunchFailure(): void { + this.tailEpoch += 1; + this.clearStableTailTimer(); + this.stopHistoryPolling(); + const tail = this.tailProcess; + this.tailProcess = null; + this.state = 'idle'; + try { tail?.kill('SIGTERM'); } catch { /* already gone */ } + } + + private stopTailForRecovery(): void { + this.tailEpoch += 1; + this.clearStableTailTimer(); + const tail = this.tailProcess; + this.tailProcess = null; + this.state = 'recovering'; + try { tail?.kill('SIGTERM'); } catch { /* already gone */ } + } + + private clearReconnectTimer(): void { + if (!this.reconnectTimer) return; + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + + private clearStableTailTimer(): void { + if (!this.stableTailTimer) return; + clearTimeout(this.stableTailTimer); + this.stableTailTimer = null; + } + + private emitData(data: string): void { + if (this.dataCbs.length === 0) { + const next = this.earlyBuffer + data; + if (next.length > EARLY_BUFFER_MAX && this.earlyBuffer.length <= EARLY_BUFFER_MAX) { + logger.warn(`[zmx:${this.sessionName}] early output exceeded 1 MiB; keeping newest text`); + } + this.earlyBuffer = next.slice(-EARLY_BUFFER_MAX); + return; + } + for (const cb of this.dataCbs) { + try { cb(data); } catch { /* listener failure must not kill transport */ } + } + } + + private fireExit(code: number | null, signal: string | null): void { + if (this.exited) return; + // Once the backing daemon is gone, history is no longer queryable. Never + // fall back to tail payload bytes here: upstream may already have deleted + // or corrupted their UTF-8 content. + this.exited = true; + this.state = 'exited'; + this.tailEpoch += 1; + this.clearReconnectTimer(); + this.clearStableTailTimer(); + this.stopHistoryPolling(); + const tail = this.tailProcess; + this.tailProcess = null; + try { tail?.kill('SIGTERM'); } catch { /* already gone */ } + this.pendingExit = { code, signal }; + if (this.exitCbs.length === 0) return; + for (const cb of this.exitCbs) { + try { cb(code, signal); } catch { /* listener failure must not block teardown */ } + } + } +} + +export function buildFreshAttachArgs(sessionName: string, bootstrapPath: string): string[] { + // An existing session ignores this command, which makes attach safer than + // `zmx run` (run is an upsert that can inject into a foreign race winner). + return ['attach', sessionName, '/bin/sh', bootstrapPath]; +} + +function shellSingleQuote(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +/** Render the private bootstrap and payload used by a fresh session. */ +export function buildZmxLaunchFiles( + bin: string, + args: string[], + opts: SpawnOpts, + payloadPath: string, + readyPath: string, + readyNonce: string, + releasePath: string, + releaseToken: string, +): { bootstrap: string; payload: string } { + const shellSpec = resolveUserShell(process.env, opts.launchShell); + const envAssignments = buildBotmuxEnvAssignments(opts.env, opts.injectEnv) + .filter(assignment => !/^ZMX_(?:SESSION|SESSION_PREFIX)=/.test(assignment)); + const debugKeepShell = process.env.BOTMUX_DEBUG_KEEP_SHELL === '1'; + const wrapped = debugKeepShell + ? buildDebugKeepShellScript(shellSpec.shell) + : SHELL_WRAPPER_SCRIPT; + const payloadArgv = [opts.cwd, ...envAssignments, bin, ...args]; + const payload = `set -- ${payloadArgv.map(shellSingleQuote).join(' ')}\n`; + const userScript = [ + 'payload=$1', + 'if [ ! -r "$payload" ]; then printf "[botmux] ZMX launch payload unavailable\\n" >&2; exit 126; fi', + '. "$payload" || exit 126', + 'rm -f -- "$payload"', + 'unset payload ZMX_SESSION ZMX_SESSION_PREFIX', + wrapped, + ].join('\n'); + const shellCommand = [ + shellSingleQuote(shellSpec.shell), + ...shellSpec.flags.map(shellSingleQuote), + '-c', shellSingleQuote(userScript), + '_', shellSingleQuote(payloadPath), + ].join(' '); + const launchDir = payloadPath.slice(0, payloadPath.lastIndexOf('/')); + const cliPidPath = join(launchDir, 'cli-pid'); + const gateScript = [ + 'payload_path=$1', + 'ready_path=$2', + 'release_path=$3', + 'cli_pid_path=$4', + 'ready_nonce=$5', + 'release_token=$6', + 'printf \'%s\\n\' "$$" > "$cli_pid_path" || exit 126', + 'printf \'%s\\n\' "$ready_nonce" > "$ready_path" || exit 126', + 'attempt=0', + `while [ ! -r "$release_path" ] && [ "$attempt" -lt ${Math.ceil(FRESH_RELEASE_TIMEOUT_MS / 100)} ]; do`, + ' sleep 0.1', + ' attempt=$((attempt + 1))', + 'done', + '[ -r "$release_path" ] || exit 75', + 'release_value=', + 'IFS= read -r release_value < "$release_path" || [ -n "$release_value" ]', + '[ "$release_value" = "$release_token" ] || exit 75', + 'rm -f -- "$ready_path" "$release_path"', + 'unset ready_path release_path release_value attempt cli_pid_path ready_nonce release_token', + 'trap - 1 2 15', + // Keep the ZMX forkpty slave inherited on fd 0. Reopening `/dev/tty` + // produces a descriptor that Darwin kqueue rejects with EINVAL, so + // crossterm/mio event readers fail to initialize even though isatty(0) + // still reports true. The release-file read above redirects only that one + // command and automatically restores the inherited PTY afterwards. + `exec ${shellCommand}`, + ].join('\n'); + const gateCommand = [ + '/bin/sh', + '-c', shellSingleQuote(gateScript), + '_', + '"$payload_path"', + '"$ready_path"', + '"$release_path"', + '"$cli_pid_path"', + '"$ready_nonce"', + '"$release_token"', + ].join(' '); + const bootstrap = [ + '#!/bin/sh', + 'umask 077', + 'self=$0', + 'rm -f -- "$self"', + 'unset self ZMX_SESSION ZMX_SESSION_PREFIX', + `payload_path=${shellSingleQuote(payloadPath)}`, + `ready_path=${shellSingleQuote(readyPath)}`, + `release_path=${shellSingleQuote(releasePath)}`, + `cli_pid_path=${shellSingleQuote(cliPidPath)}`, + `ready_nonce=${shellSingleQuote(readyNonce)}`, + `release_token=${shellSingleQuote(releaseToken)}`, + `launch_dir=${shellSingleQuote(launchDir)}`, + 'cleanup_launch() {', + ' rm -f -- "$ready_path" "$release_path" "$cli_pid_path" "$payload_path"', + ' rmdir -- "$launch_dir" 2>/dev/null || true', + '}', + // Do not exec away the PTY-root bootstrap. ZMX destroys history together + // with that root process, so retaining it for a short bounded grace after + // the real CLI exits gives the authoritative history poller time to read + // final output (including pure Unicode that tail cannot signal reliably). + // A foreground gate child keeps the normal SIGINT disposition (POSIX async + // jobs may inherit SIGINT ignored), while retaining one stable PID through + // gate -> user shell -> env -> CLI exec. The parent validates and labels + // that PID before atomically publishing release. + 'is_direct_child() {', + ' for ps_bin in /usr/bin/ps /bin/ps; do', + ' [ -x "$ps_bin" ] || continue', + ' child_parent=$("$ps_bin" -o ppid= -p "$child_pid" 2>/dev/null) || continue', + ' [ "$child_parent" -eq "$$" ] 2>/dev/null && return 0', + ' done', + ' return 1', + '}', + 'forward_stop() {', + ' trap - 1 15', + ' child_pid=', + ' IFS= read -r child_pid < "$cli_pid_path" 2>/dev/null || true', + ' if [ -n "$child_pid" ] && is_direct_child; then', + ' kill -HUP "$child_pid" 2>/dev/null || true', + ' kill -HUP -- "-$child_pid" 2>/dev/null || true', + ' sleep 0.15', + ' if is_direct_child; then', + ' kill -KILL "$child_pid" 2>/dev/null || true', + ' kill -KILL -- "-$child_pid" 2>/dev/null || true', + ' fi', + ' fi', + ' cleanup_launch', + ' exit 75', + '}', + "trap 'forward_stop' 1 15", + "trap ':' 2", + gateCommand, + 'cli_status=$?', + // Do not retain a dead PID during the grace period: an external HUP must + // never target an unrelated process that reused the numeric pid. + 'rm -f -- "$cli_pid_path"', + `while ! sleep ${ZMX_EXIT_HISTORY_GRACE_SECONDS}; do :; done`, + 'cleanup_launch', + 'trap - 1 2 15', + 'exit "$cli_status"', + '', + ].join('\n'); + return { bootstrap, payload }; +} + +function createZmxLaunchPayload(bin: string, args: string[], opts: SpawnOpts): ZmxLaunchPayload { + const dir = mkdtempSync(join(tmpdir(), 'botmux-zmx-launch-')); + chmodSync(dir, 0o700); + const bootstrapPath = join(dir, 'bootstrap.sh'); + const payloadPath = join(dir, 'payload.sh'); + const readyPath = join(dir, 'ready'); + const releasePath = join(dir, 'release'); + const releaseTempPath = join(dir, 'release.tmp'); + const cliPidPath = join(dir, 'cli-pid'); + const readyNonce = randomBytes(16).toString('hex'); + const releaseToken = randomBytes(16).toString('hex'); + const cleanup = () => { + for (const path of [readyPath, releasePath, releaseTempPath, cliPidPath, payloadPath, bootstrapPath]) { + try { rmSync(path, { force: true }); } catch { /* already consumed */ } + } + try { rmdirSync(dir); } catch { /* live bootstrap still owns the directory */ } + }; + try { + const files = buildZmxLaunchFiles( + bin, + args, + opts, + payloadPath, + readyPath, + readyNonce, + releasePath, + releaseToken, + ); + writeFileSync(payloadPath, files.payload, { mode: 0o600, flag: 'wx' }); + writeFileSync(bootstrapPath, files.bootstrap, { mode: 0o600, flag: 'wx' }); + return { + dir, + bootstrapPath, + readyPath, + readyNonce, + releasePath, + releaseTempPath, + cliPidPath, + releaseToken, + cleanup, + }; + } catch (err) { + cleanup(); + throw err; + } +} + +/** Strip every payload-delivered key from ZMX control subprocesses. */ +export function zmxControlEnv(opts: SpawnOpts): NodeJS.ProcessEnv { + const env = zmxEnv(opts.env); + for (const assignment of buildBotmuxEnvAssignments(opts.env, opts.injectEnv)) { + const equals = assignment.indexOf('='); + if (equals > 0) delete env[assignment.slice(0, equals)]; + } + if (opts.injectEnv) { + for (const key of Object.keys(opts.injectEnv)) delete env[key]; + } + return env; +} + +export function parseZmxList(output: string): { + sessions: string[]; + unhealthySessions: string[]; + malformedLines: string[]; +} { + const sessions: string[] = []; + const unhealthySessions: string[] = []; + const malformedLines: string[] = []; + let sawRecord = false; + for (const line of output.split('\n')) { + if (!line.trim()) continue; + const looksLikeRecord = /^\s*name=[^\t]*\t(?:pid=\d+(?:\t|$)|err=)/.test(line); + if (!looksLikeRecord) { + // Full-list cmd= is verbatim and may contain literal newlines. Once a + // record starts, continuation text is opaque even when it says name=. + if (!sawRecord) malformedLines.push(line); + continue; + } + sawRecord = true; + const row = parseZmxListRow(line); + if (!row) malformedLines.push(line); + else if (row.state === 'unhealthy') unhealthySessions.push(row.name); + else sessions.push(row.name); + } + return { sessions, unhealthySessions, malformedLines }; +} + +export function parseZmxShortList(output: string): { + sessions: string[]; + malformedLines: string[]; +} { + const sessions: string[] = []; + const malformedLines: string[] = []; + const seen = new Set(); + for (const raw of output.split('\n')) { + const name = raw.endsWith('\r') ? raw.slice(0, -1) : raw; + if (!name) continue; + if (/[\t\x00-\x1f\x7f]/.test(name) || seen.has(name)) { + malformedLines.push(raw); + continue; + } + seen.add(name); + sessions.push(name); + } + return { sessions, malformedLines }; +} + +function parseZmxListRow(line: string): { + name: string; + state: 'healthy' | 'unhealthy'; + pid?: number; + clients?: number; + command?: string; +} | null { + const fields = line.replace(/^\s*/, '').split('\t'); + const nameField = fields[0]; + const name = nameField?.startsWith('name=') ? nameField.slice('name='.length) : ''; + const status = fields[1]; + if (!name || /[\x00-\x1f\x7f]/.test(name) || !status) return null; + const pid = status.match(/^pid=(\d+)$/)?.[1]; + if (pid) { + const clients = fields.map(field => field.match(/^clients=(\d+)$/)?.[1]).find(Boolean); + const command = fields.find(field => field.startsWith('cmd='))?.slice('cmd='.length); + return { + name, + state: 'healthy', + pid: Number(pid), + clients: clients === undefined ? undefined : Number(clients), + command, + }; + } + if (/^err=/.test(status)) return { name, state: 'unhealthy' }; + return null; +} + +function sessionDetails( + sessionName: string, + env: NodeJS.ProcessEnv = zmxEnv(), +): ZmxSessionDetails | null { + const probe = ZmxBackend.probeSessions(env); + return probe.ok ? sessionDetailsFromSnapshot(probe, sessionName) : null; +} + +function sessionDetailsFromSnapshot( + probe: ZmxSessionProbeResult, + sessionName: string, +): ZmxSessionDetails | null { + if (!probe.sessions.includes(sessionName)) return null; + const rows = probe.raw + .split('\n') + .map(parseZmxListRow) + .filter((row): row is NonNullable => + row?.name === sessionName && row.state === 'healthy' && !!row.pid, + ); + if (rows.length !== 1) return null; + const row = rows[0]!; + return { + name: row.name, + pid: row.pid!, + clients: row.clients ?? null, + command: row.command ?? null, + }; +} + +export function findSessionPid(sessionName: string): number | null { + return sessionDetails(sessionName)?.pid ?? null; +} + +export function tmuxKeyToBytes(key: string): string { + const named: Record = { + Enter: '\r', + Tab: '\t', + Escape: '\x1b', + Esc: '\x1b', + Space: ' ', + BSpace: '\x7f', + Backspace: '\x7f', + Up: '\x1b[A', + Down: '\x1b[B', + Right: '\x1b[C', + Left: '\x1b[D', + Home: '\x1b[H', + End: '\x1b[F', + PageUp: '\x1b[5~', + PPage: '\x1b[5~', + PageDown: '\x1b[6~', + NPage: '\x1b[6~', + 'M-Enter': '\x1b\r', + }; + if (key in named) return named[key]!; + + const ctrl = key.match(/^C-([A-Za-z])$/); + if (ctrl) return String.fromCharCode(ctrl[1]!.toLowerCase().charCodeAt(0) - 96); + const meta = key.match(/^M-(.)$/); + if (meta) return `\x1b${meta[1]}`; + return key; +} diff --git a/src/adapters/cli/oh-my-pi.ts b/src/adapters/cli/oh-my-pi.ts index 5930e44a8..2fc04ddca 100644 --- a/src/adapters/cli/oh-my-pi.ts +++ b/src/adapters/cli/oh-my-pi.ts @@ -1,13 +1,13 @@ import { resolveCommand } from './registry.js'; import { BOTMUX_SHELL_HINTS } from './shared-hints.js'; import type { CliAdapter, PtyHandle } from './types.js'; +import { TERMINAL_CANCEL_COOLDOWN_MS } from '../backend/critical-control-key.js'; import { delay } from '../../utils/timing.js'; const OMP_INPUT_CHUNK_CHARS = 512; const OMP_INPUT_CHUNK_NEWLINES = 9; const OMP_INPUT_THROTTLE_MS = 20; -const OMP_CLEAR_COOLDOWN_MS = 550; const BRACKETED_PASTE_START = '\x1b[200~'; const BRACKETED_PASTE_END = '\x1b[201~'; @@ -95,8 +95,13 @@ export function createOhMyPiAdapter(pathOverride?: string): CliAdapter { const clearComposer = async (pty: PtyHandle): Promise => { // OMP treats a second Ctrl+C within 500 ms as exit. Keep recovery clears - // outside that window even when consecutive terminal writes fail quickly. - const waitMs = OMP_CLEAR_COOLDOWN_MS - (Date.now() - lastClearAttemptAt); + // outside that window even when the backend already injected the first one + // while recovering an ambiguous ZMX frame. + const mostRecentCancelAt = Math.max( + lastClearAttemptAt, + pty.lastInjectedCancelAt ?? 0, + ); + const waitMs = TERMINAL_CANCEL_COOLDOWN_MS - (Date.now() - mostRecentCancelAt); if (waitMs > 0) await delay(waitMs); lastClearAttemptAt = Date.now(); try { diff --git a/src/adapters/cli/strict-input-handle.ts b/src/adapters/cli/strict-input-handle.ts new file mode 100644 index 000000000..e6f828bd8 --- /dev/null +++ b/src/adapters/cli/strict-input-handle.ts @@ -0,0 +1,41 @@ +import type { PtyHandle } from './types.js'; + +const cachedHandles = new WeakMap(); + +/** + * Turn boolean write rejection into an exception at the submit boundary. + * + * Several established CLI adapters intentionally ignore the optional boolean + * returned by sendText/sendSpecialKeys because PTY/tmux historically either + * wrote or threw. Snapshot transports can return false for an ambiguous, + * non-retriable send. Wrapping only adapter submission keeps those failures on + * worker's existing notification path without making best-effort navigation + * and startup-control keystrokes throw from unrelated event callbacks. + */ +export function strictInputHandle(pty: T): T { + const cached = cachedHandles.get(pty as object); + if (cached) return cached as T; + + const proxy = new Proxy(pty as T & object, { + get(target, property) { + const value = Reflect.get(target, property, target); + if (property === 'sendText' || property === 'sendSpecialKeys') { + if (typeof value !== 'function') return value; + return (...args: unknown[]) => { + const result = Reflect.apply(value, target, args); + if (result === false) { + throw new Error(`backend rejected ${String(property)} during prompt submission`); + } + return result; + }; + } + return typeof value === 'function' ? value.bind(target) : value; + }, + set(target, property, value) { + return Reflect.set(target, property, value, target); + }, + }) as T; + + cachedHandles.set(pty as object, proxy); + return proxy; +} diff --git a/src/adapters/cli/types.ts b/src/adapters/cli/types.ts index b8701e3de..21f3128da 100644 --- a/src/adapters/cli/types.ts +++ b/src/adapters/cli/types.ts @@ -10,6 +10,12 @@ export interface PtyHandle { /** Send special keys via tmux send-keys, e.g. 'Enter', 'Escape', 'C-c' (tmux mode only). * Returns `false` on a dropped write (see sendText). */ sendSpecialKeys?(...keys: string[]): void | boolean; + /** + * Epoch-ms timestamp of the most recent Ctrl+C the backend may have injected. + * Snapshot transports record this before an ambiguous send so adapters with + * double-Ctrl+C exit gestures can keep their own recovery outside the window. + */ + readonly lastInjectedCancelAt?: number; /** Paste text via tmux load-buffer + paste-buffer (auto-brackets if terminal supports it). */ pasteText?(text: string): void; /** Absolute path to Claude Code's session JSONL; set by worker for claude-code adapter. diff --git a/src/bot-registry.ts b/src/bot-registry.ts index 9f8c61149..893a8654f 100644 --- a/src/bot-registry.ts +++ b/src/bot-registry.ts @@ -885,7 +885,7 @@ export interface BotConfig { */ wrapperCli?: string; /** - * Per-bot launch-shell override for the persistent backends (tmux/zellij). + * Per-bot launch-shell override for the persistent backends (tmux/zellij/zmx). * When set, botmux launches the CLI under this shell instead of the daemon's * `$SHELL`. Accepts a bare name (`zsh`/`bash`/`sh`) or an absolute path * (`/usr/bin/zsh`). The escape hatch for a login `$SHELL` (e.g. bash) whose diff --git a/src/cli.ts b/src/cli.ts index b1c8fb8c6..a3ed93ea1 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -13,7 +13,7 @@ * botmux status — show daemon status * botmux upgrade|update — upgrade to latest version * botmux device enroll|status|logout — manage the host desktop device credential - * botmux list — interactive session picker (TUI), attach to tmux + * botmux list — interactive session picker (TUI), attach to managed tmux/ZMX sessions * botmux list --plain — plain table output (for piping / scripts) * botmux delete — close a session by ID prefix * botmux delete all — close all active sessions @@ -38,6 +38,7 @@ import { pickTurnReplyTarget } from './core/reply-target.js'; import { recordDispatchRegistryEntry } from './core/dispatch-registry.js'; import { enableAutostart, disableAutostart, autostartStatus, refreshAutostart } from './autostart.js'; import { tmuxEnv } from './setup/ensure-tmux.js'; +import { zmxEnv } from './setup/ensure-zmx.js'; import { writeBotsJsonAtomic as writeBotsAtomic } from './setup/bots-store.js'; import { applyBotConfigEdits, @@ -76,6 +77,7 @@ import { import { interactiveSelect, pickChoice, pickCliSelection } from './setup/interactive-select.js'; import { buildPreset, serializePreset, presetFilename } from './setup/agent-preset.js'; import type { CliId } from './adapters/cli/types.js'; +import type { BackendType, SessionProbe } from './adapters/backend/types.js'; import { logger } from './utils/logger.js'; import { scrubClaudeSessionMarkerEnv, scrubSessionCliHomeEnv } from './utils/child-env.js'; import { scheduleTimeZone } from './utils/timezone.js'; @@ -164,6 +166,14 @@ import { stopExactPm2Process, type BotmuxPm2Inspection, } from './core/bot-live-control.js'; +import { + isSuspendableBackendType, + killPersistentSession, + persistentSessionName, + probePersistentSession, + probePersistentSessions, + type PersistentBackendType, +} from './core/persistent-backend.js'; // Resolve the CLI's UI locale once from the global config file, so subsequent // CLI output (and any t() callers that don't pass an explicit locale) honour @@ -1333,9 +1343,9 @@ async function promptEditBotConfig( } printInputHelp('会话后端 backendType', [ - '可选。pty 更轻量;tmux 支持 adopt 和 Web Terminal 附着;herdr 支持托管持久会话;zellij 为实验后端(需 zellij >= 0.44)。', + '可选。pty 更轻量;tmux 支持 adopt 和 Web Terminal 附着;herdr 支持托管持久会话;zmx >= 0.7.0 提供纯文本持久会话 + 本机 attach(无 Web TUI);zellij 为实验后端(需 zellij >= 0.44)。', '选择 traex + herdr 时,可在 Dashboard Settings 中开启 TraeX herdr plugin opt-in 并填写可信插件 spec;默认不会自动安装第三方插件。', - '留空保留当前值;输入 - 回到自动检测;接受 pty / tmux / herdr / zellij。', + '留空保留当前值;输入 - 回到全局默认(未设置 BACKEND_TYPE 时为 tmux);接受 pty / tmux / herdr / zellij / zmx。', ]); input.backendType = await ask(rl, `会话后端 backendType [${formatOptionalValue(bot.backendType)}]: `); @@ -3108,6 +3118,7 @@ interface SessionData { // here, so they're typed loosely. Used by cmdList to avoid reporting an // unconfirmed /adopt scratch as a crashed CLI session. cliId?: string; + backendType?: BackendType; lastCliInput?: string; adoptedFrom?: AdoptedFromData; /** Deliberately suspended by the resident-session cap. No process/backing @@ -3343,6 +3354,7 @@ function formatSessionRow( multiBot: boolean, botLabels: Map, cols: { id: number; bot?: number; title: number; dir: number; pid: number; uptime: number; status: number; target: number }, + probeSnapshot: BackingProbeSnapshot, ): { text: string; alive: boolean } { const id = padEndDisplay(s.sessionId.substring(0, 8), cols.id); const parts = [id]; @@ -3357,13 +3369,13 @@ function formatSessionRow( const uptime = formatDuration(Date.now() - new Date(s.createdAt).getTime()).padEnd(cols.uptime); const alive = isSessionAliveForList(s); const status = padEndDisplay(sessionStatusLabel(s), cols.status); - const target = padEndDisplay(truncate(sessionTargetLabel(s), cols.target), cols.target); + const target = padEndDisplay(truncate(sessionTargetLabel(s, probeSnapshot), cols.target), cols.target); parts.push(title, dir, pid, uptime, status, target); return { text: parts.join(' │ '), alive }; } /** Print plain session table (non-interactive). */ -function printSessionTable(active: SessionData[]): void { +function printSessionTable(active: SessionData[], probeSnapshot: BackingProbeSnapshot): void { const botConfigs = loadBotConfigsForDisplay(); const multiBot = botConfigs.length > 1 || new Set(active.map(s => s.larkAppId).filter(Boolean)).size > 1; const botLabels = new Map(); @@ -3392,7 +3404,7 @@ function printSessionTable(active: SessionData[]): void { console.log(separator); for (const s of active) { - const { text } = formatSessionRow(s, multiBot, botLabels, cols); + const { text } = formatSessionRow(s, multiBot, botLabels, cols, probeSnapshot); console.log(text); } @@ -3400,16 +3412,6 @@ function printSessionTable(active: SessionData[]): void { console.log(`共 ${active.length} 个活跃会话`); } -/** Check if a tmux session exists. */ -function tmuxSessionExists(name: string): boolean { - try { - execSync(`tmux has-session -t ${name} 2>/dev/null`, { stdio: 'ignore', env: tmuxEnv() }); - return true; - } catch { - return false; - } -} - function applyTmuxWindowSizeLargest(sessionName: string): void { try { execFileSync('tmux', ['set-option', '-t', sessionName, 'window-size', 'largest'], { @@ -3448,13 +3450,19 @@ function closeSessionOffline(s: SessionData): void { // Adopted panes belong to the user. Ordinary bmx-* sessions are botmux-owned // and still need direct cleanup when no daemon exists to run killWorker(). if (!isAdoptedSession(s)) { - const tmuxName = `bmx-${s.sessionId.substring(0, 8)}`; - try { - execSync(`tmux kill-session -t '${tmuxName}' 2>/dev/null`, { - stdio: 'ignore', - env: tmuxEnv(), - }); - } catch { /* no tmux session */ } + if (isSuspendableBackendType(s.backendType)) { + const name = persistentSessionName(s.backendType, s.sessionId); + try { killPersistentSession(s.backendType, name, s.sessionId); } catch { /* absent or unavailable */ } + } else { + // Legacy rows without backendType were historically tmux-backed. + const tmuxName = `bmx-${s.sessionId.substring(0, 8)}`; + try { + execSync(`tmux kill-session -t '${tmuxName}' 2>/dev/null`, { + stdio: 'ignore', + env: tmuxEnv(), + }); + } catch { /* no tmux session */ } + } } s.status = 'closed'; @@ -3549,14 +3557,104 @@ function sessionStatusLabel(s: SessionData): string { return s.pid && isProcessAlive(s.pid) ? 'online' : s.pid ? 'stopped' : 'idle'; } -function sessionTargetLabel(s: SessionData, tmuxName?: string, hasTmux?: boolean): string { +type BackingProbeSnapshot = ReadonlyMap; + +function backingProbeKey(backendType: PersistentBackendType, name: string): string { + return `${backendType}\0${name}`; +} + +function buildBackingProbeSnapshot(sessions: readonly SessionData[]): BackingProbeSnapshot { + const namesByBackend = new Map>(); + const add = (backendType: PersistentBackendType, name: string) => { + const names = namesByBackend.get(backendType) ?? new Set(); + names.add(name); + namesByBackend.set(backendType, names); + }; + + for (const session of sessions) { + if (isAdoptedSession(session) || session.backendType === 'pty') continue; + if (isSuspendableBackendType(session.backendType)) { + add(session.backendType, persistentSessionName(session.backendType, session.sessionId)); + } else { + // Rows created before backend stamping used tmux as their only + // externally attachable backing session. + add('tmux', `bmx-${session.sessionId.substring(0, 8)}`); + } + } + + const snapshot = new Map(); + for (const [backendType, names] of namesByBackend) { + for (const [name, probe] of probePersistentSessions(backendType, names)) { + snapshot.set(backingProbeKey(backendType, name), probe); + } + } + return snapshot; +} + +function backingProbe( + snapshot: BackingProbeSnapshot | undefined, + backendType: PersistentBackendType, + name: string, +): SessionProbe { + return snapshot?.get(backingProbeKey(backendType, name)) + ?? probePersistentSession(backendType, name); +} + +function sessionBackingInfo(s: SessionData, snapshot?: BackingProbeSnapshot): { + backendType?: BackendType; + name?: string; + probe: 'exists' | 'missing' | 'unknown'; + label: string; + attachBackend?: 'tmux' | 'zmx'; +} { + if (isSuspendableBackendType(s.backendType)) { + const name = persistentSessionName(s.backendType, s.sessionId); + const probe = backingProbe(snapshot, s.backendType, name); + const suffix = probe === 'exists' ? '' : ` (${probe})`; + return { + backendType: s.backendType, + name, + probe, + label: `${s.backendType}: ${name}${suffix}`, + attachBackend: s.backendType === 'tmux' || s.backendType === 'zmx' + ? s.backendType + : undefined, + }; + } + if (s.backendType === 'pty') { + return { backendType: 'pty', probe: 'missing', label: 'pty' }; + } + // Legacy rows predate backend stamping. Only tmux was externally attachable. + const name = `bmx-${s.sessionId.substring(0, 8)}`; + const probe = backingProbe(snapshot, 'tmux', name); + return { + backendType: 'tmux', + name, + probe, + label: probe === 'exists' ? `tmux: ${name}` : '-', + attachBackend: 'tmux', + }; +} + +function sessionTargetLabel(s: SessionData, snapshot?: BackingProbeSnapshot): string { if (isAdoptedSession(s)) return adoptTargetLabel(s); - if (hasTmux === undefined) { - const name = tmuxName ?? `bmx-${s.sessionId.substring(0, 8)}`; - hasTmux = tmuxSessionExists(name); - tmuxName = name; + return sessionBackingInfo(s, snapshot).label; +} + +function hasRecoverableBackingSession(s: SessionData, snapshot?: BackingProbeSnapshot): boolean { + if (isSuspendableBackendType(s.backendType)) { + // Unknown means the backend probe itself was inconclusive; keep the session + // rather than closing a potentially recoverable conversation from `list`. + // ZMX has one daemon per session. A clean "missing" result cannot + // distinguish a host reboot from an individual CLI exit, so keep the + // transcript-backed row for lazy resume instead of auto-pruning it. + if (s.backendType === 'zmx') return true; + const name = persistentSessionName(s.backendType, s.sessionId); + const probe = backingProbe(snapshot, s.backendType, name); + return probe === 'exists' || probe === 'unknown'; } - return hasTmux ? `tmux: ${tmuxName}` : '-'; + // Legacy sessions created before backendType stamping only had tmux recovery. + return backingProbe(snapshot, 'tmux', `bmx-${s.sessionId.substring(0, 8)}`) === 'exists'; } /** Shorten path for display: replace $HOME with ~. */ @@ -3566,7 +3664,7 @@ function shortenPath(p: string): string { } /** Interactive TUI session picker — returns a promise that resolves when done. */ -function interactiveSessionPicker(active: SessionData[]): Promise { +function interactiveSessionPicker(active: SessionData[], probeSnapshot: BackingProbeSnapshot): Promise { const botConfigs = loadBotConfigsForDisplay(); const multiBot = botConfigs.length > 1 || new Set(active.map(s => s.larkAppId).filter(Boolean)).size > 1; const botLabels = new Map(); @@ -3603,17 +3701,19 @@ function interactiveSessionPicker(active: SessionData[]): Promise { session: SessionData; text: string; alive: boolean; - tmuxName: string; - hasTmux: boolean; + backendName?: string; + backingProbe: 'exists' | 'missing' | 'unknown'; + attachBackend?: 'tmux' | 'zmx'; isAdopt: boolean; targetLabel: string; canAttach: boolean; }> { return active.map(s => { - const tmuxName = `bmx-${s.sessionId.substring(0, 8)}`; const isAdopt = isAdoptedSession(s); - const hasTmux = !isAdopt && tmuxSessionExists(tmuxName); - const targetLabel = sessionTargetLabel(s, tmuxName, hasTmux); + const backing = isAdopt + ? { probe: 'missing' as const, label: adoptTargetLabel(s) } + : sessionBackingInfo(s, probeSnapshot); + const targetLabel = backing.label; // Build row text with shortened dir const id = padEndDisplay(s.sessionId.substring(0, 8), cols.id); const parts = [id]; @@ -3631,7 +3731,17 @@ function interactiveSessionPicker(active: SessionData[]): Promise { const target = padEndDisplay(truncate(targetLabel, cols.target), cols.target); parts.push(title, dir, pid, uptime, status, target); - return { session: s, text: parts.join(' │ '), alive, tmuxName, hasTmux, isAdopt, targetLabel, canAttach: hasTmux && !isAdopt }; + return { + session: s, + text: parts.join(' │ '), + alive, + backendName: 'name' in backing ? backing.name : undefined, + backingProbe: backing.probe, + attachBackend: 'attachBackend' in backing ? backing.attachBackend : undefined, + isAdopt, + targetLabel, + canAttach: !isAdopt && backing.probe === 'exists' && !!('attachBackend' in backing && backing.attachBackend), + }; }); } @@ -3692,9 +3802,9 @@ function interactiveSessionPicker(active: SessionData[]): Promise { const selected = rows[cursor]; const targetHint = selected.isAdopt ? `\x1b[33m${selected.targetLabel}\x1b[0m \x1b[2mEnter 已禁用;请直接使用原 tmux/zellij/herdr 客户端。\x1b[0m` - : selected.hasTmux - ? `\x1b[32mtmux: ${selected.tmuxName}\x1b[0m` - : `\x1b[2mtmux: 无会话\x1b[0m`; + : selected.canAttach + ? `\x1b[32m${selected.attachBackend}: ${selected.backendName}\x1b[0m` + : `\x1b[2m${selected.targetLabel}(不可连接)\x1b[0m`; process.stdout.write(`\n ${targetHint}\n`); // Flash message or confirmation prompt @@ -3802,7 +3912,7 @@ function interactiveSessionPicker(active: SessionData[]): Promise { return; } - // Enter — attach to tmux + // Enter — attach to a managed persistent backend. if (key === '\r' || key === '\n') { const selected = rows[cursor]; if (selected.isAdopt) { @@ -3811,16 +3921,26 @@ function interactiveSessionPicker(active: SessionData[]): Promise { return; } if (!selected.canAttach) { - flashMsg = '\x1b[33m该会话没有 tmux,无法连接\x1b[0m'; + flashMsg = '\x1b[33m该会话没有可连接的持久后端\x1b[0m'; render(); return; } - applyTmuxWindowSizeLargest(selected.tmuxName); cleanup(); - spawnSync('tmux', ['attach-session', '-t', selected.tmuxName], { - stdio: 'inherit', - env: tmuxEnv(), - }); + if (selected.attachBackend === 'zmx') { + // The sentinel is ignored by an existing session. If it disappears + // between probe and attach, it exits immediately instead of leaving + // an accidental login shell behind. + spawnSync('zmx', ['attach', selected.backendName!, '/bin/sh', '-c', 'exit 75'], { + stdio: 'inherit', + env: zmxEnv(), + }); + } else { + applyTmuxWindowSizeLargest(selected.backendName!); + spawnSync('tmux', ['attach-session', '-t', `=${selected.backendName!}`], { + stdio: 'inherit', + env: tmuxEnv(), + }); + } resolve(); return; } @@ -3831,8 +3951,13 @@ function interactiveSessionPicker(active: SessionData[]): Promise { async function cmdList(): Promise { const sessions = loadSessions(); const active = [...sessions.values()].filter(s => s.status === 'active'); + // One immutable control-plane snapshot per invocation. In particular, ZMX's + // full-list probe walks every per-session daemon, so running it once per row + // would make a large session list quadratic and amplify socket timeouts. + const probeSnapshot = buildBackingProbeSnapshot(active); - // Auto-prune unrecoverable sessions: process dead and no tmux session. + // Auto-prune unrecoverable sessions: process dead and no recoverable backing + // session (tmux/herdr/zellij/zmx). // Split into two buckets so a never-activated daemon-command scratch (e.g. an // unconfirmed /adopt that only posted a picker card, /help, an abandoned // /relay picker) isn't reported as a crashed CLI. Such a scratch never forked @@ -3858,24 +3983,37 @@ async function cmdList(): Promise { } const hasPid = !!(s.pid && isProcessAlive(s.pid)); - const hasTmux = tmuxSessionExists(`bmx-${s.sessionId.substring(0, 8)}`); - const disposition = sessionListDisposition(s, { hasPid, hasBackingSession: hasTmux }); + const hasBackingSession = hasRecoverableBackingSession(s, probeSnapshot); + const disposition = sessionListDisposition(s, { hasPid, hasBackingSession }); if (disposition === 'prune_real') pruned.push(s); else if (disposition === 'prune_scratch') prunedScratch.push(s); else live.push(s); } - const closeNow = (arr: SessionData[]) => { + const closeNow = async (arr: SessionData[], kind: 'scratch' | 'real'): Promise => { + let closed = 0; for (const s of arr) { - s.status = 'closed'; - s.closedAt = new Date().toISOString(); - saveSession(s); + const result = await closeSessionForDelete(s); + if (result.ok) { + closed++; + } else { + // Keep it visible: mutating only the store while a possible owner + // daemon still has the row in memory lets the next message resurrect + // exactly the session auto-prune claimed to close. + live.push(s); + console.warn( + `⚠️ 未自动清理 ${kind === 'scratch' ? '占位' : '会话'} ${s.sessionId.substring(0, 8)}:${result.error}`, + ); + } } + return closed; }; // Scratches: close silently — they were placeholders, not dead sessions. - closeNow(prunedScratch); + await closeNow(prunedScratch, 'scratch'); if (pruned.length > 0) { - closeNow(pruned); - console.log(`已自动清理 ${pruned.length} 个不可恢复的会话(进程已退出或无可恢复后端)`); + const closed = await closeNow(pruned, 'real'); + if (closed > 0) { + console.log(`已自动清理 ${closed} 个不可恢复的会话(进程已退出或无可恢复后端)`); + } } // Sort by creation time, newest first @@ -3888,12 +4026,12 @@ async function cmdList(): Promise { // Non-TTY (piped output) or explicit --plain flag: plain table if (!process.stdout.isTTY || process.argv.includes('--plain')) { - printSessionTable(live); + printSessionTable(live, probeSnapshot); return; } // Interactive TUI - await interactiveSessionPicker(live); + await interactiveSessionPicker(live, probeSnapshot); } async function cmdDelete(): Promise { @@ -3905,6 +4043,7 @@ async function cmdDelete(): Promise { const sessions = loadSessions(); const active = [...sessions.values()].filter(s => s.status === 'active'); + const probeSnapshot = buildBackingProbeSnapshot(active); if (active.length === 0) { console.log('没有活跃会话。'); @@ -3928,8 +4067,7 @@ async function cmdDelete(): Promise { return pid ? !isProcessAlive(pid) : !(s.pid && isProcessAlive(s.pid)); } const hasPid = !!(s.pid && isProcessAlive(s.pid)); - const hasTmux = tmuxSessionExists(`bmx-${s.sessionId.substring(0, 8)}`); - return !hasPid && !hasTmux; + return !hasPid && !hasRecoverableBackingSession(s, probeSnapshot); }); if (toDelete.length === 0) { console.log('没有 stopped 状态的会话。'); @@ -4187,24 +4325,19 @@ async function cmdRoleSwitch(argv: string[]): Promise { * so SESSION_DATA_DIR / breadcrumb-overridden deployments find the right * descriptor directory. */ -function listOnlineDaemons(): Array<{ +interface DaemonDescriptorLite { ipcPort: number; larkAppId: string; + pid?: number; bootInstanceId?: string; workflowIpcProtocol?: string; lastHeartbeat?: number; -}> { +} + +function listDaemonDescriptors(): DaemonDescriptorLite[] { const regDir = join(resolveDataDir(), 'dashboard-daemons'); if (!existsSync(regDir)) return []; - const STALE_MS = 90_000; - const now = Date.now(); - const all: Array<{ - ipcPort: number; - larkAppId: string; - bootInstanceId?: string; - workflowIpcProtocol?: string; - lastHeartbeat?: number; - }> = []; + const all: DaemonDescriptorLite[] = []; let names: string[] = []; try { names = readdirSync(regDir); } catch { return []; } for (const f of names) { @@ -4212,29 +4345,30 @@ function listOnlineDaemons(): Array<{ try { const d = JSON.parse(readFileSync(join(regDir, f), 'utf-8')); if (typeof d?.ipcPort !== 'number' || typeof d?.larkAppId !== 'string') continue; - if (now - (d.lastHeartbeat ?? 0) > STALE_MS) continue; all.push({ ipcPort: d.ipcPort, larkAppId: d.larkAppId, + ...(typeof d.pid === 'number' ? { pid: d.pid } : {}), ...(typeof d.bootInstanceId === 'string' && d.bootInstanceId ? { bootInstanceId: d.bootInstanceId } : {}), ...(typeof d.workflowIpcProtocol === 'string' && d.workflowIpcProtocol ? { workflowIpcProtocol: d.workflowIpcProtocol } : {}), - lastHeartbeat: d.lastHeartbeat, + ...(typeof d.lastHeartbeat === 'number' ? { lastHeartbeat: d.lastHeartbeat } : {}), }); } catch { /* skip malformed */ } } return all; } -function findDaemon(larkAppId?: string): { - ipcPort: number; - larkAppId: string; - bootInstanceId?: string; - workflowIpcProtocol?: string; -} | null { +function listOnlineDaemons(): DaemonDescriptorLite[] { + const STALE_MS = 90_000; + const now = Date.now(); + return listDaemonDescriptors().filter(d => now - (d.lastHeartbeat ?? 0) <= STALE_MS); +} + +function findDaemon(larkAppId?: string): DaemonDescriptorLite | null { const all = listOnlineDaemons(); if (larkAppId) return all.find(d => d.larkAppId === larkAppId) ?? null; return all[0] ?? null; @@ -4669,6 +4803,8 @@ async function cmdResume(): Promise { console.error('❌ adopt 接管会话不支持 resume。'); } else if (errCode === 'deferred_unmaterialized') { console.error('❌ 该静默定时轮次未创建话题,隐藏会话只保留审计记录,不能 resume。'); + } else if (errCode === 'resume_cancelled') { + console.error('❌ 恢复过程中会话被关闭,本次 resume 已取消。'); } else { console.error(`❌ 恢复失败: ${errCode}`); } @@ -4769,6 +4905,8 @@ async function cmdTermLink(rest: string[]): Promise { console.error('❌ daemon 中该会话非活跃,无法获取可操作终端。'); } else if (errCode === 'terminal_unavailable') { console.error('❌ 该会话终端尚未就绪(worker 未起或缺 token)。等会话起来再试。'); + } else if (errCode === 'terminal_unsupported') { + console.error('❌ 该会话后端不提供 Web 终端;ZMX 会话请在本机运行 botmux list 或 zmx attach。'); } else if (errCode === 'no_owner') { console.error('❌ 该 bot 未配置 owner(allowedUsers 为空 / 全开放模式),没有可私密投递的对象。'); } else if (errCode === 'delivery_failed') { diff --git a/src/cli/session-list-liveness.ts b/src/cli/session-list-liveness.ts index c212b365d..fc96cd1e9 100644 --- a/src/cli/session-list-liveness.ts +++ b/src/cli/session-list-liveness.ts @@ -12,7 +12,7 @@ export type SessionListDisposition = 'keep' | 'prune_real' | 'prune_scratch'; * process/backing-session probes have already been evaluated by the caller. * * A cap-suspended session deliberately has neither a process PID nor a backing - * tmux/herdr/zellij session: that is how its memory is reclaimed. The persisted + * tmux/herdr/zellij/zmx session: that is how its memory is reclaimed. The persisted * cold-resume marker is therefore authoritative and must beat the generic * zombie heuristic. */ diff --git a/src/core/command-handler.ts b/src/core/command-handler.ts index edc23c3e3..6c91e5d99 100644 --- a/src/core/command-handler.ts +++ b/src/core/command-handler.ts @@ -24,7 +24,7 @@ import { chatAppLink, normalizeBrand } from '../im/lark/lark-hosts.js'; import { claimPairing } from '../services/pairing-store.js'; import { logger } from '../utils/logger.js'; import { scheduleTimeZone } from '../utils/timezone.js'; -import { killWorker, suspendWorker, forkWorker, forkAdoptWorker, adoptSandboxBlocked, getCurrentCliVersion, postFreshStreamingCard, postPrivateSnapshotCard, resolvePrivateCardAudience, deliverEphemeralOrReply, deliverWritableTerminalCardTo, requestSessionRestart } from './worker-pool.js'; +import { killWorker, suspendWorker, forkWorker, forkAdoptWorker, adoptSandboxBlocked, closeSession, teardownAuthoritativePersistentBackingBeforeClose, getCurrentCliVersion, postFreshStreamingCard, postPrivateSnapshotCard, resolvePrivateCardAudience, deliverEphemeralOrReply, deliverWritableTerminalCardTo, requestSessionRestart } from './worker-pool.js'; import { expandHome, getSessionWorkingDir, @@ -1209,7 +1209,9 @@ export async function handleTermLinkCommand( } const channel = await deliverWritableTerminalCardTo(ds, senderOpenId); - if (channel === 'not_ready') { + if (channel === 'unsupported') { + await reply(t('cmd.term.unsupported', undefined, loc)); + } else if (channel === 'not_ready') { await reply(t('cmd.term.not_ready', undefined, loc)); } else if (channel === 'failed') { await reply(t('cmd.term.failed', undefined, loc)); @@ -1277,9 +1279,18 @@ export async function handleCommand( // Capture the closed-session card BEFORE killWorker/closeSession — // it reads the live session's identity off `ds`. const card = buildClosedSessionCard(ds, loc); - killWorker(ds); - sessionStore.closeSession(ds.session.sessionId); - activeSessions.delete(sessionKey(rootId, larkAppId!)); + const activeKey = sessionKey(rootId, larkAppId!); + try { + await closeSession(ds.session.sessionId); + } catch (err) { + logger.error(`[${logTag}] Refused /close because backing teardown was not verified: ${err}`); + await sessionReply( + rootId, + `⚠️ 会话关闭失败,已保留 active 记录以便重试:${err instanceof Error ? err.message : String(err)}`, + ); + break; + } + if (activeSessions.get(activeKey) === ds) activeSessions.delete(activeKey); // 「会话已关闭」卡片优先「仅自己可见」:普通群里走 ephemeral 只发给执行 // /close 的本人;话题群不支持 ephemeral(18053) 时回退为正常的群内可见回复 // ——与流式卡片上「关闭会话」按钮的送达方式保持一致。 @@ -1408,7 +1419,7 @@ export async function handleCommand( await sessionReply(rootId, t('cmd.rename.usage', undefined, loc)); break; } - const updated = updateSessionTitle(ds.session, rawTitle); + const updated = updateSessionTitle(ds.session, rawTitle, 'user'); if (!updated.ok) { await sessionReply(rootId, t('cmd.rename.usage', undefined, loc)); break; @@ -1428,7 +1439,6 @@ export async function handleCommand( logger.info(`[${logTag}] Session renamed by /rename: ${updated.title} (agentSync=${agentSync.status})`); break; } - case '/repo': { const repoArg = message.content.replace(/^\/repo\s*/, '').trim(); @@ -1616,6 +1626,18 @@ export async function handleCommand( // anchor — expected; `/close` the new one first, or use the // command.) Mirrors the `/close` case above. // + // ZMX close is identity/generation verified and may refuse. Prove + // teardown before claiming the card or mutating any state so a + // refusal leaves the current session fully retryable. + try { + teardownAuthoritativePersistentBackingBeforeClose(ds!); + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + logger.warn(`[${logTag}] Repo switch refused because backing teardown was not proven: ${reason}`); + await sessionReply(rootId, t('cmd.repo.switch_close_failed', { error: reason }, loc)); + return false; + } + // Claim any open repo card BEFORE killWorker / await so a concurrent // card click cannot double-switch while this text path runs. // @@ -1625,6 +1647,8 @@ export async function handleCommand( // the new repo's cwd. The new repo is pinned onto the fresh session // below instead. const claimedCard = claimCurrentRepoCard(ds!, undefined); + const oldSession = ds!.session; + const oldSessionId = oldSession.sessionId; const closedCard = buildClosedSessionCard(ds!, loc); killWorker(ds!); sessionStore.closeSession(ds!.session.sessionId); @@ -1636,7 +1660,12 @@ export async function handleCommand( () => sessionReply(rootId, closedCard, 'interactive'), ); - const oldSession = ds!.session; + const stillOwnsGeneration = activeSessions.get(sessionKey(rootId, larkAppId!)) === ds + && ds!.session === oldSession; + if (!stillOwnsGeneration) { + logger.warn(`[${logTag}] Repo switch cancelled after closing ${oldSessionId.substring(0, 8)}; routing generation changed during card delivery`); + return false; + } // `rootId` is the routing anchor. For chat-scope sessions it is the // `oc_...` chat id, not the traceable `om_...` message root stored on // Session. Preserve the old identity and explicitly persist scope so diff --git a/src/core/dashboard-ipc-server.ts b/src/core/dashboard-ipc-server.ts index 5af39699a..2b910f7f0 100644 --- a/src/core/dashboard-ipc-server.ts +++ b/src/core/dashboard-ipc-server.ts @@ -66,7 +66,7 @@ import { readGlobalConfig } from '../global-config.js'; import { normalizeChatReplyMode, setChatReplyMode, type ChatReplyMode } from '../services/chat-reply-mode-store.js'; import * as chatFirstSeenStore from '../services/chat-first-seen-store.js'; import * as scheduler from './scheduler.js'; -import { listActiveSessions, findActiveBySessionId, closeSession, getActiveSessionsRegistry, transferSession, deliverWriteLinkCardToOwners, forkWorker, suspendWorker, killWorker } from './worker-pool.js'; +import { listActiveSessions, findActiveBySessionId, closeSession, getActiveSessionsRegistry, transferSession, deliverWriteLinkCardToOwners, forkWorker, suspendWorker, killWorker, sessionSupportsWebTerminal } from './worker-pool.js'; import { listOnlineDaemons } from '../utils/daemon-discovery.js'; import { isSessionStopped } from './session-liveness.js'; import { getChatMode, replyMessage, sendMessage, resolveUnionIdFromOpenId, listThreadMessages, listChatMessages, listChatBotMembers, getUserProfile, getUserProfileStrict, resolveAllowedUsersWithMap, type ChatBotMember } from '../im/lark/client.js'; @@ -136,7 +136,7 @@ import { validateSlashInjection } from './slash-inject.js'; import { validateRoleLibraryPath } from './role-library.js'; import { repinSessionWorkingDir } from './session-cwd.js'; import { authorizeSessionScopedIpc } from './daemon-ipc-session-auth.js'; -import { updateSessionTitle } from './session-title.js'; +import { normalizeSessionTitleSource, updateSessionTitle } from './session-title.js'; import { requestAgentSessionRename } from './session-rename.js'; import type { DaemonToWorker, ScheduledTask, ParsedSchedule, ScheduleExecutionPosition, Session } from '../types.js'; import { sessionAnchorId, type DaemonSession } from './types.js'; @@ -853,6 +853,11 @@ function findSessionRecord(sessionId: string): Session | undefined { return findActiveBySessionId(sessionId)?.session ?? sessionStore.getSession(sessionId); } +/** Mutating IPC routes may only touch this daemon's own bot-partitioned store. */ +function findOwnedSessionRecord(sessionId: string): Session | undefined { + return findActiveBySessionId(sessionId)?.session ?? sessionStore.getOwnedSession(sessionId); +} + /** Four-state async lookup with durable fallback (design A). * * In-memory `asyncTriggerResults` lives only on the active DaemonSession and is @@ -922,7 +927,7 @@ ipcRoute('POST', '/api/sessions/:sessionId/board', async (req, res, params) => { const column = normalizeKanbanColumn(body.column); const position = normalizeKanbanPosition(body.position); if (!column && position === null) return jsonRes(res, 400, { ok: false, error: 'bad_request' }); - const session = findSessionRecord(params.sessionId); + const session = findOwnedSessionRecord(params.sessionId); if (!session) return jsonRes(res, 404, { ok: false, error: 'session_not_found' }); // 待办池(queued)会话被拖到「进行中」= 激活:把暂存内容当首轮发给 CLI 开跑。 // activateQueuedSession 内部会清 queued + 把列设成 in_progress + forkWorker。 @@ -1219,19 +1224,28 @@ ipcRoute('GET', '/api/owner-profile', async (_req, res) => { // Codex/Claude Code 再收到一条 best-effort 原生 /rename,同步其 resume picker。 // 飞书话题标题不受影响。全视图(看板/状态板/表格/抽屉)读同一字段。 ipcRoute('POST', '/api/sessions/:sessionId/rename', async (req, res, params) => { - let body: { title?: unknown }; + let body: { title?: unknown; source?: unknown } & Record; try { body = await readJsonBody(req); } catch { return jsonRes(res, 400, { ok: false, error: 'bad_json' }); } + const active = findActiveBySessionId(params.sessionId); + const auth = sessionCliIpcAuth(req, active, params.sessionId, body); + if (!auth.ok) return jsonRes(res, 403, { ok: false, error: auth.error }); const title = normalizeSessionTitle(body.title); if (!title) return jsonRes(res, 400, { ok: false, error: 'bad_title' }); - const active = findActiveBySessionId(params.sessionId); - const session = active?.session ?? sessionStore.getSession(params.sessionId); + const session = active?.session ?? sessionStore.getOwnedSession(params.sessionId); if (!session) return jsonRes(res, 404, { ok: false, error: 'session_not_found' }); - const updated = updateSessionTitle(session, title); + const source = normalizeSessionTitleSource(body.source, 'dashboard'); + const updated = updateSessionTitle(session, title, source); if (!updated.ok) return jsonRes(res, 400, { ok: false, error: updated.error }); const agentSync = active ? requestAgentSessionRename(active, updated.title) : { status: 'not_running' as const }; - jsonRes(res, 200, { ...updated, agentSync: agentSync.status }); + jsonRes(res, 200, { + ok: true, + title: updated.title, + titleUpdatedAt: updated.updatedAt, + titleSource: updated.source, + agentSync: agentSync.status, + }); }); // 会话锁定:保护被锁定会话不被 dashboard「清理空闲」批量关闭。锁定是会话元数据, @@ -1240,7 +1254,7 @@ ipcRoute('POST', '/api/sessions/:sessionId/lock', async (req, res, params) => { let body: { locked?: unknown }; try { body = await readJsonBody(req); } catch { return jsonRes(res, 400, { ok: false, error: 'bad_json' }); } if (typeof body.locked !== 'boolean') return jsonRes(res, 400, { ok: false, error: 'bad_locked' }); - const session = findSessionRecord(params.sessionId); + const session = findOwnedSessionRecord(params.sessionId); if (!session) return jsonRes(res, 404, { ok: false, error: 'session_not_found' }); if (body.locked) session.locked = true; else delete session.locked; @@ -1271,6 +1285,9 @@ ipcRoute('GET', '/api/sessions/:sessionId/write-link', (req, res, params) => { if (!ipcHmacAuthorized(req)) return jsonRes(res, 401, { ok: false, error: 'unauthorized' }); const ds = findActiveBySessionId(params.sessionId); if (!ds) return jsonRes(res, 404, { ok: false, error: 'session_not_active' }); + if (!sessionSupportsWebTerminal(ds)) { + return jsonRes(res, 409, { ok: false, error: 'terminal_unsupported' }); + } // Riff backend: the sandbox URL is the writable link — no local worker needed. if (ds.riffAccessUrl) { jsonRes(res, 200, { ok: true, url: ds.riffAccessUrl }); @@ -1317,7 +1334,7 @@ ipcRoute('POST', '/api/sessions/:sessionId/write-link-card', async (req, res, pa if (!ds) return jsonRes(res, 404, { ok: false, error: 'session_not_active' }); const r = await deliverWriteLinkCardToOwners(ds); const status = r.ok ? 200 - : r.error === 'terminal_unavailable' ? 409 + : r.error === 'terminal_unavailable' || r.error === 'terminal_unsupported' ? 409 : r.error === 'no_owner' ? 422 : 502; jsonRes(res, status, r); @@ -3154,7 +3171,7 @@ ipcRoute('PUT', '/api/bot-read-isolation', async (req, res) => { jsonRes(res, 200, { ok: true, readIsolation: r.readIsolation, suspendedSessions }); }); -// Per-bot session backend override (pty | tmux | herdr | zellij), or clear it +// Per-bot session backend override (pty | tmux | herdr | zellij | zmx), or clear it // ('' / 'auto' / null → follow the daemon default). next-session 生效:running // sessions keep their spawn-time backend (Session.backendType stamp), only new // spawns read the new value — so switching here can't strand live sessions. diff --git a/src/core/dashboard-rows.ts b/src/core/dashboard-rows.ts index ce278292e..44f901e62 100644 --- a/src/core/dashboard-rows.ts +++ b/src/core/dashboard-rows.ts @@ -13,6 +13,7 @@ import { getBotBrand } from '../bot-registry.js'; import { type Brand, chatAppLink } from '../im/lark/lark-hosts.js'; import { getSessionTokenUsage, type SessionTokenUsage } from './cost-calculator.js'; import { getIdentity } from '../im/lark/identity-cache.js'; +import { isSuspendableBackendType, persistentSessionName } from './persistent-backend.js'; export interface SessionRow { sessionId: string; @@ -41,6 +42,13 @@ export interface SessionRow { * Absent on rows from older daemons → callers keep the locate behavior. */ scope?: 'thread' | 'chat'; title?: string; + titleUpdatedAt?: string; + /** Informational only; callers must not treat it as authenticated identity. */ + titleSource?: Session['titleSource']; + /** Backend stamped at spawn time. Exposed so external surfaces can filter zmx/tmux/etc. */ + backendType?: Session['backendType']; + /** Deterministic backing multiplexer name, not proof the session is currently live. */ + backendSessionName?: string; /** 看板视图的手动放置(列 id / 列内排序位置),用户拖拽后持久化在 Session 上。 * 未设置时前端按运行状态推导默认列。 */ kanbanColumn?: string; @@ -137,6 +145,12 @@ function directChatDisplayName(s: Session, larkAppId?: string): string | undefin return undefined; } +function backendSessionNameForRow(s: Session): string | undefined { + const backendType = s.backendType; + if (s.adoptedFrom || !isSuspendableBackendType(backendType)) return undefined; + return persistentSessionName(backendType, s.sessionId); +} + export function composeRowFromActive(ds: DaemonSession): SessionRow { return { sessionId: ds.session.sessionId, @@ -162,6 +176,10 @@ export function composeRowFromActive(ds: DaemonSession): SessionRow { lastInputFromBot: ds.session.quoteTargetSenderIsBot === true, scope: ds.session.scope, title: ds.session.title, + titleUpdatedAt: ds.session.titleUpdatedAt, + titleSource: ds.session.titleSource, + backendType: ds.session.backendType, + backendSessionName: backendSessionNameForRow(ds.session), kanbanColumn: ds.session.kanbanColumn, kanbanPosition: ds.session.kanbanPosition, locked: !!ds.session.locked, @@ -208,6 +226,10 @@ export function composeRowFromClosed(s: Session): SessionRow { lastInputFromBot: s.quoteTargetSenderIsBot === true, scope: s.scope, title: s.title, + titleUpdatedAt: s.titleUpdatedAt, + titleSource: s.titleSource, + backendType: s.backendType, + backendSessionName: backendSessionNameForRow(s), kanbanColumn: s.kanbanColumn, kanbanPosition: s.kanbanPosition, locked: !!s.locked, diff --git a/src/core/device-isolation-daemon.ts b/src/core/device-isolation-daemon.ts index f5cb70256..4ba8a70dd 100644 --- a/src/core/device-isolation-daemon.ts +++ b/src/core/device-isolation-daemon.ts @@ -34,7 +34,7 @@ export const DEVICE_ISOLATION_PREPARE_PATH = '/api/device-isolation/activation/p export const DEVICE_ISOLATION_COMMIT_PATH = '/api/device-isolation/activation/commit'; export const DEVICE_ISOLATION_RELEASE_PATH = '/api/device-isolation/activation/release'; -type LocalPersistentBackend = 'tmux' | 'herdr' | 'zellij'; +type LocalPersistentBackend = 'tmux' | 'herdr' | 'zellij' | 'zmx'; type InventoryBackend = BackendType | 'unknown'; type ProcessIdentity = { pid: number; procStart: string }; @@ -99,7 +99,13 @@ export interface DeviceIsolationDaemonDependencies { processExists: (pid: number) => boolean; signalProcess: (pid: number, signal: NodeJS.Signals) => void; probePersistent: (backendType: LocalPersistentBackend, name: string) => SessionProbe; - killPersistent: (backendType: LocalPersistentBackend, name: string) => void; + /** Full sessionId is mandatory so prefix-addressed backends such as ZMX can + * re-verify ownership before destructive teardown. */ + killPersistent: ( + backendType: LocalPersistentBackend, + name: string, + sessionId: string, + ) => void; closeWorker: (session: DeviceIsolationRuntimeSession) => void; readMarker: () => string | null; sleep: (ms: number) => Promise; @@ -122,7 +128,7 @@ let daemonIdentity: DeviceIsolationDaemonIdentity | null = null; let transaction: ActivationTransaction | null = null; function isPersistentBackend(value: InventoryBackend): value is LocalPersistentBackend { - return value === 'tmux' || value === 'herdr' || value === 'zellij'; + return value === 'tmux' || value === 'herdr' || value === 'zellij' || value === 'zmx'; } function isLocalBackend(value: InventoryBackend): value is Exclude { @@ -454,7 +460,11 @@ async function quiesceOwnedSessions( if (!session) return 'teardown_failed'; dependencies.closeWorker(session); if (target.persistent) { - dependencies.killPersistent(target.persistent.backendType, target.persistent.name); + dependencies.killPersistent( + target.persistent.backendType, + target.persistent.name, + target.sessionId, + ); } } } catch { @@ -486,7 +496,11 @@ async function quiesceOwnedSessions( if (probe === 'exists') { clean = false; try { - dependencies.killPersistent(target.persistent.backendType, target.persistent.name); + dependencies.killPersistent( + target.persistent.backendType, + target.persistent.name, + target.sessionId, + ); } catch { /* verified by the next probe */ } } } diff --git a/src/core/idle-worker-sweeper.ts b/src/core/idle-worker-sweeper.ts index bbb44e679..87000a0d6 100644 --- a/src/core/idle-worker-sweeper.ts +++ b/src/core/idle-worker-sweeper.ts @@ -56,7 +56,7 @@ export function sweepIdleWorkers( const candidates = running // Never suspend an adopted session. forkAdoptWorker stamps its - // initConfig.backendType as tmux/herdr/zellij (so it would otherwise pass + // initConfig.backendType as tmux/herdr/zellij/zmx (so it would otherwise pass // isSuspendableBackendType), but the worker-null resume path in daemon.ts // re-forks via forkWorker — NOT forkAdoptWorker — so a suspended adopt // session would come back as a normal botmux bmx-* session, losing its diff --git a/src/core/persistent-backend.ts b/src/core/persistent-backend.ts index a91b89330..3c1eba607 100644 --- a/src/core/persistent-backend.ts +++ b/src/core/persistent-backend.ts @@ -1,6 +1,6 @@ /** * Shared helpers for sessions backed by a persistent multiplexer - * (tmux / herdr / zellij). These backends keep the CLI alive across worker + * (tmux / herdr / zellij / zmx). These backends keep the CLI alive across worker * exits BY DESIGN (idle-suspend, lazy restore), so several daemon paths must * resolve / name / probe / kill the backing session WITHOUT a live worker: * the restore-time zombie sweep and terminal wake (session-manager.ts), and @@ -15,16 +15,32 @@ import { getBot } from '../bot-registry.js'; import { TmuxBackend } from '../adapters/backend/tmux-backend.js'; import { HerdrBackend } from '../adapters/backend/herdr-backend.js'; import { ZellijBackend } from '../adapters/backend/zellij-backend.js'; +import { ZmxBackend } from '../adapters/backend/zmx-backend.js'; import type { BackendType, PersistentBackendTarget, SessionProbe } from '../adapters/backend/types.js'; import type { DaemonSession } from './types.js'; import type { Session } from '../types.js'; -export type PersistentBackendType = Extract; +export type PersistentBackendType = Extract; + +/** + * Decide whether a post-kill probe still blocks a cold replacement. + * + * ZMX owns sessions by labels + frozen PID, so an inconclusive confirmation + * must remain fail-closed. The older mux backends only have best-effort + * process/session probes: after a successful kill, `unknown` is not proof that + * the target survived (notably zellij reports zero live sessions with exit 1). + */ +export function shouldRejectPersistentPostKillProbe( + backendType: PersistentBackendType, + probe: SessionProbe, +): boolean { + return probe === 'exists' || (backendType === 'zmx' && probe === 'unknown'); +} export function isSuspendableBackendType( backendType: BackendType | undefined, ): backendType is PersistentBackendType { - return backendType === 'tmux' || backendType === 'herdr' || backendType === 'zellij'; + return backendType === 'tmux' || backendType === 'herdr' || backendType === 'zellij' || backendType === 'zmx'; } /** @@ -132,6 +148,7 @@ export function shutdownBackendDisposition(ds: DaemonSession): 'detach' | 'close export function persistentSessionName(backendType: PersistentBackendType, sessionId: string): string { if (backendType === 'tmux') return TmuxBackend.sessionName(sessionId); if (backendType === 'zellij') return ZellijBackend.sessionName(sessionId); + if (backendType === 'zmx') return ZmxBackend.sessionName(sessionId); return HerdrBackend.sessionName(sessionId); } @@ -186,20 +203,76 @@ export function probePersistentBackendTarget(target: PersistentBackendTarget): S return probePersistentSession(target.backendType, target.sessionName); } -export function killPersistentBackendTarget(target: PersistentBackendTarget): void { +/** + * `sessionId` is REQUIRED for ZMX: its destruction is identity-verified against + * the botmux labels stamped on the session, and `killPersistentSession` refuses + * a name-only ZMX kill rather than risk destroying a same-named user session. + * Callers that hold the owning session must always pass it through. + */ +export function killPersistentBackendTarget( + target: PersistentBackendTarget, + sessionId?: string, +): void { if (target.backendType === 'herdr' && target.agentName) { HerdrBackend.killAgent(target.sessionName, target.agentName); return; } - killPersistentSession(target.backendType, target.sessionName); + killPersistentSession(target.backendType, target.sessionName, sessionId); } export function probePersistentSession(backendType: PersistentBackendType, name: string): SessionProbe { if (backendType === 'tmux') return TmuxBackend.probeSession(name); if (backendType === 'zellij') return ZellijBackend.probeSession(name); + if (backendType === 'zmx') return ZmxBackend.probeSession(name); return HerdrBackend.probeSession(name); } +/** + * Take one liveness snapshot for a set of backing-session names. + * + * ZMX and Zellij expose all session states in one command, so probing each row + * separately would repeatedly scan the same control plane (and makes `botmux + * list` quadratic for ZMX). tmux and Herdr keep their established per-session + * probes, but duplicate names are still coalesced here. + */ +export function probePersistentSessions( + backendType: PersistentBackendType, + names: Iterable, +): ReadonlyMap { + const uniqueNames = [...new Set(names)]; + const result = new Map(); + + if (backendType === 'zmx') { + const snapshot = ZmxBackend.probeSessions(); + for (const name of uniqueNames) { + result.set( + name, + !snapshot.ok + ? 'unknown' + : snapshot.sessions.includes(name) + ? 'exists' + : snapshot.unhealthySessions.includes(name) + ? 'unknown' + : 'missing', + ); + } + return result; + } + + if (backendType === 'zellij') { + const snapshot = ZellijBackend.probeLiveSessions(); + for (const name of uniqueNames) { + result.set(name, !snapshot.ok ? 'unknown' : snapshot.sessions.includes(name) ? 'exists' : 'missing'); + } + return result; + } + + for (const name of uniqueNames) { + result.set(name, probePersistentSession(backendType, name)); + } + return result; +} + /** * Tri-state liveness of the backend's multiplexer SERVER itself (not one * session). The restore path consults this when a session probes 'missing' to @@ -216,12 +289,25 @@ export function probePersistentBackendServer( ): 'running' | 'down' | 'unknown' { if (backendType === 'tmux') return TmuxBackend.serverState(); if (backendType === 'zellij') return ZellijBackend.serverState(); + if (backendType === 'zmx') return ZmxBackend.serverState(); return 'unknown'; } -/** Kill a backing session (each backend's killSession is a no-op when absent). */ -export function killPersistentSession(backendType: PersistentBackendType, name: string): void { +/** + * Kill a backing session. ZMX additionally requires the complete botmux UUID: + * its public name contains only eight UUID characters, so name-only deletion + * could destroy a different session after a prefix collision. + */ +export function killPersistentSession( + backendType: PersistentBackendType, + name: string, + sessionId?: string, +): void { if (backendType === 'tmux') TmuxBackend.killSession(name); else if (backendType === 'zellij') ZellijBackend.killSession(name); + else if (backendType === 'zmx') { + if (!sessionId) throw new Error(`refusing name-only ZMX kill for ${name}`); + ZmxBackend.killManagedSession(name, sessionId); + } else HerdrBackend.killSession(name); } diff --git a/src/core/session-board.ts b/src/core/session-board.ts index f1f73d995..3113d1efc 100644 --- a/src/core/session-board.ts +++ b/src/core/session-board.ts @@ -18,8 +18,11 @@ export function normalizeKanbanPosition(value: unknown): number | null { return typeof value === 'number' && Number.isFinite(value) ? value : null; } -/** 重命名标题:单行化、移除会被终端解释的 C0/DEL 控制字符、限长; - * 空串视为非法。标题会被送进原生 TUI 的 `/rename`,所以这里也是输入边界。 */ +/** + * 重命名标题:单行化、移除会被终端解释的 C0/C1/DEL 控制字符、限长; + * 空串视为非法。标题既会写到 `botmux list` 的本机 TTY,也会送进原生 + * TUI 的 `/rename`,所以这里是两条路径共用的输入边界。 + */ export function normalizeSessionTitle(value: unknown): string | null { if (typeof value !== 'string') return null; const title = value.replace(/[\u0000-\u001f\u007f-\u009f]+/g, ' ').replace(/\s+/g, ' ').trim(); diff --git a/src/core/session-manager.ts b/src/core/session-manager.ts index 761b4ee1a..42a9ce653 100644 --- a/src/core/session-manager.ts +++ b/src/core/session-manager.ts @@ -12,7 +12,7 @@ import * as sessionStore from '../services/session-store.js'; import * as messageQueue from '../services/message-queue.js'; import { downloadMessageResource, listChatBotMembers, UserTokenMissingError } from '../im/lark/client.js'; import { logger } from '../utils/logger.js'; -import { forkWorker, sendWorkerInput, forkAdoptWorker, adoptSandboxBlocked, killStalePids, getCurrentCliVersion, restoreUsageLimitRuntimeState, setActiveSessionSafe, isRelayableRealSession, closeSession, getActiveSessionsRegistry, suspendWorker } from './worker-pool.js'; +import { forkWorker, sendWorkerInput, forkAdoptWorker, adoptSandboxBlocked, killStalePids, getCurrentCliVersion, restoreUsageLimitRuntimeState, setActiveSessionIfActive, setActiveSessionSafe, isDisposableCommandScratch, isRelayableRealSession, closeSession, getActiveSessionsRegistry, suspendWorker } from './worker-pool.js'; import { createCliAdapterSync } from '../adapters/cli/registry.js'; import { buildBotmuxShellHints } from '../adapters/cli/shared-hints.js'; import { @@ -24,11 +24,16 @@ import { import { getSessionPersistentBackendType, persistentBackendTargetForSession, - probePersistentBackendTarget, + persistentSessionName, probePersistentBackendServer, + probePersistentBackendTarget, + probePersistentSession, + probePersistentSessions, killPersistentBackendTarget, + killPersistentSession, type PersistentBackendType, } from './persistent-backend.js'; +import type { PersistentBackendTarget } from '../adapters/backend/types.js'; import { adoptTargetLabel, validateAdoptTargetState } from './session-discovery.js'; import { getBot, getAllBots, getOwnerOpenId, findOncallChat, effectiveDefaultWorkingDir } from '../bot-registry.js'; import type { CliId } from '../adapters/cli/types.js'; @@ -43,8 +48,9 @@ import { type Coworker, } from './session-create.js'; import { validateZellijAdoptTarget } from './zellij-adopt-discovery.js'; -import type { BackendType } from '../adapters/backend/types.js'; -import type { CliTurnPayload, CodexAppAdditionalContextEntry, CodexAppTurnInput, LarkAttachment, LarkMention, ScheduledTask, SubstituteTrigger } from '../types.js'; +import type { BackendType, SessionProbe } from '../adapters/backend/types.js'; +import { backendSupportsWebTerminal } from '../adapters/backend/capabilities.js'; +import type { CliTurnPayload, CodexAppAdditionalContextEntry, CodexAppTurnInput, LarkAttachment, LarkMention, ScheduledTask, Session, SubstituteTrigger } from '../types.js'; import { addCodexAppContext } from '../utils/codex-app-context.js'; import type { MessageResource } from '../im/lark/message-parser.js'; import type { ResolvedSender } from '../im/lark/identity-cache.js'; @@ -104,9 +110,11 @@ function sessionBotCliMismatch(ds: DaemonSession): { sessionCli: string; botCli: return null; } -async function closeActiveSessionIfCliMismatch(ds: DaemonSession): Promise { +type CliMismatchCloseResult = 'not_mismatched' | 'closed' | 'teardown_failed'; + +async function closeActiveSessionIfCliMismatch(ds: DaemonSession): Promise { const mismatch = sessionBotCliMismatch(ds); - if (!mismatch) return false; + if (!mismatch) return 'not_mismatched'; const tag = ds.session.sessionId.substring(0, 8); const backendType = getSessionPersistentBackendType(ds); @@ -117,12 +125,25 @@ async function closeActiveSessionIfCliMismatch(ds: DaemonSession): Promise): Promise { const sessions = sessionStore.listSessions(); - const active = sessions.filter(s => s.status === 'active'); + const restorePriority = (session: Session): number => { + if (session.adoptedFrom || session.cliId || session.lastCliInput || session.backendType) return 2; + if (session.queued) return 1; + return 0; // disposable daemon-command scratch + }; + // Deterministic winner policy for corrupt/legacy same-anchor duplicates: + // real CLI/adopt rows first, queued intent second, command scratches last. + // Registration itself is CAS, so a fresh runtime occupant always wins over + // every startup candidate regardless of this disk ordering. + const active = sessions + .filter(s => s.status === 'active') + .sort((a, b) => restorePriority(b) - restorePriority(a)); if (active.length === 0) { logger.info('No active sessions to restore'); @@ -1210,11 +1249,33 @@ export async function restoreActiveSessions(activeSessions: Map + [...activeSessions.values()].find(ds => ds !== candidate && ds.session.sessionId === sessionId); + // Persistent recovery below must only inspect rows registered by THIS + // restore pass. The dispatcher/IPC are already live and may add a fresh + // runtime session while one of the CAS registrations awaits; sweeping the + // whole live Map would then mistake that new session for a startup snapshot + // row and could close it while its backing pane is still being created. + const restoredByThisInvocation: DaemonSession[] = []; + const stillOwnsRestoreRegistration = (ds: DaemonSession): boolean => + ds.session.status === 'active' + && activeSessions.get(activeSessionKey(ds)) === ds; for (const session of active) { + // Dispatcher/IPC may create and register this exact persisted row while + // startup restore is running. The runtime object is authoritative; never + // rebuild or later close it as a collision loser. + if (runtimeWinnerFor(session.sessionId)) { + logger.debug(`[${session.sessionId.substring(0, 8)}] Already registered by live runtime during restore; skipping snapshot row`); + continue; + } // Restored sessions persisted before the scope field was added default to // 'thread' — that matches the legacy thread-only behaviour. const scope: 'thread' | 'chat' = session.scope === 'chat' ? 'chat' : 'thread'; @@ -1299,9 +1360,21 @@ export async function restoreActiveSessions(activeSessions: Map