tmux 越しの xterm.js で OSC 52 クリップボードを動かした方法
OSC 52 クリップボードは tmux 越しの xterm.js で動きません。修正は set-clipboard でも terminal-features でもなく、allow-passthrough です。
xterm.js + WebSocket + PTY を使って tmux セッションへブラウザーベースのターミナルアクセスを提供する Web ダッシュボードを作っています。Claude Code には OSC 52 でシステムクリップボードへテキストをコピーする /copy コマンドがあります。iTerm2、Windows Terminal、Ghostty では完璧に動きます。私の Web ターミナル経由では何も起きません。
理由を突き止めるのに 2 時間と 3 つの遠回りがかかりました。
xterm.js は OSC 52 を扱わない
最初の問題: xterm.js はデフォルトで OSC 52 を無視します。エスケープシーケンスは届き、パーサーはそれを見て、何も起こりません。ハンドラーを手動で登録する必要がありました。
term.parser.registerOscHandler(52, (data) => {
const idx = data.indexOf(';')
if (idx === -1) return false
const b64 = data.substring(idx + 1)
if (!b64) return false
try {
const decoded = atob(b64)
const bytes = new Uint8Array(decoded.length)
for (let i = 0; i < decoded.length; i++) {
bytes[i] = decoded.charCodeAt(i)
}
const text = new TextDecoder().decode(bytes)
navigator.clipboard.writeText(text)
.catch(() => {
const el = document.createElement('textarea')
el.value = text
document.body.appendChild(el)
el.select()
document.execCommand('copy')
document.body.removeChild(el)
})
} catch (e) {}
return true
})形式は \x1b]52;c;<base64>\x07 です。c はクリップボードセレクション、ペイロードは Base64 でエンコードされたテキストです。落とし穴: atob はバイナリ文字列を返すのであって UTF-8 ではありません。非 ASCII 文字を正しく扱うには TextDecoder が必要です。
これだけで終わるはずでした。終わりませんでした。
tmux set-clipboard、偽の手がかり
ハンドラーを追加した後も、Claude Code の /copy は動きませんでした。tmux 設定の罠の穴に降りていきました。
tmux には OSC 52 の扱いを制御する set-clipboard があります。
set -g set-clipboard external # OSC 52 を外側のターミナルへパススルー効きませんでした。次に、tmux が外側のターミナルが OSC 52 をサポートするかを判断するために Ms という terminfo capability をチェックすることを見つけました。私の xterm-256color には無かったのです。tmux 3.2+ には回避策があります。
set -g terminal-features "xterm-256color:clipboard"それでも何も起きません。/copy の後で tmux list-buffers をチェックしました。空でした。tmux は OSC 52 をまったく見ていなかったのです。これらは全部関係ありませんでした。
理由はこうです。set-clipboard は tmux が生の OSC 52 をどう扱うかを制御します。しかし Claude Code は tmux 内部では生の OSC 52 を送りません。DCS パススルーの封筒に包みます (次のセクション)。DCS パススルーはまったく別のコードパスで、tmux のクリップボード処理を完全に迂回します。つまり set-clipboard external も terminal-features clipboard も、間違った問題を解決していました。
ブレイクスルーは別のテストから来ました。tmux 内部で printf '\e]52;c;dGVzdA==\a' を直接実行しました。動きました。私の xterm.js ハンドラーが発火し、「test」がクリップボードに現れました。生の OSC 52 は tmux をきれいに通り抜けます。
ではなぜ Claude Code の /copy は動かないのか?
DCS パススルー、本当の問題
Claude Code は $TMUX を検出し、OSC 52 を DCS パススルー封筒に包みます。
\ePtmux;\e\e]52;c;<base64>\a\e\\
これは tmux に「このエスケープシーケンスを外側のターミナルへ直接パススルーしろ」と伝えます。Neovim や tmux-yank も同じことをします。tmux 内で動いていることを知っているツールの標準的なやり方です。
落とし穴: tmux 3.3+ は DCS パススルーをデフォルトでブロックします。allow-passthrough のデフォルトは off。DCS 封筒は静かに捨てられます。私の xterm.js ハンドラーは何も見ません。
tmux set-option -t <session> allow-passthrough onオプション 1 つ。2 時間。
完全な解決策
バックエンド (Python/FastAPI) で、attach の前にパススルーを有効にし、disconnect で戻します。
async def attach_session(session_name: str):
subprocess.run([
'tmux', 'set-option', '-t', session_name,
'allow-passthrough', 'on'
])
# ... attach via PTY ...
async def detach_session(session_name: str):
subprocess.run([
'tmux', 'set-option', '-t', session_name,
'-u', 'allow-passthrough' # restore default
])セッション単位で、グローバルではありません。disconnect で戻すことでセキュリティの攻撃面を最小化します。DCS パススルーは内側のシェルに任意のエスケープシーケンスをあなたのターミナルへ送らせるので、グローバルに on のまま残すのは欲しくない攻撃面です。
フロントエンド (xterm.js) では、すべての Terminal インスタンスにハンドラーを登録します。
function setupClipboard(term) {
term.parser.registerOscHandler(52, (data) => {
const idx = data.indexOf(';')
if (idx === -1) return false
const b64 = data.substring(idx + 1)
if (!b64) return false
try {
const decoded = atob(b64)
const bytes = new Uint8Array(decoded.length)
for (let i = 0; i < decoded.length; i++)
bytes[i] = decoded.charCodeAt(i)
navigator.clipboard.writeText(new TextDecoder().decode(bytes))
} catch (e) {}
return true
})
}スプリットペインがあるなら、各 Terminal インスタンスに自分のハンドラーが必要です。
デバッグの時系列
| 手順 | 試したこと | 結果 |
|---|---|---|
| 1 | xterm.js に OSC 52 ハンドラー | /copy まだ動かない |
| 2 | set-clipboard external | 何もなし (間違ったコードパス) |
| 3 | terminal-features clipboard | 何もなし (同じ理由) |
| 4 | tmux list-buffers | 空、tmux はシーケンスを見ない |
| 5 | 生の printf '\e]52;c;dGVzdA==\a' | 動く。ハンドラーが発火 |
| 6 | /copy で console.log とサーバー側 PTY ログ | どちらも発火しない |
| 7 | Claude Code が DCS パススルーで包んでいると気づく | tmux にブロックされている |
| 8 | allow-passthrough on | 解決 |
手順 5 が鍵でした。生の OSC 52 は動き、Claude Code のは動かなかった。同じシーケンス、違う封筒。
自分で作るなら
必要なのは 2 つだけです。xterm.js の OSC 52 ハンドラー (xterm.js には付属していません) と、Claude Code や Neovim から来る DCS で包まれたシーケンスがハンドラーへ届くための tmux の allow-passthrough on。
set-clipboard external も terminal-features clipboard も要りません。これらは生の OSC 52 を制御しますが、最近のツールは出力を DCS パススルーで包み、そのパイプラインを完全に迂回します。
どちらかを忘れるとクリップボードは黙って失敗します。エラーも警告もありません。