SSH + tmux 下 opencode v2 复制静默失效:OSC52 与 wl-copy 双路径多方验证记

十月 08, 2026 更新: 十月 09, 2026 [AI] #SSH #tmux #opencode #clipboard

现象

SSH 到远程 Linux 机器上开发,环境是"全家桶":X11 转发(DISPLAY=localhost:11.0)+ waypipe 转发 Wayland(WAYLAND_DISPLAY=wayland-HdnS4jlM)+ tmux 3.5a。 本地终端 kitty。在 opencode v2.0.24 TUI 里按复制(ctrl+x 然后 y): toast 提示 "Copied to clipboard",但本地剪贴板是空的。

环境变量看起来都"正确",wl-copy/xclip/xsel 都装着,tmux set-clipboard 也是 on。 为什么复制就是不通?

先给结论,再给多方验证过程。

TL;DR:断链的完整因果

opencode v2.0.24 按复制
  └─ 跨终端边界的只有 OSC52 转义序列(二进制里没有 wl-copy/xclip/clipboardy,0 命中,
  │   也不会 spawn 任何外部剪贴板工具——见第二层与验证矩阵)
      └─ opentui 检测到 tmux 3.5a < 3.6 → 判定 "legacy tmux"
          └─ OSC52 被强制包进 DCS 透传信封 \ePtmux;...(不再发送原生序列)
              └─ tmux 默认 allow-passthrough off
                  └─ 信封被【静默丢弃】→ 复制无声失败,但 toast 照样弹

2026-10-09 勘误:初版写"复制只有一条路"。不准确——写入策略是 destination: "all-available" 双腿并行(host 原生剪贴板 + terminal OSC52), 但 host 腿在终端被判为 remote(SSH/能力未知时默认 true)时直接 not-attempted,所以在 SSH+tmux 场景实际出门的只有 OSC52,因果链不变。 详见第二层。

修复只需一行(tmux 会话内立即生效 + 写入 ~/.tmux.conf 持久化):

tmux set-option -g allow-passthrough on
echo 'set -g allow-passthrough on' >> ~/.tmux.conf

而"网上资料说不需要开透传"的说法,只对发送原生 OSC52 的程序(如 Neovim)成立; tmux 原生转发的可靠性问题是 opentui 弃用这条路径的原因,下文有实验数据。

第一层:复制到底在哪个进程执行

opencode v2 是客户端-服务端架构,但剪贴板完全在 TUI 客户端进程内执行:

所以"复制在 server 端、要重启 server 才能更新 WAYLAND_DISPLAY"是常见误解—— 重启 TUI 客户端就够,server 的环境变量与复制无关。 (但注意:常驻进程持有的是启动时的环境。SSH 会话换了 waypipe socket, 老进程里的 WAYLAND_DISPLAY 就指向死 socket。)

第二层:v2.0.24 的剪贴板写入结构(2026-10-09 修正)

对官方二进制做字符串统计(这一步今天依然成立):

$ for s in wl-copy xclip clipboardy Ptmux; do echo -n "$s: "; grep -ac "$s" ~/.opencode/bin/opencode; done
wl-copy: 0
xclip: 0
clipboardy: 0
Ptmux: 3

补充一个更严格的整词级检查(裸二进制的 grep -ac 是子串匹配,xclip 这种纯 base64 字母组合理论上存在误报可能;按 strings 整词匹配更硬):

$ strings -n 5 ~/.opencode/bin/opencode | grep -xE 'wl-copy|wl-paste|xclip|xsel|pbcopy|clip.exe'
# 无输出 —— 整词 0 命中(同法只捞出 WAYLAND_DISPLAY 一个环境变量名)

勘误:初版据此得出"复制只有一条路",并把二进制里提取的这段 writeText 当成了写入全貌:

writeText(r, i) {
  let s = u()  // osc52_support 能力
  if (s === "unsupported") return {status: "not-attempted", capability: s}
  return {status: e.copyToClipboardOSC52(r, t(i)) ? "attempted" : "local-failure", ...}
}

这段代码是真的,但它只是 terminal 腿的适配器 (createRendererClipboardAdapter),不是剪贴板写入的全部。2026-10-09 用 release 二进制字符串 + @opentui/core@0.5.16(TUI 依赖的版本)源码双重确认, 完整结构是:

