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).
The layout
Section titled “The layout”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 untouchedInstalled
Section titled “Installed”Everything except Starship and ccstatusline came from Ubuntu 26.04’s own repos — no cargo, no brew, no PPAs.
| Tool | Replaces | Notes |
|---|---|---|
zsh 5.9 + autosuggestions + syntax-highlighting | bash | Distro packages, no Oh My Zsh |
starship 1.26 | PS1 | ~/.local/bin, installed without sudo |
fzf 0.67 | Ctrl-R | Ctrl-R history, Ctrl-T files, Alt-C dirs |
zoxide 0.9.8 | cd | z Logrite-UI from anywhere; zi for an fzf picker |
eza 0.23.4 | ls | git-aware, icons |
bat 0.25 | cat | Also the MANPAGER |
fd 10.3 | find | Backs FZF_DEFAULT_COMMAND |
ripgrep 15.1 | grep -r | See the gotcha below |
btop duf ncdu direnv | top df du | |
tig 2.6 · lazygit 0.57 · delta 0.18.2 | git log --graph | Added 2026-09-22 — see Git TUIs |
Gotchas worth knowing
Section titled “Gotchas worth knowing”These all cost time to find and are the reason the config looks the way it does.
rg existed but was not installed
Section titled “rg existed but was not installed”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.
Ubuntu renames bat and fd
Section titled “Ubuntu renames bat and fd”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.
~/.npm-global/bin was missing from PATH
Section titled “~/.npm-global/bin was missing from PATH”~/.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.
nvm is lazy-loaded, deliberately
Section titled “nvm is lazy-loaded, deliberately”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.
The os module was removed from the prompt
Section titled “The os module was removed from the prompt”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].
ccstatusline colours need a hex: prefix
Section titled “ccstatusline colours need a hex: prefix”"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.
Git TUIs — the branch tree
Section titled “Git TUIs — the branch tree”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.
gtree # tig --all — every local branch + every remote-tracking ref, one graphgtree -f # fetch --all --prune first, so the tree matches GitHublg # lazygittigs # tig status — the staging viewgtree 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.
Why two tools
Section titled “Why two tools”tig | lazygit | |
|---|---|---|
| Role | Browse topology, blame, diffs | Stage, commit, rebase, resolve conflicts |
| Writes to the repo | No | Yes |
| Graph width | Truncates to the real view width | Raw 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.
What is configured
Section titled “What is configured”~/.config/tig/config:
graph=v2— the accurate graph renderer. Switch tov1if 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-Ncolors. 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. Frefetches everything (git fetch --all --prune) instead of tig’s default single-remote fetch, andRopens the refs view. The tree is only as current as the last fetch.reference-formatdistinguishes<remote>,[tag],{local-tag}and plain branches.
~/.config/lazygit/config.yml:
gui.statusPanelView: allBranchesLog— pressing1puts the whole branch tree in the main panel.acycles to the verbose--pretty=mediumversion.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 amain/devpair and JobSearch ismaster/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).
Considered and rejected: serie
Section titled “Considered and rejected: serie”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
mainsession is how work on this VM survives a disconnect.
Revisit only if the team’s terminals move to kitty and zellij grows graphics support.
Claude Code
Section titled “Claude Code”c # claude --dangerously-skip-permissionsC # identicalcr # claude --resumec -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.
Zellij — opt-in, never automatic
Section titled “Zellij — opt-in, never automatic”An SSH login gives you a plain shell. You enter the persistent session main by typing
zellij with no arguments, and only then.
ssh dev # plain zsh, no multiplexerzellij # → zellij attach --create mainThe 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:
| Invocation | What runs |
|---|---|
zellij | zellij attach --create main |
zellij a / zellij attach (no name) | an fzf picker over every session, running and exited |
zellij inside a session | the real binary (prints help) — no nesting |
zellij ls, zellij attach foo, zellij kill-session x | passed 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:
| Guard | Prevents |
|---|---|
$# -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 definition | Shadowing the binary for scripts and ssh dev '<cmd>' |
-t 1 | No 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.
The disconnect case: nothing is lost
Section titled “The disconnect case: nothing is lost”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.
| Restored | Not restored |
|---|---|
| Tabs, tab names, pane splits and sizes | The running processes themselves |
| Each pane’s working directory | Anything held only in a process’s memory |
| The command line each pane was running | Unsaved 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 itserialize_pane_viewport true // OFF by default — this is why panes used to come back blankscrollback_lines_to_serialize 10000After a reboot, main is already waiting
Section titled “After a reboot, main is already waiting”A user-level systemd unit resurrects it at boot — which only works because linger keeps the user manager running with nobody logged in:
systemctl --user status zellij@main # enabled; template, so zellij@<name> works for any sessionsystemctl --user enable --now zellij@work # add anotherIt 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.
The zellij snap is held
Section titled “The zellij snap is held”snap refresh --hold zellij # applied 2026-08-12Auto-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:
zellij ls # check what you would losesudo snap refresh --unhold zellij && sudo snap refresh zellijThe 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.
| Piece | What it does |
|---|---|
~/.config/systemd/user/heavy.slice | MemoryMax=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.sh | The 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 |
| Mode | When | Effect |
|---|---|---|
| CRITICAL | MemAvailable under 3 GiB, or an OOM kill outside heavy.slice | slots = 1; the orchestrator stops the newest agent and re-queues it at the head |
| SINGLE | MemAvailable under 6 GiB, or load5 over 16, or an OOM kill inside heavy.slice | slots = 1; no new agent until one is running |
| PARALLEL | MemAvailable at least 10 GiB and load5 at most 12, for 10 minutes unbroken | slots = 2; up to 2 agents |
Between the thresholds the current mode is kept, so it cannot flap.
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 commandheavy.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””journalctl -k | grep 'Killed process' # what the kernel killedjournalctl --user | grep "Failed with result 'oom-kill'" # which unit systemd failed for itsystemctl --user list-units --failed 'snap.zellij*' # a failed zellij scope: the session went with itsystemctl --user show -p OOMPolicy <that-scope> # OOMPolicy=stopThe kernel log tells a contained kill from a global one:
Memory cgroup out of memory: Killed process …on arun-….scopeunit means a cap did its job. That is aheavy.shcommand, 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.
Nerd Font
Section titled “Nerd Font”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:
starship preset no-nerd-font -o ~/.config/starship.tomlRollback
Section titled “Rollback”Every original file was backed up in place before modification.
sudo chsh -s /bin/bash deployd # back to bash; .bashrc is fully functionalcp ~/.bashrc.pre-shell-setup.bak ~/.bashrccp ~/.claude/settings.json.pre-shell-setup.bak ~/.claude/settings.jsoncp ~/.config/zellij/config.kdl.pre-shell-setup.bak ~/.config/zellij/config.kdlThere 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.