Dev

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:

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
})

The format is \x1b]52;c;<base64>\x07c 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:

bash
set -g set-clipboard external  # pass OSC 52 through to outer terminal

Didn'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:

bash
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.

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

One option. Two hours.

The full solution

On the backend (Python/FastAPI), enable passthrough before attach and unset it on 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
    ])

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:

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
  })
}

If you have split panes, each Terminal instance needs its own handler.

Debugging timeline

StepWhat I triedResult
1OSC 52 handler in xterm.js/copy still broken
2set-clipboard externalNothing (wrong code path)
3terminal-features clipboardNothing (same reason)
4tmux list-buffersEmpty — tmux never sees the sequence
5Raw printf '\e]52;c;dGVzdA==\a'Works. Handler fires
6console.log + server-side PTY log on /copyNeither fires
7Realized Claude Code wraps in DCS passthroughSequence blocked by tmux
8allow-passthrough onFixed

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.

Discussion

No comment section here — all discussions happen on X.

More articles

The tool you install has your reach

An installed tool holds whatever authority you already have, and handing it over is the one security decision that gets no second look: taken once, in the least deliberate act of the whole install, and never revisited. You can contain what it can reach or read what its code actually touches; trusting the name does neither.

The compaction is an untrusted input

Input from outside gets a discipline and the agent's own output does not, so the summary it writes to survive a reset and the memory it recalls come back in as instructions with a standing nothing revoked. The author the harness records for them is the model itself, which is the exact reason the check lets them through.

A prompt is not an invariant

A rule you write into an agent prompt or a CLAUDE.md is advisory: the model reads it on every path but only weighs it, and weighing is not refusing, so it holds most of the time, and most of the time is not what a load-bearing rule is for. To make one actually hold it has to move to where compliance is not optional, a check the harness runs and enforces on its own, regardless of what the model decided. With a limit: a gate binds only over the paths it covers, and some rules cannot be settled at any gate at all, because their violation shows up in the world and not in the action it would inspect.