TUI clipboard.write → clipboard.writeText({ destination: "all-available" })
  ├─ terminal 腿: 上面的 OSC52 适配器(osc52 能力门控, tmux<3.6 时包 DCS 信封)
  └─ host 腿: createHostClipboard → NativeClipboardBackend(Zig 原生)
       = 进程内 Wayland zwlr_data_control / X11 选区客户端(与 wl-copy 同协议!)
         默认 timeoutMs = 1000ms
         二进制里可见 zwlr_data_control_manager_v1 / wl_data_device_manager 等接口字符串

"all-available" 会把两条腿都跑一遍(并行),但 core 里有远端保护:

canUseRemoteHost = !terminal.remote || options.allowRemoteHost === true
// terminal.remote = capabilities.remote ?? true   ← 能力未知时默认 true
// 不满足时该腿: { status: "not-attempted" }

opencode 没传 allowRemoteHost,而 remote 能力未知时默认 true—— 所以在 SSH + tmux 这类"远端"场景,host 腿直接 not-attempted, 实际出门的只有 OSC52。这就是初版"只有一条路"的结论歪打正着的原因: 机制上双路,效果上单路。

写入返回的是状态对象、不抛异常,TUI 适配器的成功判定是 host.status === "written" || terminal.status === "attempted"—— 所以 OSC52 被下游丢弃时 toast 照样弹。这就是"提示成功但剪贴板为空"的直接原因。

勘误:初版括号里写"dev 分支的 clipboard.ts 是另一套:OSC52 + wl-copy/xclip/xsel 双保险"——归因错误。@opentui/core@0.5.16 的 createHostClipboard 同样是原生 backend(整个包里 wl-copy/xclip/ xsel/pbcopy 出现 0 次),dev 源码与 release 二进制在这部分一致 (二进制里同时有该适配器的错误文案和 zwlr_data_control 协议字符串可证)。

运行时备注(顺带回答"二进制里怎么又有 Bun 又有 Zig"):v2 核心代码是 运行时无关设计——运行时相关位被隔离成 .bun.ts/.node.ts/.workerd.ts 三套成对适配器(sqlite、pty、filesystem、image、persistent-pty);npm 分发 声明 engines: node >= 22,入口是 #!/usr/bin/env node 的 opencode.cjs + ./server-process 导出;桌面 standalone 则是 bun build --compile 单文件 (内嵌 Bun v1.4.2,本文抓字节用的就是它——本机 4 个 opencode 进程全是它, RSS 各约 0.4GB,没有任何 node 进程)。而 OpenTUI 的 Zig 原生层与上层 JS 运行时无关:index.bun.js/index.node.js 双入口,Bun 走 bun:ffi、Node 走 process.dlopen,加载的是同一个 Zig 库。剪贴板 host 腿(OSC52 写出、 tmux 信封、zwlr_data_control 客户端)就住在这层——上层运行时怎么换, 本文的字节级结论一字不改。

第三层:tmux < 3.6 时强制 DCS 信封(本次问题的根源)

从二进制提取的 opentui 逻辑:

isLegacyTmux = terminal.name.toLowerCase().includes("tmux")
            && terminal.version.localeCompare("3.6") < 0

writeOsc(seq, force = false) {
  return this.writeFn(force && this.inLegacyTmux ? 包DCS信封(seq) : seq)  // 二选一!
}

关键在那个 ?:——二选一,不是"两个都发"。tmux 通过 XTVERSION 自报 tmux 3.5a, 3.5a < 3.6 → 被判为 legacy → 剪贴板 OSC52 只以 \ePtmux; 信封形式发出。

顺带一提:localeCompare 比较版本字符串,字典序下 "3.10" < "3.6", 未来 tmux 3.10 也会被误判 legacy——这个启发式挺粗糙。

验证:捕获真实 TUI 发出的字节(最硬的证据)

静态分析可能过时,直接抓真实 TUI 的输出。在独立 tmux 会话里跑 opencode, 用 pipe-pane 捕获 pane 原始字节(挂在 tmux 解析之前的 pty 输出流上), 然后模拟键盘触发复制。

