Max Nardit
Dev

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 を無視します。エスケープシーケンスは届き、パーサーはそれを見て、何も起こりません。ハンドラーを手動で登録する必要がありました。

javascript
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 があります。

bash
set -g set-clipboard external  # OSC 52 を外側のターミナルへパススルー

効きませんでした。次に、tmux が外側のターミナルが OSC 52 をサポートするかを判断するために Ms という terminfo capability をチェックすることを見つけました。私の xterm-256color には無かったのです。tmux 3.2+ には回避策があります。

bash
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 externalterminal-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 ハンドラーは何も見ません。

bash
tmux set-option -t <session> allow-passthrough on

オプション 1 つ。2 時間。

完全な解決策

バックエンド (Python/FastAPI) で、attach の前にパススルーを有効にし、disconnect で戻します。

python
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 インスタンスにハンドラーを登録します。

javascript
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 インスタンスに自分のハンドラーが必要です。

デバッグの時系列

手順試したこと結果
1xterm.js に OSC 52 ハンドラー/copy まだ動かない
2set-clipboard external何もなし (間違ったコードパス)
3terminal-features clipboard何もなし (同じ理由)
4tmux list-buffers空、tmux はシーケンスを見ない
5生の printf '\e]52;c;dGVzdA==\a'動く。ハンドラーが発火
6/copy で console.log とサーバー側 PTY ログどちらも発火しない
7Claude Code が DCS パススルーで包んでいると気づくtmux にブロックされている
8allow-passthrough on解決

手順 5 が鍵でした。生の OSC 52 は動き、Claude Code のは動かなかった。同じシーケンス、違う封筒。

自分で作るなら

必要なのは 2 つだけです。xterm.js の OSC 52 ハンドラー (xterm.js には付属していません) と、Claude Code や Neovim から来る DCS で包まれたシーケンスがハンドラーへ届くための tmux の allow-passthrough on

set-clipboard externalterminal-features clipboard も要りません。これらは生の OSC 52 を制御しますが、最近のツールは出力を DCS パススルーで包み、そのパイプラインを完全に迂回します。

どちらかを忘れるとクリップボードは黙って失敗します。エラーも警告もありません。

ディスカッション

コメント欄はありません。議論は X で行っています。

ほかの記事

xterm.js + tmux での OSC 52 クリップボード修正 (allow-passthrough)