pi-ssh-image-clipboard

Pi extension: Ctrl+V image paste into remote pi sessions over ssh/Eternal Terminal reverse tunnels, multi-client-box aware via tmux.

Packages

Package details

extension

Install pi-ssh-image-clipboard from npm and Pi will load the resources declared by the package manifest.

$ pi install npm:pi-ssh-image-clipboard
Package
pi-ssh-image-clipboard
Version
1.0.0
Published
Jul 6, 2026
Downloads
27/mo · 17/wk
Author
pasky
License
MIT
Types
extension
Size
15.2 KB
Dependencies
0 dependencies · 1 peer
Pi manifest JSON
{
  "extensions": [
    "./extensions/ssh-image-clipboard.ts"
  ]
}

Security note

Pi packages can execute code and influence agent behavior. Review the source before installing third-party packages.

README

pi-ssh-image-clipboard

Ctrl+V image paste into remote pi sessions. Locally, pi's Ctrl+V grabs an image from your clipboard, writes a temp file and inserts its path. On a headless box over ssh/mosh/et it silently does nothing — this package makes it work, by fetching the clipboard image from the machine you are typing on through a reverse tunnel riding your ssh or Eternal Terminal connection.

Works with any terminal (ghostty, iTerm2, ...) since the terminal is not involved. Multi-client aware: if several boxes are attached to the same tmux session, the image comes from the box that pressed Ctrl+V (resolved via tmux client_activity).

How it works

Mac: launchd (inetd-style) -> pngpaste          [nothing runs when idle]
        |  unix socket / TCP 127.0.0.1:7779
        |  reverse tunnel (et env-var socket, or ssh RemoteForward)
        v
remote: per-connection socket (+ ~/.pi-clip/by-tty/<tty>.sock symlink
        maintained by a login hook)
        v
pi extension: Ctrl+V -> pick socket of the most-recently-active tmux
        client -> fetch bytes -> /tmp/pi-clipboard-<uuid>.png -> insert path

The extension registers Ctrl+V only on headless Linux (no DISPLAY/WAYLAND_DISPLAY), so it never shadows pi's built-in image paste on desktop machines. With no live tunnel (e.g. connected via mosh, which cannot forward anything) it is a silent no-op, matching stock behavior.

Install (remote box running pi)

pi install git:github.com/pasky/pi-ssh-image-clipboard