勘误:初版这段只贴了 6 行梗概,照抄是复现不出来的。两个坑:

  1. 致命坑:会话状态前置条件。 messages.copy 复制的是"最后一条 assistant 消息的纯文本部分"。-c 续接的会话若以纯工具调用结尾(做实验时几乎必然), 命令直接走 No text parts found in last assistant message 分支 early return——一个字节都不会写,屏幕上只有一个 toast。2026-10-09 复现时 第一次捕获 0 命中就是这个原因,全程 capture-pane 截屏才定位到。 解法:用 -s <sessionID> 指定一个以纯文本结尾的会话。
  2. 工程坑:盲等 sleep 9 赌时序(应轮询 capture-pane 直到屏幕稳定)、 不停 pipe-pane(文件持续增长,"恰好 1 次"无从统计)、解码脚本写成省略号、 在 shell 里 grep '\e]52;c;' 的转义根本不成立。这类嵌套转义实验必须 落成脚本文件,不能内联敲。

完整可复现脚本(两个文件 + 一条命令):

# ============ verify-osc52.sh ============
#!/usr/bin/env bash
# 用法: verify-osc52.sh <wrapped|plain|wrapped-headless|plain-headless>
set -euo pipefail
MODE=${1:?usage: verify-osc52.sh <wrapped|plain|wrapped-headless|plain-headless>}
BIN=${OPENCODE_BIN:-$HOME/.opencode/bin/opencode}
BASE=/home/peter/.tmp/opencode
SESSION=ocverify
CAP=$BASE/oc-tui-capture-$MODE.bin

tmux kill-session -t "$SESSION" 2>/dev/null || true
rm -f "$CAP"

# TMUX 可见性决定是否走信封分支; DISPLAY/WAYLAND 决定宿主图形环境是否可用
ENVPFX=(env)
[[ "$MODE" == plain*          ]] && ENVPFX+=(-u TMUX)
[[ "$MODE" == *-headless      ]] && ENVPFX+=(-u DISPLAY -u WAYLAND_DISPLAY)
ENVPFX+=(TERM=xterm-256color)   # env 的 -u 必须在变量赋值之前

# -s 指定"最后一条 assistant 消息是纯文本"的会话 => 复制必有内容
SESSION_ARG="-c"
[[ -n "${OPENCODE_SESSION:-}" ]] && SESSION_ARG="-s $OPENCODE_SESSION"

tmux new-session -d -s "$SESSION" -x 120 -y 35 "${ENVPFX[*]} '$BIN' $SESSION_ARG"
tmux pipe-pane -o -t "$SESSION" "cat >> $CAP"   # 水龙头: tmux 解析前的原始字节

# 就绪检测: 轮询到两次连续截屏一致且非空(代替盲等)
prev=""; for i in $(seq 1 60); do
  cur=$(tmux capture-pane -p -t "$SESSION" 2>/dev/null || true)
  [[ -n "$cur" && "$cur" == "$prev" ]] && break
  prev=$cur; sleep 0.5
done

# 驱动按键: leader=ctrl+x, messages.copy=<leader>y (leader 超时默认 2000ms)
tmux send-keys -t "$SESSION" C-x; sleep 0.6
tmux capture-pane -p -t "$SESSION" > "$BASE/oc-tui-screen-$MODE-2-leader.txt"
tmux send-keys -t "$SESSION" y;   sleep 2

tmux pipe-pane -t "$SESSION" 2>/dev/null || true   # 关水龙头
sleep 0.3; tmux kill-session -t "$SESSION" 2>/dev/null || true
python3 "$BASE/analyze-capture.py" "$CAP"

# ============ analyze-capture.py ============
import base64, re, sys
data = open(sys.argv[1], "rb").read()
marker = b"\x1b]52;c;"
print(f"capture: {sys.argv[1]}  ({len(data)} bytes)")
print(f"OSC52 marker 出现次数: {data.count(marker)}")
for text, label in [(b"copied to clipboard", "成功 toast"),
                    (b"No assistant messages", "错误:无消息"),
                    (b"No text parts", "错误:无文本部分")]:
    print(f"{label}: {data.count(text)} 次")
