nateparker.dev

← Writing

A screenshot on the laptop, a file path on the desktop

Pasting an image into an agent running over SSH, a drop folder that needs no new daemon, and streaming the desktop's GUI to the laptop on a screen sized for the laptop.

5 min readhomelabagentstooling
A blank violet photograph in mid-air between the open ends of two curving pneumatic message tubes.

Part four of a series on wiring my machines together. Previously: unlocking an encrypted disk from the couch. Next: leaving zsh, keeping the prompt.

Once I could get a shell on the desktop from the couch in a few seconds, the next problem was everything that isn’t text.

The specific annoyance: I take a screenshot on the laptop and want to show it to an agent running on the desktop. With the agent in a local terminal, that’s paste and done. Over SSH, it doesn’t work, because a terminal session carries keystrokes and text. The image is in the Mac’s clipboard, the agent is on another machine, and nothing connects the two.

What I almost built

I had three candidate pipelines sketched out:

  1. Point the Mac’s screenshot folder at a synced directory, and tell the agent “look at ~/Drop/latest.png” — with a script on the desktop that keeps a latest.png symlink pointing at the newest file.
  2. A hotkey script on the Mac: dump the clipboard to a PNG with pngpaste, scp it to the desktop, and put the remote path on the Mac’s clipboard so I can paste the path into the prompt.
  3. Check whether the tools I already use can do it before building either.

The third one went on the list as an afterthought. It turned out to be the answer.

It was already there

herdr, which holds my agent sessions, has a remote mode. Instead of SSHing in and running herdr inside the SSH session, you run herdr on the laptop and point it at the other machine:

herdr --remote omarchy

The laptop draws the UI and the desktop runs everything. Because the client is a real program on the Mac rather than a dumb terminal, it can see the Mac’s clipboard. Press Ctrl+V with an image on it and herdr uploads the image to the desktop and pastes the file’s path into the pane. The agent gets a path it can read, which is all it ever needed.

My flow now: Shottr to grab a region, Cmd+C, switch to the session, Ctrl+V. About two seconds. Text going the other way works too — selecting in a pane copies to the Mac’s clipboard.

I changed one line in the work script from part two, from ssh -t omarchy herdr to herdr --remote omarchy, and copied the desktop’s herdr config to the Mac so keybindings and theme match.

The lesson isn’t really about herdr. I’d written two pipelines in my head before spending five minutes reading the docs of a tool I use every day.

The Wayland catch

The one bug showed up back at the desk. herdr’s server keeps running between clients, so if its first client was the laptop, every pane it starts is a child of an SSH session with no graphical environment. Sit down at the desktop, attach to the same session, and the clipboard is broken there: wl-copy, wl-paste and an agent’s own Ctrl+V image paste all need the Wayland compositor’s socket, and those shells don’t know where it is.

The fix goes in .bashrc: if WAYLAND_DISPLAY is unset, find the socket and export it.

if [[ -z ${WAYLAND_DISPLAY-} && -n ${XDG_RUNTIME_DIR-} ]]; then
  for _wl in "$XDG_RUNTIME_DIR"/wayland-[0-9]; do
    [[ -S $_wl ]] && export WAYLAND_DISPLAY=${_wl##*/} && break
  done
  unset _wl
fi

The same goes for HYPRLAND_INSTANCE_SIGNATURE, so hyprctl works from any pane. Now the clipboard behaves the same in every shell, however the session started.

A drop folder with no new daemon

For moving files that aren’t screenshots, I wanted a folder that’s simply there on both machines.

Syncthing is the usual answer and it’s good. But it’s a new daemon on each machine, a new web UI, and a new set of conflict files to learn about. The NAS was already mounted on the desktop, and macOS can mount an SMB share at login.

So ~/Drop on the desktop is a symlink to a folder on the NAS, and on the Mac the share auto-mounts as a Finder login item. Drag a file in on one side, and it’s there on the other. Nothing to sync, because there’s only one copy.

The trade-off is that it only works at home, and only while the NAS is up. Neither has been a problem. When I add Tailscale, it’ll work away from home as well without changing anything else.

The GUI, for the times a terminal isn’t enough

Sometimes I need the desktop’s actual screen — a browser with a dev build in it, a CAD model, something that won’t run in a terminal. For that I use Sunshine on the desktop and Moonlight on the laptop. They were built for game streaming, so they’re very low latency, and they encode on the GPU with NVENC.

Three things made it actually pleasant rather than merely working.

Capture without the picker. By default Sunshine captures through the XDG desktop portal, and on Hyprland that means a “choose a screen” dialog pops up on the desktop’s monitor — where I’m not sitting — every time a stream starts. Capturing straight from the compositor avoids it:

capture = wlr
encoder = nvenc

A screen sized for the client. The desktop’s monitor is an ultrawide OLED. Streamed to a 16:10 laptop, it arrives letterboxed into a thin strip with text too small to read. So when a stream starts, a prep command creates a headless virtual display at the client’s resolution and turns the OLED off; when the stream ends, it reverses both:

hyprctl output create headless SUNSHINE
hyprctl eval "hl.monitor({ output = \"SUNSHINE\",
  mode = \"${w}x${h}@${fps}\", scale = $scale })"
hyprctl eval "hl.monitor({ output = \"DP-1\", disabled = true })"

Sunshine passes the client’s width, height and frame rate to the prep command as environment variables, so a 2560×1600 laptop gets a 2560×1600 screen at 1.6× scale, and every window moves onto it. Turning the OLED off has two benefits: Hyprland moves every workspace onto the streamed screen, and a burn-in-prone panel doesn’t sit there showing a static image to an empty room. If a stream ever crashes without running its cleanup, a hotkey on the desktop restores the monitor.

Input that feels local. Moonlight’s “optimize mouse for remote desktop” setting, flat acceleration for Sunshine’s virtual mouse on the Hyprland side so the pointer isn’t accelerated twice, and capturing system shortcuts in fullscreen so ⌘ on the Mac arrives as Super on the desktop.

Two small things cost more time than they should have. Omarchy’s Sunshine installer referred to the service by an old unit name and stopped partway through, so I had to enable the right user unit by hand. And the firewall needed a rule allowing Sunshine’s ports from the LAN, or Moonlight can’t see a host on the same network.

Where that leaves things

Everything I used to set up each time is now just there: a session to get back into, images that paste across, a folder that exists on both sides, and the full desktop when I need it.

Next, the part that was mostly about taste: getting my old shell back without fighting the distro.