How I Fixed OSC 52 Clipboard in xterm.js Through tmux
OSC 52 clipboard doesn't work in xterm.js through tmux. The fix isn't set-clipboard or terminal-features — it's allow-passthrough.
I'm building a web dashboard that gives browser-based terminal access to tmux sessions via xterm.js + WebSocket + PTY. Claude Code has a /copy command that uses OSC 52 to copy text to the system clipboard. Works perfectly in iTerm2, Windows Terminal, Ghostty. Through my web terminal — nothing.
Took me two hours and three wrong turns to figure out why.
xterm.js doesn't handle OSC 52
First problem: xterm.js ignores OSC 52 by default. The escape sequence arrives, the parser sees it, nothing happens. I had to register a handler manually:
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
})The format is \x1b]52;c;<base64>\x07 — c is the clipboard selection, the payload is base64-encoded text. One gotcha: atob returns a binary string, not UTF-8. You need TextDecoder to handle non-ASCII characters correctly.
Should have been the whole fix. Wasn't.
tmux set-clipboard, the red herring
After adding the handler, /copy in Claude Code still didn't work. I went down the tmux configuration rabbit hole.
tmux has set-clipboard which controls OSC 52 handling:
set -g set-clipboard external # pass OSC 52 through to outer terminalDidn't help. Then I found that tmux checks the Ms terminfo capability to decide if the outer terminal supports OSC 52. My xterm-256color didn't have it. tmux 3.2+ has a workaround:
set -g terminal-features "xterm-256color:clipboard"Still nothing. I checked tmux list-buffers after /copy — empty. tmux wasn't seeing OSC 52 at all. None of this was relevant.
Here's why: set-clipboard controls how tmux handles raw OSC 52. But Claude Code doesn't send raw OSC 52 inside tmux — it wraps it in DCS passthrough (next section). DCS passthrough is a completely different code path that bypasses tmux's clipboard handling entirely. So set-clipboard external and terminal-features clipboard were solving the wrong problem.
The breakthrough came from a different test: I ran printf '\e]52;c;dGVzdA==\a' directly inside tmux. It worked. My xterm.js handler fired, "test" appeared in the clipboard. Raw OSC 52 passes through tmux just fine.
So why doesn't Claude Code's /copy work?
DCS passthrough, the real problem
Claude Code detects $TMUX and wraps OSC 52 in a DCS passthrough envelope:
\ePtmux;\e\e]52;c;<base64>\a\e\\
This tells tmux "pass this escape sequence directly to the outer terminal." It's what Neovim and tmux-yank do too — the standard approach for tools that know they're running inside tmux.
The catch: tmux 3.3+ blocks DCS passthrough by default. allow-passthrough defaults to off. The DCS envelope is silently dropped. My xterm.js handler never sees anything.
tmux set-option -t <session> allow-passthrough onOne option. Two hours.
The full solution
On the backend (Python/FastAPI), enable passthrough before attach and unset it on 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
])Per-session, not global. Unset on disconnect to minimize the security surface — DCS passthrough lets the inner shell ship arbitrary escape sequences to your terminal, so leaving it on globally is more attack surface than you want.
On the frontend (xterm.js), register the handler on every Terminal instance:
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
})
}If you have split panes, each Terminal instance needs its own handler.
Debugging timeline
| Step | What I tried | Result |
|---|---|---|
| 1 | OSC 52 handler in xterm.js | /copy still broken |
| 2 | set-clipboard external | Nothing (wrong code path) |
| 3 | terminal-features clipboard | Nothing (same reason) |
| 4 | tmux list-buffers | Empty — tmux never sees the sequence |
| 5 | Raw printf '\e]52;c;dGVzdA==\a' | Works. Handler fires |
| 6 | console.log + server-side PTY log on /copy | Neither fires |
| 7 | Realized Claude Code wraps in DCS passthrough | Sequence blocked by tmux |
| 8 | allow-passthrough on | Fixed |
Step 5 was the key — raw OSC 52 worked, Claude Code's didn't. Same sequence, different envelope.
If you're building this yourself
Two things you need, and only two: an OSC 52 handler in xterm.js (it doesn't ship one), and allow-passthrough on in tmux so DCS-wrapped sequences from Claude Code or Neovim reach your handler.
You don't need set-clipboard external or terminal-features clipboard. Those control raw OSC 52, but modern tools wrap their output in DCS passthrough which bypasses that whole pipeline.
Miss either one and clipboard silently fails. No error, no warning.