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. Here's the full debugging story with working code.
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. Important: 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 in 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 the standard approach — Neovim, tmux-yank, and most modern CLI tools do the same.
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
Backend (Python/FastAPI) — enable passthrough before attach, disable 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.
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.
The checklist
If you're building a web terminal with xterm.js and tmux, you need two things:
- xterm.js
registerOscHandler(52, ...)— xterm.js doesn't ship one - tmux
allow-passthrough on— so DCS-wrapped sequences from Claude Code, Neovim, and other modern CLI tools reach your handler
That's it. You don't need set-clipboard external or terminal-features clipboard — those control raw OSC 52 handling, but modern tools use DCS passthrough which bypasses that entirely.
Miss either one and clipboard silently fails. No error, no warning.