Wie ich OSC 52 Clipboard in xterm.js durch tmux zum Laufen gebracht habe
OSC 52 Clipboard funktioniert in xterm.js durch tmux nicht. Der Fix ist nicht set-clipboard oder terminal-features, sondern allow-passthrough.
Ich baue ein Web-Dashboard, das browserbasierten Terminal-Zugriff auf tmux-Sessions über xterm.js + WebSocket + PTY bietet. Claude Code hat einen /copy-Befehl, der über OSC 52 Text in die System-Zwischenablage kopiert. Funktioniert tadellos in iTerm2, Windows Terminal, Ghostty. Durch mein Web-Terminal: nichts.
Hat mich zwei Stunden und drei Sackgassen gekostet, um herauszufinden warum.
xterm.js verarbeitet OSC 52 nicht
Erstes Problem: xterm.js ignoriert OSC 52 standardmäßig. Die Escape-Sequenz kommt an, der Parser sieht sie, nichts passiert. Ich musste einen Handler manuell registrieren:
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
})Das Format ist \x1b]52;c;<base64>\x07. c ist die Clipboard-Selection, der Payload ist Base64-codierter Text. Eine Stolperfalle: atob gibt einen Binär-String zurück, kein UTF-8. Du brauchst TextDecoder, damit Nicht-ASCII-Zeichen korrekt landen.
Hätte der ganze Fix sein sollen. War es nicht.
tmux set-clipboard, der falsche Hinweis
Nachdem der Handler installiert war, funktionierte /copy in Claude Code immer noch nicht. Ich bin in den tmux-Konfigurations-Kaninchenbau gestiegen.
tmux hat set-clipboard, das die OSC-52-Behandlung steuert:
set -g set-clipboard external # OSC 52 ans äußere Terminal weiterreichenHat nicht geholfen. Dann habe ich gefunden, dass tmux die Terminfo-Capability Ms prüft, um zu entscheiden, ob das äußere Terminal OSC 52 unterstützt. Mein xterm-256color hatte sie nicht. tmux 3.2+ hat einen Workaround:
set -g terminal-features "xterm-256color:clipboard"Trotzdem nichts. Ich habe tmux list-buffers nach /copy geprüft: leer. tmux hat OSC 52 überhaupt nicht gesehen. Nichts davon war relevant.
Hier ist der Grund: set-clipboard steuert, wie tmux rohes OSC 52 behandelt. Aber Claude Code schickt innerhalb von tmux kein rohes OSC 52, sondern wickelt es in DCS-Passthrough ein (nächster Abschnitt). DCS-Passthrough ist ein komplett anderer Code-Pfad, der das Clipboard-Handling von tmux vollständig umgeht. set-clipboard external und terminal-features clipboard haben also das falsche Problem gelöst.
Der Durchbruch kam aus einem anderen Test: Ich habe printf '\e]52;c;dGVzdA==\a' direkt in tmux ausgeführt. Hat funktioniert. Mein xterm.js-Handler hat gefeuert, „test" landete in der Zwischenablage. Rohes OSC 52 läuft sauber durch tmux durch.
Warum funktioniert dann /copy von Claude Code nicht?
DCS-Passthrough, das eigentliche Problem
Claude Code erkennt $TMUX und wickelt OSC 52 in eine DCS-Passthrough-Hülle:
\ePtmux;\e\e]52;c;<base64>\a\e\\
Das sagt tmux: „reich diese Escape-Sequenz direkt ans äußere Terminal weiter". Genau das machen Neovim und tmux-yank auch, der Standard-Ansatz für Tools, die wissen, dass sie in tmux laufen.
Der Haken: tmux 3.3+ blockiert DCS-Passthrough standardmäßig. allow-passthrough ist auf off. Die DCS-Hülle wird stillschweigend verworfen. Mein xterm.js-Handler sieht nie etwas.
tmux set-option -t <session> allow-passthrough onEine Option. Zwei Stunden.
Die vollständige Lösung
Im Backend (Python/FastAPI) Passthrough vor dem Attach aktivieren und beim Disconnect wieder zurücksetzen:
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
])Pro Session, nicht global. Beim Disconnect zurücksetzen, um die Sicherheits-Angriffsfläche klein zu halten. DCS-Passthrough lässt die innere Shell beliebige Escape-Sequenzen an dein Terminal schicken, also ist „global an" mehr Angriffsfläche, als du willst.
Im Frontend (xterm.js) den Handler auf jeder Terminal-Instanz registrieren:
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
})
}Bei Split-Panes braucht jede Terminal-Instanz ihren eigenen Handler.
Debugging-Timeline
| Schritt | Was ich versucht habe | Ergebnis |
|---|---|---|
| 1 | OSC-52-Handler in xterm.js | /copy immer noch kaputt |
| 2 | set-clipboard external | Nichts (falscher Code-Pfad) |
| 3 | terminal-features clipboard | Nichts (gleicher Grund) |
| 4 | tmux list-buffers | Leer, tmux sieht die Sequenz nie |
| 5 | Rohes printf '\e]52;c;dGVzdA==\a' | Funktioniert. Handler feuert |
| 6 | console.log + serverseitiges PTY-Log auf /copy | Keiner feuert |
| 7 | Erkannt: Claude Code wickelt in DCS-Passthrough ein | Sequenz von tmux blockiert |
| 8 | allow-passthrough on | Gefixt |
Schritt 5 war der Schlüssel: rohes OSC 52 hat funktioniert, das von Claude Code nicht. Gleiche Sequenz, andere Hülle.
Wenn du das selbst baust
Zwei Dinge brauchst du, und nur diese: einen OSC-52-Handler in xterm.js (xterm.js liefert keinen mit) und allow-passthrough on in tmux, damit DCS-eingewickelte Sequenzen von Claude Code oder Neovim deinen Handler erreichen.
Du brauchst weder set-clipboard external noch terminal-features clipboard. Die steuern rohes OSC 52, aber moderne Tools wickeln ihren Output in DCS-Passthrough, was diese ganze Pipeline umgeht.
Vergiss eines davon und das Clipboard scheitert lautlos. Kein Fehler, keine Warnung.