Skip to content

Dev VM Shell

The dev VM’s login shell is zsh, with a Starship prompt and the modern Rust CLI stack. This page documents what is installed and — more usefully — the handful of things that are non-obvious and cost real time to discover.

Set up 2026-08-08 on deployd-dev (Ubuntu 26.04).

Everything shell-agnostic lives in one file sourced by both rc files, so bash remains a fully working fallback with an identical PATH and identical aliases:

~/.config/shell/common.sh PATH, exports, aliases, lazy nvm, fzf environment
├── sourced by ~/.zshrc before the interactive guard
└── sourced by ~/.bashrc before its `case $- in *i*)` block
~/.zshrc history, completion, keybinds, plugins, starship
~/.bashrc stock Ubuntu content + starship/fzf/zoxide/direnv, interactive-only
~/.config/starship.toml Gruvbox Rainbow, tuned (see below)
~/.config/bat/config gruvbox-dark, matched to the prompt
~/.config/ccstatusline/settings.json Claude Code statusline
~/.config/zellij/config.kdl `theme "gruvbox-dark"` added; existing keybinds untouched

Everything except Starship and ccstatusline came from Ubuntu 26.04’s own repos — no cargo, no brew, no PPAs.

ToolReplacesNotes
zsh 5.9 + autosuggestions + syntax-highlightingbashDistro packages, no Oh My Zsh
starship 1.26PS1~/.local/bin, installed without sudo
fzf 0.67Ctrl-RCtrl-R history, Ctrl-T files, Alt-C dirs
zoxide 0.9.8cdz Logrite-UI from anywhere; zi for an fzf picker
eza 0.23.4lsgit-aware, icons
bat 0.25catAlso the MANPAGER
fd 10.3findBacks FZF_DEFAULT_COMMAND
ripgrep 15.1grep -rSee the gotcha below
btop duf ncdu direnvtop df du
tig 2.6 · lazygit 0.57 · delta 0.18.2git log --graphAdded 2026-09-22 — see Git TUIs

These all cost time to find and are the reason the config looks the way it does.

Before this setup, command -v rg inside Claude Code printed rg with no path. It was a shell function injected by Claude Code, which bundles its own ripgrep — there was no /usr/bin/rg at all, so any plain SSH session had no rg. ripgrep is now installed from apt.

The packages install /usr/bin/batcat and /usr/bin/fdfind (name collisions with bacula-console-qt and fdclone). Aliases are not enough — they do not apply in scripts or when another program shells out — so ~/.local/bin/bat and ~/.local/bin/fd are symlinks.

~/.npmrc sets prefix=/home/deployd/.npm-global, so every npm i -g lands there, but nothing ever put it on PATH. This is the root cause of the mintlify / take devdocs gotcha already recorded in CLAUDE.md: the binary is installed, correctly reported as installed, and simply not findable. common.sh now adds it.

The Claude Code statusline depends on this too — but settings.json uses the absolute path /home/deployd/.npm-global/bin/ccstatusline anyway, because Claude Code does not necessarily invoke it through a login shell.

Sourcing nvm.sh costs 200–500ms per shell. On Claude Code’s Bash tool, which starts a shell per call, that is a tax on every command.

Instead, common.sh resolves nvm’s default version by reading ~/.nvm/alias/default (the bare string 22) and globbing versions/node/v22*/bin onto PATH directly — no subshell. node, npm and npx are real binaries and instant; only nvm itself is a stub that loads nvm.sh on first call.

If several 22.x are installed the glob picks the last match, which is lexical rather than numeric. nvm use overrides it regardless.

git_status.ignore_submodules is load-bearing

Section titled “git_status.ignore_submodules is load-bearing”

MasterControl is a superproject with 11 submodules. An unbounded git status there takes 70ms; with --ignore-submodules it is ~0ms. Without the setting the prompt visibly stutters between commands in the one repo you use most. command_timeout = 1000 is a second backstop.

