Skip to content

Commit f6f4cdf

Browse files
binarypieclaude
andauthored
Sessions (#217)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 9f29a40 commit f6f4cdf

12 files changed

Lines changed: 409 additions & 88 deletions

File tree

dot_files/devcube/Containerfile

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -258,27 +258,33 @@ RUN mkdir -p /etc/profile.d /etc/fish/conf.d \
258258
# =============================================================================
259259
# LAYER 14: Bake the Neovim configuration + pre-install plugins
260260
# =============================================================================
261-
# The full LazyVim config is baked into $HOME/.config/nvim. Personal overrides
262-
# are bind-mounted at runtime from the host (~/.config/hypercube/nvim) and
263-
# layered on top via the runtimepath (see lua/config/lazy.lua).
261+
# The full LazyVim config is baked into a PRISTINE, non-volume path under
262+
# /usr/share/hypercube/config. The entrypoint syncs it into $HOME/.config/nvim
263+
# on every start, so config updates ship with the image instead of going stale
264+
# on the persisted (copy-up-seeded) home volume. Personal overrides are
265+
# bind-mounted at runtime from the host (~/.config/hypercube/nvim) and layered
266+
# on top via the runtimepath (see lua/config/lazy.lua).
264267
# NOTE: the build context is dot_files/, so the nvim config lives under nvim/.
265-
COPY nvim/config/ /root/.config/nvim/
268+
COPY nvim/config/ /usr/share/hypercube/config/nvim/
266269

267270
# Pre-install all plugins + build treesitter parsers so first launch is fast and
268-
# offline. HOME=/root and PATH already include the brew nvim binary.
269-
RUN nvim --headless "+Lazy! sync" +qa
271+
# offline. Point nvim at the pristine config via XDG_CONFIG_HOME; plugins still
272+
# land in stdpath('data') = /root/.local/share/nvim, which IS on the home volume
273+
# (seeded via copy-up on first run, matching the baked lazy-lock.json).
274+
RUN XDG_CONFIG_HOME=/usr/share/hypercube/config nvim --headless "+Lazy! sync" +qa
270275

271276
# =============================================================================
272277
# LAYER 14b: Bake the shell / multiplexer / orchestrator configs
273278
# =============================================================================
274279
# fish + starship give a good in-container shell/prompt; zellij + workmux drive
275-
# the parallel-agent workflow. These seed into the persisted home volume on
276-
# first run (podman copy-up), like the nvim config above. starship.toml lands at
277-
# the exact path dot_files/fish/config.fish hardcodes for STARSHIP_CONFIG, so
278-
# fish needs no edits (and it lives outside /root, so it's always present).
279-
COPY fish/ /root/.config/fish/
280-
COPY zellij/ /root/.config/zellij/
281-
COPY workmux/ /root/.config/workmux/
280+
# the parallel-agent workflow. Like the nvim config above, these are baked into
281+
# the pristine /usr/share/hypercube/config path and synced into $HOME/.config by
282+
# the entrypoint on every start (so config updates ship with the image rather
283+
# than going stale on the home volume). starship.toml already lives here at the
284+
# exact path dot_files/fish/config.fish hardcodes for STARSHIP_CONFIG.
285+
COPY fish/ /usr/share/hypercube/config/fish/
286+
COPY zellij/ /usr/share/hypercube/config/zellij/
287+
COPY workmux/ /usr/share/hypercube/config/workmux/
282288
COPY starship/ /usr/share/hypercube/config/starship/
283289

284290
# Make fish root's login shell so every interactive shell in the container

dot_files/devcube/Justfile

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
local_image := "localhost/devcube"
77
remote_image := "ghcr.io/binarypie-dev/devcube:latest"
88
# Throwaway volumes so local testing never touches the real per-project state.
9+
# Passing DEVCUBE_VOLUME explicitly forces this single fixed home volume instead
10+
# of the wrapper's per-project derivation, keeping local testing predictable.
911
test_volume := "hypercube-devcube-test"
1012
test_wt_prefix := "devcube-wt-test"
1113

@@ -80,6 +82,11 @@ test-build: build
8082
# The entrypoint must pin $SHELL to fish so workmux drops new worktrees into
8183
# fish (its default-shell fallback is /bin/sh otherwise).
8284
podman run --rm {{local_image}} sh -c '[ "$SHELL" = "$(command -v fish)" ]'
85+
# Baked config lives at the pristine path, NOT under /root (the home volume),
86+
# so config updates ship with the image instead of going stale on the volume.
87+
podman run --rm --entrypoint sh {{local_image}} -c 'for c in nvim fish zellij workmux; do test -d /usr/share/hypercube/config/$c || exit 1; done && ! test -e /root/.config/nvim'
88+
# ...and the entrypoint syncs it into /root/.config on every start.
89+
podman run --rm {{local_image}} sh -c 'test -d /root/.config/nvim && test -f /root/.config/fish/config.fish && test -d /root/.config/zellij && test -d /root/.config/workmux'
8390
@echo ""
8491
@echo "All tests passed!"
8592

dot_files/devcube/README.md

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,10 @@ Linux desktop, a remote box, and macOS — no distrobox required.
4242
| `devc codex` | OpenAI Codex CLI, run directly |
4343
| `devc agy` | Antigravity CLI, run directly |
4444

45-
All entry points share one home volume, so an AI login from any of them works in
46-
all of them.
45+
Within a project, all entry points share that project's home volume, so an AI
46+
login from any of them works in all of them. The home volume is **per-project**
47+
(derived from the launch path), so logins and state never leak between
48+
workspaces — you log in once per project.
4749

4850
### Parallel agents
4951

@@ -88,17 +90,20 @@ only what's needed. The mounts:
8890

8991
| Mounted in | Purpose |
9092
|---|---|
91-
| named volume `hypercube-devcube-home``/root` | plugin/mason updates, sessions, shada, **and AI-CLI auth** — seeded from the image on first run, persisted after |
93+
| per-project volume `hypercube-devcube-home-<proj>-<hash>``/root` | plugin/mason updates, sessions, shada, **and AI-CLI auth** — seeded from the image on first run, persisted after; one per project so creds never leak between workspaces |
9294
| current directory | the project you're editing |
9395
| per-project volume `devcube-wt-<proj>-<hash>``/worktrees` | worktrees + workmux state (orchestrators only) |
9496
| `~/.config/hypercube/nvim` | **your** plugin overrides, layered on top of the baked config |
9597
| `~/.gitconfig` (ro) | commit identity |
9698
| `$SSH_AUTH_SOCK` (Linux) | SSH agent for git/gh |
9799

98-
Everything else (the LazyVim config, plugins, AI CLIs, zellij/workmux/fish/
99-
starship config) lives in the image. The container runs as `--user 0:0` so files
100-
you edit are owned by your host user under rootless podman. Multiple sessions run
101-
concurrently.
100+
The AI CLIs and nvim plugins live in the image and seed onto the home volume on
101+
first run. The **baked config** (LazyVim, zellij, workmux, fish, starship) is
102+
image-owned: it's stored at a pristine path and synced into `/root/.config` by
103+
the entrypoint on **every** start, so config updates ship with `podman pull`
104+
no volume reset needed (plugin *version* bumps still want `:Lazy update`). The
105+
container runs as `--user 0:0` so files you edit are owned by your host user
106+
under rootless podman. Multiple sessions run concurrently.
102107

103108
Clipboard uses **OSC 52** through the terminal, so yank/paste works locally
104109
(Ghostty) and over SSH without forwarding a Wayland/pbcopy socket.
@@ -114,8 +119,8 @@ devc nvim file.rs # ...or just the editor
114119
| Command | Description |
115120
|---------|-------------|
116121
| `ujust devcube-setup` | pull image + install the `devc` wrapper |
117-
| `ujust devcube-upgrade` | pull the latest image + refresh the wrapper |
118-
| `ujust devcube-reset` | drop the home volume to re-seed (clears plugin state + AI logins); optionally prune per-project worktree volumes |
122+
| `ujust devcube-upgrade` | pull the latest image + refresh the wrapper (baked config refreshes itself on next launch) |
123+
| `ujust devcube-reset` | drop all per-project home volumes to re-seed (clears plugin state + AI logins); optionally prune per-project worktree volumes |
119124
| `ujust devcube-shell` | debug shell inside a throwaway container |
120125

121126
## Setup (macOS / any podman host)

dot_files/devcube/devcube-session.sh

Lines changed: 47 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,19 +15,57 @@
1515
# interpreted as "add these tabs to the (not-yet-existing) session" and zellij
1616
# errors with "There is no active session!". The flag that always starts a fresh
1717
# named session with a layout is --new-session-with-layout, used below.
18+
#
19+
# Recovering from an unsaved exit. The home volume (/root) outlives any single
20+
# `--rm` container, but the live zellij socket lives in the container's
21+
# ephemeral /tmp. If a devcube is torn down WITHOUT going through Ctrl+Space q
22+
# (the terminal/container is just killed), the socket dies with the container
23+
# while a stale scrap of session state can linger on the persisted volume. zellij
24+
# then refuses to reopen that name -- "session ... already exists, but is dead" --
25+
# and the next `devc` wedges instead of recovering. So when no usable session of
26+
# this name is left to attach to, we clear any dead remnant before creating a
27+
# fresh one (see below). Cleanly saved sessions still resurrect; only the wedged
28+
# leftovers get cleared.
1829
set -euo pipefail
1930

2031
session="$1"
2132
layout="${2:-}"
2233

23-
# A session of this name already exists (running or serialized/resurrectable)?
24-
# `list-sessions` prints the name as the first field for both; awk matches it
25-
# exactly. With no sessions it errors to stderr (suppressed) and prints nothing.
26-
if zellij list-sessions --no-formatting 2>/dev/null |
27-
awk -v s="$session" '$1 == s { found = 1 } END { exit !found }'; then
28-
exec zellij attach "$session"
29-
elif [ -n "$layout" ]; then
30-
exec zellij --session "$session" --new-session-with-layout "$layout"
34+
# The zellij binary, overridable so the logic can be exercised by tests with a
35+
# stub on this seam (see scripts/devcube/test-devcube-session.sh). Defaults to
36+
# the real `zellij` on PATH in the container.
37+
zellij_bin="${ZELLIJ_BIN:-zellij}"
38+
39+
# Does a session of this name currently exist that we can attach to -- either
40+
# running, or serialized/resurrectable (left by Ctrl+Space q -> Save, or by the
41+
# periodic snapshot of a session we never cleanly closed)? `list-sessions` prints
42+
# the name as the first field for both kinds, tagging the resurrectable ones; awk
43+
# matches the name exactly. With no sessions it errors to stderr (suppressed) and
44+
# prints nothing, so awk finds no match.
45+
session_attachable() {
46+
"$zellij_bin" list-sessions --no-formatting 2>/dev/null |
47+
awk -v s="$session" '$1 == s { found = 1 } END { exit !found }'
48+
}
49+
50+
if session_attachable; then
51+
# Running -> reattach; serialized -> resurrect. `attach` does both, restoring
52+
# the saved tabs / panes / worktrees. --force-run-commands re-runs the
53+
# serialized command panes (claude, nvim, ...) immediately instead of parking
54+
# them behind zellij's "Press ENTER to run" banner, so a resumed devcube comes
55+
# back live. (No-op when merely reattaching to a still-running session.)
56+
exec "$zellij_bin" attach "$session" --force-run-commands
57+
fi
58+
59+
# Nothing attachable by this name. A session killed uncleanly (container torn
60+
# down without Ctrl+Space q) can still leave a *dead* remnant that list-sessions
61+
# won't surface yet `zellij --session NAME` trips over with "already exists, but
62+
# is dead" -- the wedged state that breaks the next `devc`. Clear any such remnant
63+
# first; delete-session is a harmless no-op (non-zero, swallowed) when there's
64+
# nothing to remove, so a truly first-run session is unaffected.
65+
"$zellij_bin" delete-session "$session" --force >/dev/null 2>&1 || true
66+
67+
if [ -n "$layout" ]; then
68+
exec "$zellij_bin" --session "$session" --new-session-with-layout "$layout"
3169
else
32-
exec zellij --session "$session"
70+
exec "$zellij_bin" --session "$session"
3371
fi

dot_files/devcube/entrypoint.sh

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,35 @@ if [ -z "${TERM:-}" ] || ! infocmp "$TERM" >/dev/null 2>&1; then
2626
export TERM=xterm-256color
2727
fi
2828

29+
# Refresh image-baked editor/shell/multiplexer config into the home volume on
30+
# every start, so config updates ship with `podman pull` instead of forcing a
31+
# volume wipe. Only these fully image-owned dirs are replaced; auth + state
32+
# (~/.local, ~/.claude, ~/.cache, plugin downloads + lazy-lock, shell history,
33+
# zellij session serialization) live elsewhere on the volume and are untouched.
34+
# rm -rf + cp -a (not rsync, which isn't installed) so files removed from the
35+
# image also drop from the dir. The personal nvim override (~/.config/hypercube/
36+
# nvim) is a different dir and is never touched.
37+
config_src="/usr/share/hypercube/config"
38+
for c in nvim fish zellij workmux; do
39+
src="$config_src/$c"
40+
dest="$HOME/.config/$c"
41+
[ -d "$src" ] || continue
42+
mkdir -p "$HOME/.config"
43+
# fish writes its universal variables (set -U, e.g. fish_user_paths/colors)
44+
# to $dest/fish_variables -- that's runtime STATE, not baked config, so carry
45+
# it across the refresh rather than wiping it with the config dir.
46+
saved=""
47+
if [ "$c" = fish ] && [ -f "$dest/fish_variables" ]; then
48+
saved="$(mktemp)" && cp -a "$dest/fish_variables" "$saved"
49+
fi
50+
rm -rf "$dest"
51+
cp -a "$src" "$dest"
52+
if [ -n "$saved" ]; then
53+
cp -a "$saved" "$dest/fish_variables"
54+
rm -f "$saved"
55+
fi
56+
done
57+
2958
if [ $# -eq 0 ]; then
3059
exec nvim
3160
else

dot_files/devcube/scripts/devcube.sh

Lines changed: 58 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -14,21 +14,29 @@
1414
# devc agy -> Antigravity CLI, run directly
1515
#
1616
# Extra args after the tool are passed through (e.g. `devc nvim file.rs`,
17-
# `devc claude --resume`). All entry points share the same home volume, so
18-
# AI-CLI logins persist across them -- log in once and every tool is authed.
17+
# `devc claude --resume`). Within one project all entry points share that
18+
# project's home volume, so an AI-CLI login from any of them works in all of
19+
# them -- but the home volume is PER-PROJECT (derived from the launch path like
20+
# the worktree volume below), so creds/state never leak between workspaces.
1921
#
2022
# zellij/workmux run the parallel-agent flow: each `workmux add <branch>`
2123
# creates a git worktree on a PER-PROJECT named volume mounted at /worktrees,
2224
# so worktrees (and workmux's state) persist across restarts and never collide
23-
# with another project's. The project mount is the git repo root (discovered from
24-
# the launch dir), or the launch dir itself when not in a repo; workmux requires
25-
# a repo, the other tools don't. Plus your personal overrides and a few host
26-
# facts (git identity, SSH agent, terminal). Portable across Linux and macOS.
25+
# with another project's. The project itself (the git repo root, discovered from
26+
# the launch dir, or the launch dir when not in a repo) is mounted at /workspace
27+
# -- a fixed, branch-independent path that's kept out of /worktrees so it can
28+
# never collide with a /worktrees/<branch> linked worktree. The direct tools
29+
# instead get the project at its host path. workmux requires a repo, the other
30+
# tools don't. Plus your personal overrides and a few host facts (git identity,
31+
# SSH agent, terminal). Portable across Linux and macOS.
2732
#
2833
# Env overrides:
29-
# DEVCUBE_IMAGE container image (default ghcr.io/binarypie-dev/devcube:latest)
30-
# DEVCUBE_VOLUME named volume holding state + AI auth (default hypercube-devcube-home)
31-
# DEVCUBE_WT_PREFIX prefix for the per-project worktree volume (default devcube-wt)
34+
# DEVCUBE_IMAGE container image (default ghcr.io/binarypie-dev/devcube:latest)
35+
# DEVCUBE_VOLUME exact home-volume name (default: a per-project name derived
36+
# from DEVCUBE_HOME_PREFIX + the launch path). Set this to
37+
# force a single fixed volume (the test harness does this).
38+
# DEVCUBE_HOME_PREFIX prefix for the per-project home volume (default hypercube-devcube-home)
39+
# DEVCUBE_WT_PREFIX prefix for the per-project worktree volume (default devcube-wt)
3240
set -euo pipefail
3341

3442
# The tool to run is the first argument; default to the workmux workspace.
@@ -45,7 +53,6 @@ nvim | claude | codex | agy | zellij | workmux)
4553
esac
4654

4755
IMAGE="${DEVCUBE_IMAGE:-ghcr.io/binarypie-dev/devcube:latest}"
48-
VOLUME="${DEVCUBE_VOLUME:-hypercube-devcube-home}"
4956
OVERRIDE_DIR="$HOME/.config/hypercube/nvim"
5057

5158
# Personal overrides are layered on top of the baked config. Ensure the dir
@@ -67,26 +74,47 @@ if [ "$TOOL" = workmux ] && [ -z "$git_root" ]; then
6774
exit 1
6875
fi
6976

77+
# Stable, repo-root-derived names so a project's state persists across restarts,
78+
# is shared no matter which subdir you launch from, and never collides with
79+
# another project's. cksum is available on both Linux and macOS (the wrapper
80+
# stays portable). Used for the per-project home volume here and the per-project
81+
# worktree volume + zellij session name in the orchestrator case below.
82+
slug="$(basename "$project_dir" | tr -c 'a-zA-Z0-9_.-' '-')"
83+
phash="$(printf '%s' "$project_dir" | cksum | cut -d' ' -f1)"
84+
85+
# Home volume: PER-PROJECT by default so AI auth + state stay isolated between
86+
# workspaces. DEVCUBE_VOLUME forces an exact name (the test harness relies on
87+
# this); otherwise the name is derived from DEVCUBE_HOME_PREFIX + project, just
88+
# like the worktree volume.
89+
HOME_PREFIX="${DEVCUBE_HOME_PREFIX:-hypercube-devcube-home}"
90+
VOLUME="${DEVCUBE_VOLUME:-${HOME_PREFIX}-${slug}-${phash}}"
91+
92+
# Where the project shows up inside the container. By default it's mounted at
93+
# its own host path (identity) so file paths line up on both sides. The
94+
# orchestrators override this below.
95+
mount_target="$project_dir"
96+
7097
# Default: run the tool with only the project mounted. The orchestrators
7198
# (zellij/workmux) additionally get a per-project worktree volume + the zellij
7299
# backend, and run inside a zellij session.
73100
container_cmd=("$TOOL")
74101
extra=()
75102
case "$TOOL" in
76103
zellij | workmux)
77-
# Stable, repo-root-derived names so a project's worktrees + workmux state
78-
# persist across restarts, are shared no matter which subdir you launch from,
79-
# and never collide with another project's. cksum is available on both Linux
80-
# and macOS (the wrapper stays portable).
81-
slug="$(basename "$project_dir" | tr -c 'a-zA-Z0-9_.-' '-')"
82-
phash="$(printf '%s' "$project_dir" | cksum | cut -d' ' -f1)"
104+
# Per-project worktree volume + zellij session name, from the same slug/phash
105+
# computed above so a project's worktrees + workmux state persist across
106+
# restarts and never collide with another project's.
83107
wt_volume="${DEVCUBE_WT_PREFIX:-devcube-wt}-${slug}-${phash}"
84108
# Stable, per-project zellij session name so closing the devcube (Ctrl+Space
85-
# q -> Save) and reopening it resumes the SAME session. The home volume
86-
# (/root) is shared across every project, so the name must be unique per
87-
# project -> include phash. zellij session names can't contain '.', so the
88-
# slug's dots are squashed to '-' (the volume name keeps them; it's separate).
109+
# q -> Save) and reopening it resumes the SAME session. zellij session names
110+
# can't contain '.', so the slug's dots are squashed to '-' (the volume names
111+
# keep them; they're separate).
89112
session="devcube-$(printf '%s' "$slug" | tr '.' '-')-${phash}"
113+
# Mount the project at a fixed /workspace -- a stable, branch-independent path
114+
# regardless of which branch the host checkout is on -- kept separate from the
115+
# /worktrees volume so it never collides with a workmux /worktrees/<branch>
116+
# linked worktree.
117+
mount_target="/workspace"
90118
extra=(
91119
# Worktrees live here (podman auto-creates the volume on first mount).
92120
-v "${wt_volume}:/worktrees:rw"
@@ -118,16 +146,19 @@ args=(
118146
-e TERM
119147
-e COLORTERM
120148
"${extra[@]}"
121-
# State + plugin updates + AI auth. Empty volume is seeded from the image's
122-
# /root on first run (podman copy-up); never use a fixed container --name so
123-
# multiple sessions can run concurrently.
149+
# Per-project home volume: AI auth + plugin/state updates + shell history.
150+
# Empty volume is seeded from the image's /root on first run (podman copy-up);
151+
# baked config (nvim/fish/zellij/workmux) is NOT here -- the entrypoint syncs
152+
# it from the image on every start so config updates ship without a wipe.
153+
# Never use a fixed container --name so multiple sessions run concurrently.
124154
-v "${VOLUME}:/root"
125-
# The project -- the git repo root, or the launch dir when not in a repo --
126-
# at the same path inside the container.
127-
-v "${project_dir}:${project_dir}:rw"
155+
# The project -- the git repo root, or the launch dir when not in a repo.
156+
# Mounted at its host path for the direct tools, or at /workspace for the
157+
# orchestrators (see mount_target above).
158+
-v "${project_dir}:${mount_target}:rw"
128159
# Personal plugin overrides (nested over the home volume).
129160
-v "${OVERRIDE_DIR}:/root/.config/hypercube/nvim:rw"
130-
-w "${project_dir}"
161+
-w "${mount_target}"
131162
)
132163

133164
# Read-only git identity (name/email/aliases) from the host.

0 commit comments

Comments
 (0)