# tmux DCS 信封: ESC P tmux; ESC ESC ]52;c;<b64> ESC ESC \ ESC \
envelope = re.search(rb"\x1bPtmux;\x1b\x1b\]52;c;([A-Za-z0-9+/=]+?)\x1b\x1b\\\x1b\\", data)
bare = re.search(rb"\x1b\]52;c;([A-Za-z0-9+/=]+?)(?:\x07|\x1b\\)", data)
match = envelope or bare
if not match:
    print("无完整 OSC52 —— 复制没触发或被会话状态拦截(查屏幕截屏)")
    sys.exit(1)
payload = base64.b64decode(match.group(1))
print("形式:", "tmux DCS 信封(内层 ESC 双写)" if envelope else "裸 OSC52")
print(f"payload: {len(payload)} 字节")
print(payload.decode("utf-8", "replace")[:200])
s = data.find(marker)
print(repr(data[max(0, s-36):s+56]))   # marker 前后原始字节
OPENCODE_SESSION=ses_xxx ~/.tmp/opencode/verify-osc52.sh wrapped

实测输出(v2.0.24,"Greeting" 测试会话):

capture: /home/peter/.tmp/opencode/oc-tui-capture-wrapped.bin  (774461 bytes)
OSC52 marker 出现次数: 1
成功 toast: 1 次
形式: tmux DCS 信封(内层 ESC 双写)
payload: 38 字节
Hello! What can I help you with today?
b'...\x1b[1;95H\x1b[0m\x1b[0m\x1b[?2026l\x1bPtmux;\x1b\x1b]52;c;SGVsbG8h...'

77 万字节捕获中恰好 1 次。信封内 ESC 双写(\e\e]52)与二进制中的 e.replace(/\x1b/g,"\x1B\x1B") 完全吻合——静态分析与动态捕获互相印证。

追加的三组环境对照(同会话、只动环境变量):

环境OSC52形式
tmux 内(默认)1 次DCS 信封
tmux 内 + 剥掉 DISPLAY/WAYLAND1 次DCS 信封(host 图形环境缺失不影响 OSC52)
env -u TMUX + 剥掉 DISPLAY/WAYLAND1 次仍是 DCS 信封

第三行说明 tmux 判定不看环境变量(剪贴板 OSC52 的包装在 Zig 原生层, 靠终端能力协商识别 tmux),strip $TMUX 骗不过它。

第四层:allow-passthrough 的开关对照实验

信封被 tmux 放行还是丢弃,取决于 allow-passthrough(默认 off)。 用"伪终端假客户端"做字节级对照——用 Python pty 伪装成一个终端, attach 到 tmux 会话,捕获 tmux 客户端输出的全部字节(即"终端将会收到什么"), 并模拟 kitty 的 DA1/DA2/XTVERSION 应答让 tmux 认可剪贴板能力:

实验allow-passthrough信封内容到达假终端
对照 Aoff0 次(静默丢弃实锤)
对照 Bon1 次(完整放行)
tmux set-option -g allow-passthrough off
# pane 内发射信封: printf '\033Ptmux;\033\033]52;c;%s\007\033\\' "$b64"
# → 假终端捕获 0 次命中
tmux set-option -g allow-passthrough on
# 同样发射 → 1 次命中

反向验证:tmux 原生转发为什么不靠谱

"set-clipboard on 时 tmux 会转发原生 OSC52"是文档说法(且要求客户端 TERM 的 terminfo 有 Ms 扩展能力)。实测(伪终端客户端 + 自制 xterm-clip terminfo 带 Ms):

$ infocmp -x -1 xterm-256color | grep Ms=   # 检查常见 TERM
# 本机所有 stock terminfo(含 xterm-kitty)都没有 Ms!

10 次原生 OSC52 发射实验中只有 2 次到达假终端——间歇性成功,未能定位稳定触发条件 (attach 后首次发射必丢是唯一稳定规律)。存储侧同样不稳定:set-clipboard on 时 pane 的原生 OSC52 有时进 tmux show-buffer 有时不进。

这就是 opentui 对 tmux < 3.6 弃用原生路径、强制信封的经验依据—— 对终端模拟器直连场景,信封透传是唯一 100% 可靠的通道(本实验中 N/N 全部到达)。

另一条路:wl-copy 的三态(waypipe 场景)

