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 客户端进程内执行:
packages/tui/src/clipboard.ts只被 TUI 组件导入;server 包全文 grepwl-copy|xclip|writeOsc52零命中;- TUI 通过
createOpencodeClient以 HTTP/SSE 连接后台服务,是纯客户端; - 你在 shell 里敲
opencode启动的那个进程本身就是 TUI,OSC52 写到它自己的 stdout。 剪贴板还有一条 host 腿,但它是进程内的 Zig 原生客户端(Wayland 走zwlr_data_control协议、X11 走选区协议),不 spawn 任何外部工具。 (2026-10-09 复测:成功复制后pgrep xclip / wl-copy / wl-paste零残留。)
所以"复制在 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 行梗概,照抄是复现不出来的。两个坑:
- 致命坑:会话状态前置条件。
messages.copy复制的是"最后一条 assistant 消息的纯文本部分"。-c续接的会话若以纯工具调用结尾(做实验时几乎必然), 命令直接走No text parts found in last assistant message分支 early return——一个字节都不会写,屏幕上只有一个 toast。2026-10-09 复现时 第一次捕获 0 命中就是这个原因,全程capture-pane截屏才定位到。 解法:用-s <sessionID>指定一个以纯文本结尾的会话。 - 工程坑:盲等
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/WAYLAND | 1 次 | DCS 信封(host 图形环境缺失不影响 OSC52) |
env -u TMUX + 剥掉 DISPLAY/WAYLAND | 1 次 | 仍是 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 | 信封内容到达假终端 |
|---|---|---|
| 对照 A | off | 0 次(静默丢弃实锤) |
| 对照 B | on | 1 次(完整放行) |
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 解析 → 终端能力协商 → 本地剪贴板 的每一跳上。