Add the login hook to ~/.bashrc (maps each login tty to that connection's clipboard socket):

# pi-ssh-image-clipboard: map this login's tty to the client box's clipboard
# socket, so Ctrl+V in pi grabs the clipboard of the box you're typing from.
# ssh: LC_PI_CLIP names the box socket (~/.pi-clip/<box>.sock, RemoteForward).
# et:  PI_CLIP_SOCK is set by `et -r PI_CLIP_SOCK:/client/sock/path`.
if [ -z "$TMUX" ]; then
	_pi_clip_target=""
	if [ -n "$PI_CLIP_SOCK" ]; then
		_pi_clip_target="$PI_CLIP_SOCK"
	elif [ -n "$SSH_TTY" ] && [ -n "$LC_PI_CLIP" ]; then
		_pi_clip_target="$HOME/.pi-clip/$LC_PI_CLIP.sock"
	fi
	if [ -n "$_pi_clip_target" ] && _pi_clip_tty=$(tty 2>/dev/null); then
		case "$_pi_clip_tty" in /dev/pts/*)
			mkdir -p ~/.pi-clip/by-tty
			ln -sf "$_pi_clip_target" ~/.pi-clip/by-tty/"$(basename "$_pi_clip_tty")".sock
		esac
	fi
	unset _pi_clip_target _pi_clip_tty
fi

One-time: mkdir -p ~/.pi-clip/by-tty && chmod 700 ~/.pi-clip

Recommended if you use the ssh (RemoteForward) transport (needs root): let sshd replace stale socket files after unclean disconnects, otherwise the re-established forward fails until the stale .sock file is removed:

echo 'StreamLocalBindUnlink yes' | sudo tee /etc/ssh/sshd_config.d/60-pi-clip.conf
sudo sshd -t && sudo systemctl reload ssh

Client box setup (Mac)

Any OS works if it serves its clipboard as an image on a local socket; instructions below are for macOS.

Replace pasky below with your username, of course.

brew install pngpaste

Create ~/Library/LaunchAgents/org.pasky.clipserve.plist — launchd itself listens and spawns pngpaste - per connection (inetd-style, no daemon running when idle). Adjust the two /Users/pasky paths; Intel Macs have pngpaste at /usr/local/bin/pngpaste:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
	<key>Label</key><string>org.pasky.clipserve</string>
	<key>ProgramArguments</key>
	<array>
		<string>/opt/homebrew/bin/pngpaste</string>
		<string>-</string>
	</array>
	<key>inetdCompatibility</key>
	<dict><key>Wait</key><false/></dict>
	<!-- keep pngpaste's stderr (e.g. "no image" error text) off the socket -->
	<key>StandardErrorPath</key><string>/tmp/pngpaste-clipserve.err</string>
	<key>Sockets</key>
	<dict>
		<!-- TCP listener: used by ssh RemoteForward -->
		<key>ListenerTCP</key>
		<dict>
			<key>SockNodeName</key><string>127.0.0.1</string>
			<key>SockServiceName</key><string>7779</string>
		</dict>
		<!-- Unix socket listener: used by et's env-var reverse tunnel -->
		<key>ListenerUnix</key>
		<dict>
			<key>SockPathName</key><string>/Users/pasky/.clipserve.sock</string>
		</dict>
	</dict>
</dict>
</plist>

Activate and test locally (screenshot to clipboard first, cmd-ctrl-shift-4):

launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/org.pasky.clipserve.plist
nc -U ~/.clipserve.sock | file -    # expect: PNG image data ...
nc 127.0.0.1 7779 | file -          # ditto

(After later plist edits: launchctl bootout gui/$(id -u)/org.pasky.clipserve, rm -f ~/.clipserve.sock, then bootstrap again — launchd does not re-read plists on its own.)

Connecting

Eternal Terminal — tested with et 6.2.11

alias et='et -k 1 -r PI_CLIP_SOCK:/Users/pasky/.clipserve.sock'
et thatbox

et creates a private per-connection socket on the remote (/tmp/et_forward_sock_XXXXXX/sock, mode 0700), exports its path as $PI_CLIP_SOCK in the session, and connects it to the client-side UNIX socket named after the colon (absolute client-side path, no ~). The ~/.bashrc hook does the rest; no box name needed.

Do NOT try -r /remote/path.sock:7779 — released et (6.2.x) has no socket-path tunnel syntax (it fails with a bogus "port range" error); that syntax only exists in unreleased master. Plain TCP -r 7779:7779 works but supports only a single client box (last/first bind wins).

ssh

Pick a short unique name per client box (e.g. mbp). In the box's ~/.ssh/config:

Host thatbox
	RemoteForward /home/pasky/.pi-clip/mbp.sock localhost:7779
	SetEnv LC_PI_CLIP=mbp

LC_* is used because stock Debian sshd has AcceptEnv LANG LC_* — no server-side sshd change needed for the env var.

mosh

Cannot work — mosh forwards nothing. Ctrl+V degrades to a silent no-op.

Ctrl+V resolution order

  1. ~/.pi-clip/by-tty/<tty>.sock of the tmux client with the newest client_activity (the keypress itself bumps it).
  2. Every ~/.pi-clip/*.sock and ~/.pi-clip/by-tty/*.sock, newest first.
  3. Legacy ~/.pi-clip.sock, then TCP 127.0.0.1:7779.

Candidates are probed with a 300 ms connect timeout; dead/stale sockets are skipped. Once a socket accepts, it is authoritative: an empty/imageless response shows "No image on client clipboard" rather than falling through to a different box's clipboard.

Env overrides: PI_REMOTE_CLIP_DIR, PI_REMOTE_CLIP_SOCK (legacy single socket), PI_REMOTE_CLIP_PORT.

Notes

  • Non-PNG clipboard images (e.g. JPEG copied from a browser) are fine: pngpaste converts to PNG on output. The extension sniffs magic bytes (png/jpg/gif/webp) and rejects anything else.
  • Empty clipboard: pngpaste exits non-zero writing an error to stderr; the plist's StandardErrorPath keeps that text off the socket so the extension sees a clean empty read.
  • Multi-user remote boxes: unix sockets are used precisely so other local users cannot read your client clipboard (et's socket dir is 0700; keep ~/.pi-clip at 0700).

Tests

node test/ssh-image-clipboard.test.mjs