My projects run on a Mac mini at home. The dev servers and the coding agents live there, and I reach them from whatever I have with me: a laptop, an iPad, a phone. The thing that makes it bearable is zellij, a terminal workspace that keeps sessions alive on the Mac while I come and go.
Three pieces make it work: how I connect, how zellij stays out of the way, and how a project comes up in one command.
Connecting: mosh, Tailscale, and a picker
Every device is on the same Tailscale network, so the Mac has one stable name wherever I am. I connect with mosh rather than plain SSH. It starts over SSH, then keeps the session alive through sleep, a dropped Wi-Fi or a move from Wi-Fi to 4G, and it echoes keystrokes locally, which matters on a phone.
Mosh can run a command instead of a shell. Mine is a small script, zpick, that lists the zellij sessions and lets me pick one:
mosh mac-mini -- zpick
Here’s all of it:
#!/bin/zsh
# Pick a zellij session to attach, or create one. Works as a mosh/ssh remote command.
# enter attach to the highlighted session (exited ones get resurrected)
# ctrl-n create a session named after what you typed (or $ZPICK_DEFAULT)
# "+ new" prompt for a name, defaulting to $ZPICK_DEFAULT
# esc no session
#
# --shell afterwards (esc, detach, exit), drop into a login shell instead of
# ending the connection — use this as the mosh/ssh remote command
# mosh-server/ssh commands run without .zprofile/.zshrc, so PATH is bare.
path=(/opt/homebrew/bin /opt/homebrew/sbin "$HOME/.local/bin" $path)
export LANG="${LANG:-en_US.UTF-8}"
then_shell=0
[[ "${1:-}" == --shell ]] && then_shell=1
finish() {
(( then_shell )) && exec "${SHELL:-/bin/zsh}" -l
exit "${1:-0}"
}
default="${ZPICK_DEFAULT:-dev}"
new_label="+ new session"
name="" query="" key="" choice=""
if [[ -n "${ZELLIJ:-}" ]]; then
echo "Already inside zellij (${ZELLIJ_SESSION_NAME:-?})"
exit 1
fi
sessions=("${(@f)$(zellij list-sessions 2>/dev/null)}")
sessions=(${sessions:#})
if (( ${#sessions[@]} == 0 )); then
choice="${new_label}"
elif command -v fzf >/dev/null 2>&1; then
out=("${(@f)$(printf '%s\n' "${new_label}" "${sessions[@]}" \
| fzf --ansi --no-sort --reverse --height=~50% \
--prompt='zellij > ' --print-query --expect=ctrl-n --bind=enter:accept-or-print-query \
--header='enter: attach · ctrl-n: new from typed name · esc: shell')}")
(( $? == 130 )) && finish
query="${out[1]}" key="${out[2]}" choice="${out[3]}"
if [[ "${key}" == ctrl-n ]] || [[ -z "${choice}" && -n "${query}" ]]; then
name="${query:-${default}}"
fi
else
select choice in "${new_label}" "${sessions[@]}"; do
[[ -n "${choice}" ]] && break
done
[[ -z "${choice}" ]] && finish
fi
if [[ -z "${name}" ]]; then
if [[ "${choice}" == "${new_label}" ]]; then
read -r "name?Session name [${default}]: " || finish
[[ -z "${name}" ]] && name="${default}"
else
# strip ANSI (fallback path) and the "[Created ...]" suffix
name="${${(S)choice//$'\e'\[[0-9;]#m/}%% *}"
fi
fi
zellij attach --create "${name}"
finish $?
A few lines in it carry most of the weight.
The PATH line. A command run by mosh-server or ssh gets a bare environment: no .zshrc, no Homebrew on the path. Without that line, zellij and fzf simply aren’t found, and the connection closes before you’ve read why.
--print-query --expect=ctrl-n. fzf prints what you typed, which key ended the search, and what you picked, one per line. That’s what lets a single prompt both attach to a session and create a new one named after whatever you typed.
zellij attach --create. Attaching to a session that has exited brings it back with its layout, so a reboot of the Mac doesn’t lose the shape of the work.
--shell. Without it, leaving zellij ends the connection, which is what I want on a phone. With it, detaching or pressing Esc drops into a normal login shell instead.
Locked by default
Zellij’s default keymap catches a lot of keys, and over a remote connection, with editors and agents running inside, every caught key is one the program in the pane doesn’t get. So my config starts from nothing and stays out of the way:
default_mode "locked"
show_startup_tips false
keybinds clear-defaults=true {
// In locked mode, zellij hears one key: the one that unlocks it.
locked {
bind "Ctrl g" { SwitchToMode "normal"; }
}
// Moving between panes and tabs works without unlocking.
shared_among "normal" "locked" {
bind "Alt left" { MoveFocusOrTab "left"; }
bind "Alt down" { MoveFocus "down"; }
bind "Alt up" { MoveFocus "up"; }
bind "Alt right" { MoveFocusOrTab "right"; }
}
// Everywhere else, Ctrl g locks again.
shared_except "locked" "renametab" "renamepane" {
bind "Ctrl g" { SwitchToMode "locked"; }
bind "Ctrl q" { Quit; }
}
}
Everything else (panes, tabs, resizing, scrolling, sessions) lives behind Ctrl g, and every action that changes something ends by locking again: NewPane "down"; SwitchToMode "locked";. Unlock, do one thing, and you’re back to a terminal that passes every key through.
A project in one command
Each project wants the same things every time: a directory, a few panes, a dev server, and its ports reachable from the iPad. A small set of shell functions saves that as a preset.
znew captures the session I’m in. Zellij can describe its own current layout, so the preset records exactly what’s on screen:
znew myapp 3000,3001,4321
# Created preset: myapp
# Layout source: captured current zellij layout
# Start with: zuse myapp
Under the hood it’s zellij action dump-layout written to a .kdl file, plus a small file with the project’s directory and ports. Started outside zellij, it writes a plain default instead: a wide pane and a narrow one running the dev server.
layout {
pane split_direction="vertical" {
pane size="70%" {
command "zsh"
}
pane size="30%" {
command "zsh"
args "-lc" "nr dev; exec zsh"
}
}
}
zuse myapp brings it all back. It serves the project’s ports over Tailscale, then attaches to the session if it exists, or opens a new one with the saved layout in the project’s directory:
zresetserve || return 1
zserve "${PORTS_CSV}" || return 1
if _z_has_session "${session}"; then
zellij attach "${session}"
return
fi
(
cd "${PROJECT_DIR}" || exit 1
zellij --session "${session}" --new-session-with-layout "${LAYOUT_FILE}"
)
zserve is the part that makes the iPad useful. tailscale serve publishes a local port to the Tailscale network over HTTPS, with a real certificate. The first port takes the default, 443; each next one gets its own HTTPS port, so three dev servers can run side by side and stay reachable:
_z_https_port_for_index() {
echo $((6443 + ($1 * 1000))) # second port → 8443, third → 9443
}
if (( idx == 1 )); then
tailscale serve --bg "${port}"
else
tailscale serve --bg --https="$(_z_https_port_for_index "${idx}")" "${port}"
fi
One detail in the saved layouts earns its place: panes can start suspended. The Médicis preset opens with the coding agent on the left and the dev server on the right, but neither starts until I press Enter in its pane:
// From the Médicis preset, simplified.
pane split_direction="vertical" {
pane command="opencode" name="Opencode" size="50%" {
start_suspended true
}
pane command="nr" name="Dev" size="50%" {
args "dev"
start_suspended true
}
}
So reopening a project is free. Nothing runs that I didn’t ask for, and everything is one keystroke away.
The whole loop
From any device: mosh mac-mini -- zpick, pick the project’s session or type its name, and I’m back where I left it, with its servers on their usual ports. When I close the lid, nothing stops. When I open the iPad, it’s still there.