Starship’s $os module measured 25ms per prompt — the single largest cost in the whole thing — to detect an operating system that never changes. The Ubuntu glyph is hardcoded into the format string instead. If this config is copied to another box, edit the glyph or re-enable [os].

"color": "#d65d0e" is silently ignored. The accepted formats are named colours, hex:RRGGBB (no #), and ansi256:N. Powerline separators must also be written as real codepoints — U+E0B0/U+E0B6/U+E0B4 — and customSymbol is not honoured by the git-branch, context-percentage or session-clock widgets.

Generated completions are cached, not eval’d

Section titled “Generated completions are cached, not eval’d”

gh completion -s zsh costs ~60ms — a third of interactive startup — and changes only when gh is upgraded. It is written to ~/.cache/zsh/completions/_gh and regenerated weekly; compinit uses a cached dump rebuilt at most daily. Interactive startup is ~120ms.

Added 2026-09-22. Two tools, deliberately: tig is the read-only topology viewer, lazygit is the interactive client. Both are apt packages from Ubuntu 26.04, both are configured, and both share the gruvbox palette used by starship, bat and zellij.

Terminal window
gtree # tig --all — every local branch + every remote-tracking ref, one graph
gtree -f # fetch --all --prune first, so the tree matches GitHub
lg # lazygit
tigs # tig status — the staging view

gtree is a function in ~/.config/shell/common.sh, not an alias, so it works in scripts and takes arguments: anything after -f is passed through to tig.

tiglazygit
RoleBrowse topology, blame, diffsStage, commit, rebase, resolve conflicts
Writes to the repoNoYes
Graph widthTruncates to the real view widthRaw git log output; wraps if the panel is narrow
Startup on these repos~10ms~150ms

They answer different questions, and tig is the one that answers “what does the branch tree look like”, because it is the only one that knows how wide its own view is.

~/.config/tig/config:

  • graph=v2 — the accurate graph renderer. Switch to v1 if tig is ever pointed at the OpenSearch forks; it is faster and less accurate, which is the right trade only at that size.
  • main-options = --topo-order — groups each branch’s commits together. Without it every branch is interleaved by timestamp and a multi-branch graph is unreadable.
  • 14 distinct palette-N colors. tig’s default palette reuses 7 ANSI colors twice, relying on the bold attribute to tell the halves apart — which many terminals render identically, so two different branches get the same lane color.
  • F refetches everything (git fetch --all --prune) instead of tig’s default single-remote fetch, and R opens the refs view. The tree is only as current as the last fetch.
  • reference-format distinguishes <remote>, [tag], {local-tag} and plain branches.

~/.config/lazygit/config.yml:

  • gui.statusPanelView: allBranchesLog — pressing 1 puts the whole branch tree in the main panel. a cycles to the verbose --pretty=medium version.
  • git.log.showWholeGraph: true — without it the commits panel draws only HEAD’s ancestry, i.e. a straight line.
  • git.mainBranches: [main, master, dev, develop] — every Logrite service repo is a main/dev pair and JobSearch is master/develop, so all four are base branches for divergence arrows.
  • <c-f> fetches all remotes and prunes; <c-o> prints the repo’s GitHub URL (gh browse -n — this VM is headless, so there is no browser to open).

serie renders the nicest commit graph of anything available — it draws the graph as an image through the terminal’s image protocol. It cannot be used here:

  • It needs the iTerm2 or kitty graphics protocol. Sixel is not supported.
  • zellij is explicitly unsupported (tmux only, and only via kitty Unicode placeholders) — and the persistent main session is how work on this VM survives a disconnect.

Revisit only if the team’s terminals move to kitty and zellij grows graphics support.

Terminal window
c # claude --dangerously-skip-permissions
C # identical
cr # claude --resume
c -c # continue the most recent session in this directory

~/.claude/settings.json already had skipDangerousModePermissionPrompt: true, so c launches with no confirmation screen. Note that --dangerously-skip-permissions refuses to run as root, so the alias does not work under sudo -i.

The statusline is ccstatusline (pinned global install), showing model, cwd, git branch and changes, context percentage and session clock, in Gruvbox powerline segments matching the prompt.

An SSH login gives you a plain shell. You enter the persistent session main by typing zellij with no arguments, and only then.

Terminal window
ssh dev # plain zsh, no multiplexer
zellij # → zellij attach --create main

The behaviour comes from a wrapper function in ~/.config/shell/common.sh, so bash and zsh are identical. Bare zellij is the only invocation it changes — everything else reaches the real binary through command zellij:

InvocationWhat runs
zellijzellij attach --create main
zellij a / zellij attach (no name)an fzf picker over every session, running and exited
zellij inside a sessionthe real binary (prints help) — no nesting
zellij ls, zellij attach foo, zellij kill-session xpassed through verbatim
zellij from a script or ssh dev '<cmd>'the real binary — the wrapper is defined only for interactive shells

Its guards, each for a specific failure:

GuardPrevents
$# -eq 0 (and $# -eq 1 for a/attach)Swallowing a real CLI call — anything else passes through
-z "$ZELLIJ" / -z "$ZELLIJ_SESSION_NAME"A session nesting inside itself
case $- in *i*) around the definitionShadowing the binary for scripts and ssh dev '<cmd>'
-t 1No TTY to attach to