勘误:初版把这一节表述为"开发分支的 wl-copy 路径"——归因错误。 2026-10-09 确认:opencode 无论 release 还是 dev,都从不 spawn wl-copy (@opentui/core@0.5.16 全包 0 次出现;host 腿是进程内 Zig 原生 zwlr_data_control 客户端)。这组三态数据是对 wl-copy 工具本身在 waypipe 场景的实测——但它依然有价值:原生 host 腿走的是同一个协议 (zwlr-data-control)、同一条 waypipe socket,所以它刻画的正是 "opencode 若走 Wayland 剪贴板通道,这条链路的三种健康状况":

状态表现实测
waypipe 健康可用但慢2.8s 完成,wl-paste 回读内容一致
socket 文件已死立即失败exit=1,耗时 0.002s
僵尸 socket(文件在、连接 ESTAB、对端已死)无限挂起30s 超时未完成

僵尸态最坑:ss -x 显示连接 ESTAB、socket 文件都在,看起来一切正常, wl-copy 却永远阻塞。不过"无限挂起"不会原样映射到 opencode:原生 host 腿 默认 timeoutMs = 1000ms,超时返回 timed-out 状态。

关于"吞错误"也要修正:实际是逐腿状态对象,单腿失败被设计性吞掉; 只有两腿都失败(host failed 且 terminal 未 attempted)TUI 适配器才 throw、命令层才弹失败 toast。本文的场景里 OSC52 腿恒为 attempted, 所以永远只见成功 toast——与初版结论一致,但机制表述如上。

顺带用自制脚本复现过一个 spawn 方案的坑(opencode 不在此列,但对任何想 自己 spawn 剪贴板工具的人有效):xclip -selection clipboard 会 fork 后台 进程伺服选区,子进程继承 stdout 管道导致 Node 的 close 事件永不触发—— stdio: ["pipe","pipe","ignore"] 下永久挂死,改成 stdout: "ignore" 立即退出。

意外收获:tmux paste buffer 里的明文剪贴板

实验中 tmux show-buffer 意外读到了一段 sk- 开头的 API key 明文—— set-clipboard on 时,任何 pane 内程序发的原生 OSC52 都会被 tmux 解码存入 paste buffer 栈(上限 50 条,可用 tmux list-buffers 翻历史)。多用户共享 tmux server 的场景值得警惕;不需要的话 set -g set-clipboard external 可关闭 tmux 侧存储(只转发)。

修复与最终验证

tmux set-option -g allow-passthrough on
echo 'set -g allow-passthrough on' >> ~/.tmux.conf

重启 opencode TUI(拿到当前 SSH 会话的新 waypipe socket),按 ctrl+x y, 内容即刻到达本地 kitty 剪贴板(信封直达,不再有 waypipe 的秒级延迟)。

总结:验证矩阵

主张验证角度结果
跨终端边界只有 OSC52(host 腿 remote 判定默认跳过)字符串统计 + 双源逻辑提取 + 三环境动态捕获✅ 外部工具 0 命中;env -u TMUX/剥 DISPLAY 均照发
复制不 spawn 外部剪贴板工具复制后 pgrep xclip/wl-copy✅ 零残留进程
tmux<3.6 强制 DCS 信封静态逻辑 + pipe-pane 真实字节捕获✅ 仅 1 次且在信封内
信封被默认配置静默丢弃伪终端假客户端开关对照✅ off=0 / on=1
按键链路(leader→y→toast)正常,失败在下游全程 capture-pane 截屏 diff✅ 会话无文本时 toast "No text parts found"
tmux 原生转发不可靠假客户端 10 次发射 + terminfo 审计⚠️ 2/10 间歇成功;stock terminfo 全部缺 Ms
Wayland 剪贴板通道(wl-copy 工具级)waypipe 可用性写入回读 + 三态超时实验⚠️ 健康 2.8s / 死文件快败 / 僵尸无限挂起(原生 host 腿同协议同通道,带 1s 超时)
xclip spawn 挂死复现脚本计时✅ pipe 挂死 / ignore 秒退(自研 spawn 方案的坑,opencode 不涉及)

排查这类"环境变量都对但功能不通"的问题,多层链路里每一环都要有独立的字节级证据: 静态看代码、动态抓输出、开关做对照。环境变量只是入口,真正的执行链在 进程边界 → 转义序列 → tmux 解析 → 终端能力协商 → 本地剪贴板 的每一跳上。