HPC container for Financial / Quantitative computing. Workflow has migrated from VSCode Remote-SSH (legacy, still works) to Neovim SSH.
./build_container.shOne-shot: builds with Podman → podman export to a flat rootfs tar → optional
scp to CIRCE (/work/g/gson/fintech-rootfs.tar) → optionally runs
sync_configs.sh (default yes) to push configs + the ~/.local/bin wrappers, so
the node is fully ready in a single run. Both deployment choices are asked up
front, then the build runs unattended. Pushover notifications fire if
~/.pushover_config is set.
Prereqs (one-time):
brew install podman
podman machine init && podman machine startOne-time setup (./build_container.sh deploys the rootfs tar and runs the
config sync automatically; run sync_configs.sh on its own only when you change
configs without rebuilding):
./sync_configs.sh # push to CIRCE: term_session.sh → ~/sh/, udocker_dev.sh → ~/bin/,
# the bookokrat helpers → ~/.local/bin/,
# ~/.config/{nvim,yazi,tmux,bookokrat,starship,…},
# and the Mac's herdr settings + pluginsDaily use: sbatch ~/sh/term_session.sh, then ./connect_nvim.sh from the Mac.
The launcher (udocker, Fakechroot/F3) imports the rootfs into node-local /tmp
and enters the container. It auto-refreshes when the tar is newer than the
unpacked copy, so a rebuild is picked up with no manual step.
| Stack | Detail |
|---|---|
| Base | Ubuntu 24.04 LTS (Noble) headless |
| Python | 3.13 via deadsnakes; /usr/local/bin/python{,3} symlinked, env vars (PYTHON, RETICULATE_PYTHON, UV_PYTHON) point here — not the system 3.12 |
| uv | Astral binary in /usr/local/bin/ |
| R | 4.x from CRAN noble-cran40, default repo set to Posit Package Manager (noble/latest) so install.packages() pulls binary builds for Ubuntu instead of compiling from source |
| Quarto | Latest GitHub release, installed at /opt/quarto, on $PATH as quarto. Bundles pandoc, typst + Deno. The bundled pandoc and typst are static, and under udocker F3 a static binary looks up /opt/quarto/share on the host, so the image replaces both with dynamic builds of the same versions (pandoc from pandoc/core:<v>-ubuntu, typst compiled in its own stage). PDF output works out of the box via the TinyTeX install below, or via typst. |
| LaTeX | TinyTeX baked in at /opt/TinyTeX — installed via direct tarball pull from rstudio/tinytex-releases (TinyTeX-linux-x86_64-<TAG>.tar.xz), no R/Rscript dependency in Stage 5d. Binaries symlinked into /usr/local/bin via tlmgr path add (with sys_bin pinned explicitly so the build does not fall back to /root/.local/bin). Ships latexmk, pdflatex, xelatex, lualatex, biber, plus collection-latexrecommended, collection-fontsrecommended, and biblatex. VimTeX (lang.tex extra) and Quarto find them automatically. The image is read-only at runtime, so extra packages install in user-mode — TEXMFHOME is pinned to ~/texmf in zshenv so tlmgr --usermode init-usertree && tlmgr --usermode install <pkg> lands in a predictable, kpathsea-discoverable tree (no root required). Alternatively, rebuild the container with the package appended to Stage 5d. A pre-existing ~/.TinyTeX install is honored — zshenv prepends it to $PATH so any user-installed extras win over the system copy. |
| Editor | Neovim (latest) + LazyVim starter |
| Own nvim plugins | quarto-render.nvim (Quarto CLI above) and replstudio.nvim (R/Python REPL + plot pane), both cloned from GitHub by lazy.nvim, so the container runs what has been pushed. replstudio's container spec is configs/nvim/lua/plugins/replstudio.lua, which replaces the Mac's dir = copy. The image carries only the duckdb CLI for its parquet viewer. Its Python venv (~/.local/share/replstudio/venv) and R's ragg are installed at runtime, into $HOME |
| LazyVim extras | Whatever the Mac's ~/.config/nvim/lazyvim.json enables (the nvim sync is an overlay of it), minus lang.rust. No AI extras: avante.nvim and Copilot were removed 2026-10-01 |
| Terminal | tmux 3.6b (multiplexer, built from source — newer than 24.04's apt 3.4, which is too old for ghostty's CSI-u extended keys over SSH and broke Ctrl+Space/Ctrl+h/j/k/l), herdr (agent multiplexer, built from source as a dynamic binary — see "herdr" below), Yazi (file manager) with all recommended deps, lazygit, ncurses-term (many terminfos) |
| AI agents | claude (Claude Code) is bundled and runs well in-container under udocker/F3 (much snappier than the old ptrace runtime; F3's LD_PRELOAD path avoids the per-syscall tax). Running it locally on the Mac and driving the node over SSH is still an option. Copilot is not bundled in any form: neither the standalone copilot CLI nor the nvim Copilot/avante plugins and their language server (removed 2026-10-01 after repeated build failures). |
| SSH | openssh-client (git/scp); sshd not used |
| PDF viewer | bookokrat at /usr/local/bin/bookokrat — terminal PDF/EPUB reader (kitty graphics, renders over SSH; no X11). VimTeX (<localleader>lv), yazi, and snacks-explorer route PDFs to it (see "PDF viewing" below). |
Yazi deps included (per yazi docs):
file, p7zip, jq, poppler-utils, fd, ripgrep, fzf,
zoxide, imagemagick, resvg, unar.
## PDF viewing — bookokrat
PDFs (and EPUBs) open in **bookokrat**, a terminal reader that draws inline via
the kitty graphics protocol — which **ghostty** renders straight over SSH, so no
X11/XQuartz and no Mac-side helper are involved. Synctex works both ways because
Neovim and bookokrat run on the same node.
- **Binary** — baked into the image at `/usr/local/bin/bookokrat` (Dockerfile Stage 5f),
from the [`matthewgson/bookokrat`](https://github.com/matthewgson/bookokrat) fork's
releases (`vX.Y.Z-fork.N`), not upstream. Over SSH the terminal can't read
bookokrat's shared memory, so upstream sends every PDF page as ~18 MB of raw RGB
(~2 s per page turn); the fork zlib-compresses it (kitty `o=z`, ~0.5 MB,
~0.2-0.4 s). `build_container.sh` resolves the fork's latest release. The Mac
keeps Homebrew's upstream build, since shared memory works locally.
- **Wrappers** — synced to `~/.local/bin/` by `sync_configs.sh` (on `$PATH` inside the container):
- `bookokrat-split` — opens a PDF in a **half split beside the caller** (forwards
`$NVIM` for inverse search). Opening a second PDF respawns that same reader pane
rather than splitting again; opening one already on screen just jumps to it.
Panes carry the pane option `@bkfile`, which is how both are recognised.
- `bookokrat-forward` — VimTeX forward search (`<localleader>lv`): jumps an open instance, or launches one.
- `bookokrat-inverse` — synctex inverse search: `gd` / Ctrl-click in the PDF jumps Neovim to the source line.
- `tmux-kitty-image-gc` — takes the page **off the screen** when you switch away
from the reader or quit it (bookokrat#179). Under ghostty bookokrat draws a
classic kitty placement, which **tmux holds no cell for**, so a window switch
repaints the text and leaves the page painted over the window you switched to.
This writes the kitty delete escape straight to the client tty — around tmux's
pane-visibility rules, which would swallow it — then sends `C-l` to any reader
still on screen. Deletes are targeted at z-index `-1` / image id `1`, where
bookokrat draws and yazi does not, so a neighbouring preview survives. Wired in
`configs/tmux/tmux.conf` (`session-window-changed`, `client-session-changed`)
and appended to the reader's own command line by `bookokrat-split`. The repaint
on return cost ~1-1.5s here with upstream bookokrat, against ~100-300ms on
the Mac. That was the raw page crossing SSH, which the fork (above) compresses.
Only a reader that is **visible but not focused** actually leaks: with the
cursor in the reader, bookokrat tears its own placements down on focus-out and
this script finds nothing to do. That is why the bug reads as intermittent —
it depends on which pane had the cursor. `focus-events on` is therefore
load-bearing for images, not just for nvim. Measured 2026-09-07.
- **One file each, shared with the Mac.** `sync_configs.sh` prefers the LIVE
`~/.local/bin/` copy over the repo's `local-bin/` snapshot, so an edit made live must be
copied back to the repo **and** an edit made only in the repo is discarded by the
next sync. Platform differences live inside the scripts (`uname -s`), which is how
`bookokrat-split` picks Homebrew's binary on the Mac and `/usr/local/bin/bookokrat`
here.
- **Retired: the `~/.local/bin/override/bookokrat` wrapper** (and its `tmux` shim
under `~/.local/libexec/`). It answered bookokrat's outer-terminal probe with
`kitty` to force the Unicode-placeholder anchor path back on. Ghostty 1.3.1 does
not implement the relative placements that path emits (kitty keys `P=`/`Q=`) — it
drops them and paints the page at the cursor, i.e. on top of yazi, leaving the
reader's pane empty. Removed 2026-09-04; it was never viable here in any case,
the anchor path being implemented only over SHM transmission while the shm object
lives in `/dev/shm` on the compute node and ghostty runs on the Mac. Do not
reintroduce it — `tmux-kitty-image-gc` is the supported fix.
- **Config** — `configs/bookokrat/` → `~/.config/bookokrat/` (inverse search wired via `synctex_editor`).
- **Wiring** — VimTeX (`configs/nvim/lua/plugins/vimtex.lua`, `general` viewer), yazi
(`*.pdf`/`*.epub` opener), and `vim.ui.open` / snacks-explorer
(`pdf_open.lua`, `snacks.lua`) all route PDFs to bookokrat.
### Test
After SSH'ing in via `./connect_nvim.sh`, from inside the container (in tmux):
```bash
bookokrat-split /work/g/gson/some.pdf # opens in a new tmux split
In yazi: Enter on a .pdf/.epub opens it in a bookokrat split.
In Neovim: <localleader>lv in a .tex forward-searches; gd / Ctrl-click in
bookokrat jumps back to the source line.
Start tmux on this side first — "in tmux" above is not optional. If you SSH
in from inside a local tmux on the Mac and run bookokrat with no tmux here,
bookokrat sees no $TMUX, emits raw kitty escapes instead of wrapping them for
passthrough, and the local tmux swallows them: the reader's frame and scrollbar
draw and the page never appears. tm first. It looks like a PDF that failed to
load rather than a terminal problem, which is what makes it worth knowing.
Nesting is otherwise fine. With tmux running on both sides the page renders
correctly through both multiplexers, and nothing is left painted behind on an
inner or an outer session switch — the leftover-page bug needs a single
multiplexer, i.e. the ordinary bare-ghostty connect_nvim.sh case. Measured end
to end with a real PDF, 2026-09-07.
herdr is the container's multiplexer, and the startup
messages point only to it. tmux (tm) stays installed because bookokrat's PDF
split still needs it. Start herdr by hand after ./connect_nvim.sh:
herdr attaches to the running server, or starts one and restores the saved
layout.
-
The
/tmpself-heal does not depend on the multiplexer. The reaper repair runs inudocker_dev.shon every./connect_nvim.sh, before any shell starts, and nvim's plugin heal runs in.zshrc'snvim()wrapper, which herdr's pane shells load (they are interactive zsh). So keep coming in throughconnect_nvim.shand reattach withherdr, as you did withtm. herdr puts nothing on/tmpexcept its sockets: its binary is part of the ROOT, swept with the rest of it, and its layout, logs and plugin state are on$HOME. -
The layout outlives the job. herdr saves it to
~/.config/herdr/session.jsonand restores it, including Claude conversations, whenever a server starts. Because that file is on$HOME, it survives the reaper, a new node, and the end of the 7-day job. -
Stop the server after a rebuild. A new image replaces the ROOT under a server that is still running, and that server's new panes fail.
udocker_dev.shwarns on connect when that happens. Runherdr server stop, thenherdr. -
Built from source, not downloaded. The release binary is static musl. Under F3 a static parent never loads libfakechroot, so every pane shell,
/bin/sh -ccommand and plugin action it spawned would run against the RHEL7 host and die with theGLIBC_PRIVATErelocation error. The Dockerfile'sherdrbuildstage cross-compilesx86_64-unknown-linux-gnuon the build machine's own arch, so it does not run under emulation. That gives a normal dynamic binary that behaves like tmux. For the same reasonherdr updateis refused in the container: upgrade by rebuilding the image. -
Settings follow the Mac.
sync_configs.shpushes~/.config/herdr's settings and plugins (see the table below). On the nvim side, the Mac'sherdr-splits.luacomes across with the nvim overlay, soCtrl+h/j/k/lcrosses between herdr panes and nvim splits there too. The one exception is the machine label at the right of the tab row: the sync rewrites the Mac's Apple-iconMacOSto a server-iconCIRCE, so you can tell at a glance which herdr you are in. -
Sockets are on node-local
/tmp..zshrcsetsHERDR_SOCKET_PATHto/tmp/$USER/herdr/herdr.sock, because the default~/.config/herdris on the network home. Named sessions (--session) still keep their sockets under~/.config/herdr/sessions/. -
Claude shows up as an agent. The
claudewrapper runs through the container's loader, which hides the name herdr matches on. Inside herdr it setsHERDR_AGENT=claudeto restore it. -
Do not point the Mac's
herdr --remote/herdr machine addat CIRCE. Both install the static release on the host, outside the container, and run its server there. They also open SSH connections of their own against the login node's rate limit.
sync_configs.sh applies a per-cfg policy in its staging step:
| cfg | Policy | Why |
|---|---|---|
yazi |
replace — repo's configs/yazi/ becomes CIRCE's ~/.config/yazi/ |
Container-specific openers (e.g. bookokrat for PDFs) |
tmux |
replace — repo's configs/tmux/ becomes CIRCE's ~/.config/tmux/ |
Container-specific multiplexer config (replaced zellij); no Mac copy |
bookokrat |
replace — repo's configs/bookokrat/ becomes CIRCE's ~/.config/bookokrat/ |
Container owns the bookokrat config |
nvim |
overlay — Mac's ~/.config/nvim/ is staged, then configs/nvim/* is rsync'd on top |
Keeps your Mac LazyVim config canonical; drops in the container plugins |
bottom |
default — direct from Mac | No container-specific overrides needed |
herdr |
mirror, filtered — Mac's config.toml, plugins.json and plugins/ (minus .git), with the Mac's $HOME in plugins.json rewritten to CIRCE's |
Same keys, theme and plugins as the Mac. Its sockets, logs and saved sessions are runtime state and stay put |
avante.nvim and github-copilot used to sync here too. They were dropped on
2026-10-01 together with the avante and Copilot nvim plugins. A sync never
deletes on CIRCE, so old copies of both directories stay there until removed by
hand.
To add another override: drop files into configs/<cfg>/ and (if needed) add
the cfg name to CONFIG_LIST and the replace/overlay case in sync_configs.sh.
install.packages("tidyverse") pulls precompiled Ubuntu binaries from PPM —
about 30s vs. ~10 minutes for the from-source CRAN build. The config lives at
/etc/R/Rprofile.site inside the container; switch the URL from
noble/latest to noble/2026-MM-DD for a pinned, reproducible snapshot.