No $CLAUDECODE guard is needed any more: nothing fires unless a human types zellij.

Sessions survive — what persists and what cannot

Section titled “Sessions survive — what persists and what cannot”

Set up 2026-08-12, after sessions were repeatedly lost to laptops sleeping, dropping their link and shutting down.

A dropped client never costs anything. The session is a server process on the dev VM; your terminal is only a viewer. Sleep the laptop, lose Wi-Fi, pull the power — reconnect and zellij puts you back with every process still running, mid-build, mid-agent, mid-anything.

The reboot case: layout, cwd and scrollback come back

Section titled “The reboot case: layout, cwd and scrollback come back”

When the server itself goes away — VM reboot, zellij kill-session — zellij resurrects from what it serialized to ~/.cache/zellij/contract_version_1/session_info/<name>/, written about once a minute. zellij ls lists a dead session as (EXITED - attach to resurrect) and attaching rebuilds it.

RestoredNot restored
Tabs, tab names, pane splits and sizesThe running processes themselves
Each pane’s working directoryAnything held only in a process’s memory
The command line each pane was runningUnsaved editor buffers
Pane scrollback (serialize_pane_viewport)

A resurrected pane comes back with its command staged and suspended — press Enter to run it. That is deliberate: zellij attach -f <name> (--force-run-commands) runs everything immediately, which is not something you want happening unattended to a pane that held a build or a deploy. For a Claude Code pane, claude --resume picks the conversation back up.

Three options in ~/.config/zellij/config.kdl carry this. They are read when a server starts, so a session already running keeps the settings it was born with:

session_serialization true // default; stated so a config reset can't drop it
serialize_pane_viewport true // OFF by default — this is why panes used to come back blank
scrollback_lines_to_serialize 10000

A user-level systemd unit resurrects it at boot — which only works because linger keeps the user manager running with nobody logged in:

Terminal window
systemctl --user status zellij@main # enabled; template, so zellij@<name> works for any session
systemctl --user enable --now zellij@work # add another

It runs zellij attach --create-background <name>, guarded by a zellij ls check because that command exits 101 when the session is already up. KillMode=process means stopping or restarting the unit never kills the session — use zellij kill-session <name> when you actually want it gone.

Terminal window
snap refresh --hold zellij # applied 2026-08-12

Auto-refresh runs four times a day. Swapping the binary under a live server is a way to lose sessions that has nothing to do with your laptop, and a version bump can move the cache directory that holds the serialized layouts. Update deliberately instead, when no session matters:

Terminal window
zellij ls # check what you would lose
sudo snap refresh --unhold zellij && sudo snap refresh zellij

The wrapper deliberately does not exec, so detaching (Ctrl-o d) returns you to a shell rather than ending the SSH session.

Heavy builds — one OOM kill can stop every session

Section titled “Heavy builds — one OOM kill can stop every session”

The mitigation. Run every build, test and compile through heavy.sh. It gives each command a scope of its own inside a memory-capped slice, so an OOM kills that one command and never the session.

PieceWhat it does
~/.config/systemd/user/heavy.sliceMemoryMax=12G (MemoryHigh=11G, MemorySwapMax=0) for all heavy commands together, so builds cannot push the VM (31 GiB, no swap, about 14 GiB baseline in containers) into a global OOM
heavy.sh <cmd> [args…]Waits for a slot, then runs the command as its own transient scope in heavy.slice: systemd-run --user --scope --slice=heavy.slice with MemoryMax=10G, MemoryHigh=9G and nice -n 10. HEAVY_MEM_MAX / HEAVY_MEM_HIGH override the caps. The slot count is read from heavy.slots (1 or 2; anything else counts as 1). Exit 137 means a memory cap killed the command
health.shThe governor. It samples every 15 s, writes heavy.slots, appends each mode change to health-events.log and writes the current reading to health.state
ModeWhenEffect
CRITICALMemAvailable under 3 GiB, or an OOM kill outside heavy.sliceslots = 1; the orchestrator stops the newest agent and re-queues it at the head
SINGLEMemAvailable under 6 GiB, or load5 over 16, or an OOM kill inside heavy.sliceslots = 1; no new agent until one is running
PARALLELMemAvailable at least 10 GiB and load5 at most 12, for 10 minutes unbrokenslots = 2; up to 2 agents

Between the thresholds the current mode is kept, so it cannot flap.

Terminal window
P=/home/deployd/worktrees/progress
$P/heavy.sh mvn -o -B test -Dtest=FooTest # one command
$P/heavy.sh bash -c 'cd logwrite-api && mvn -o -B clean test' # a compound command

heavy.sh: 1 heavy command(s) running, 1 allowed right now; waiting for a slot... is normal.

Diagnosing “every session died at once”

Section titled “Diagnosing “every session died at once””
Terminal window
journalctl -k | grep 'Killed process' # what the kernel killed
journalctl --user | grep "Failed with result 'oom-kill'" # which unit systemd failed for it
systemctl --user list-units --failed 'snap.zellij*' # a failed zellij scope: the session went with it
systemctl --user show -p OOMPolicy <that-scope> # OOMPolicy=stop

The kernel log tells a contained kill from a global one:

  • Memory cgroup out of memory: Killed process … on a run-….scope unit means a cap did its job. That is a heavy.sh command, and only that command died.
  • Out of memory: Killed process … is a global OOM: the whole VM ran out, and a process in any unit can be killed, including one in zellij’s scope.

The prompt, eza icons and the ccstatusline powerline segments all need a Nerd Font in the terminal you SSH from. Glyphs render client-side; this VM has no fonts and installing them here would change nothing.

Install JetBrainsMono Nerd Font locally and select it in your terminal. If you would rather not:

Terminal window
starship preset no-nerd-font -o ~/.config/starship.toml

Every original file was backed up in place before modification.

Terminal window
sudo chsh -s /bin/bash deployd # back to bash; .bashrc is fully functional
cp ~/.bashrc.pre-shell-setup.bak ~/.bashrc
cp ~/.claude/settings.json.pre-shell-setup.bak ~/.claude/settings.json
cp ~/.config/zellij/config.kdl.pre-shell-setup.bak ~/.config/zellij/config.kdl

There is no zellij auto-attach to disable (see above). To drop the zellij wrapper, delete its block from ~/.config/shell/common.sh, or bypass it once with command zellij. To escape a broken login shell without changing anything: ssh dev -t bash --noprofile.