Repository navigation
A terminal beside the agent: zellij in every container, generically (no dotfiles, any image) #242
Description
Graduated from map blooop/dotfiles#13's terminal-beside-the-agent item after a live investigation (2026-08-16). The earlier shape (dotfiles#14) put this in chezmoi; this ticket replaces that approach with a devlaunch-owned one that works in any image, including third-party devcontainers we do not control.
What this buys
A terminal opened from inside the agent session, rather than by attaching a second one from elsewhere. Proven live: with zellij present in the container, a completely non-interactive command opens a working pane.
Measured facts the design rests on
The capability works with no TTY anywhere. Through real devpod ssh --command (verified [ -t 0 ] → no) against mcr.microsoft.com/devcontainers/base:ubuntu:
zellij attach -b agentsess— creates a detached session from a non-TTY command, exit 0.- A separate non-TTY command:
zellij list-sessions→ the session is there. - A separate non-TTY command:
zellij -s agentsess action new-pane -- sh -c "touch /tmp/PANE_PROOF; sleep 30"→ printedterminal_1, exit 0, and/tmp/PANE_PROOFexisted withsleep 30running.
ZELLIJ_SESSION_NAME does not need setting — -s <name> is sufficient and is the robust form. Bare zellij action new-pane also works by falling back to the sole active session, which is ambiguous once there are two; do not depend on it.
The one wrap point. dl <spec> -- <cmd> funnels through a single payload = f"bash -lc {shlex.quote(command)}" in workspace_ssh. Wrapping there covers wayfinder and aid without either changing, and because it is a login shell the PATH prepend that tool provisioning writes is already in effect. A bare dl <ws> attach passes command=None down the interactive branch and needs a second small edit in the same function.
Where zellij comes from — two routes, both measured.
Container-side pixi install (recommended). tools.py already provisions tools into any container, dotfiles-independent by design, and its fallback already bootstraps pixi (_pixi_bootstrap). zellij is a conda-forge package, and package downloads now hit the shared cache from #232/#240, so a second container onward pays extraction only. This is a third row in an existing table rather than new machinery, and it adds no new upstream dependency.
Host-side static binary mount (recorded alternative). The upstream zellij-x86_64-unknown-linux-musl release is a genuine single static file (52MB, static-pie). Verified: it runs unchanged in glibc (ubuntu:24.04) and musl (alpine) images as non-root uid 1000; devpod accepts a file-level --mount and passes it through verbatim; the binary lands executable at /usr/local/bin/zellij, which is on PATH in every image tested. Faster (no per-container work) but it needs a host-side download of a GitHub release, which is a new upstream dependency for a cost measured in seconds.
Do not try to lend the host's pixi copy. ~/.pixi/bin/zellij is a pixi trampoline, and the real binary behind it is dynamically linked against libssl/libcrypto inside its own env prefix, so it does not run when copied out. _gh_source already resolves trampolines for gh; that machinery does not rescue zellij. Only the musl release is single-file portable.
Traps already paid for elsewhere
- A mount only lands at container creation. devpod never persists
--mountinto workspace config, and passing--mountto an existing container is a silent no-op — exit 0, no warning. Whatever route is chosen, existing workspaces get the capability only afterdl <workspace> recreate, and that must be said in the README. - Bind mounts are read-write by default, so a container could modify the host's binary. Add
,readonlyif the mount route is taken. - A nonexistent mount source fails
uploudly, then a retriedsshsilently starts a container without the mount — the same shape Shared pixi cache mount makes ~/.cache root-owned in any image that lacks it, and hard-fails on a uid mismatch #240 added anis_dir()preflight for. - Never target a path under
$HOME. Shared pixi cache mount makes ~/.cache root-owned in any image that lacks it, and hard-fails on a uid mismatch #240: Docker creates missing mount parents asroot:root, which cost the container user its whole~/.cache./usr/local/bin/zellijis safe because the file is the mount and/usr/local/binpre-exists.
Done when
- Every container devlaunch launches has a working
zellijon PATH, with no dependence on the user's dotfiles and no edit to any repo'sdevcontainer.json. Take the container-side pixi route unless measurement contradicts the falsification condition below; record which and why. - Provisioning zellij must not be able to fail a launch — the same "cost the feature, not the launch" rule the launch lock and the pixi cache already follow. A container that ends up without zellij still opens, and the wrap in (3) degrades to running the command directly.
- An opt-in wrap, default off: with it enabled, the command
dl <spec> -- <cmd>runs inside a zellij session (zellij attach -c <session> -- <cmd>or equivalent), sozellij action new-paneworks from within the agent. Default off means no existing invocation changes meaning. A baredl <ws>attach should behave consistently with whatever is decided for the command form; say what it does either way. - README documents the capability, how to enable it, the
-s <name>form for opening a pane from inside a session, and that existing workspaces needdl <workspace> recreate. - CHANGELOG under
## [Unreleased].
Falsification condition
If a warm container-side pixi global install zellij costs more than a few seconds, the cost stops being negligible and the static-binary mount becomes the better trade — every fact needed to switch is recorded above. Measure it and put the number in the PR; do not choose on taste.
Explicitly out of scope
Session persistence across dl <ws> recreate (zellij's serialization lives in the container and dies with it), and any host-side zellij integration — a container-side session is the only architecture that works, since an agent inside the container cannot reach a host socket.
This ticket was generated by AI during investigation follow-through; every measurement above was taken live on 2026-08-16.
Activity
blooop commentedon Aug 16, 2026
breadcrumb: Building this as: (a) zellij provisioned container-side by pixi, (b) an opt-in, default-off wrap on the one command payload.
The falsification measurement is in, and it does not fire. A warm
pixi global install zellijinsidedevcontainers/base:ubuntuagainst the shared package cache, measured over three fresh containers: 0.56s, 0.23s, 0.23s. Cold (empty cache) it is 3.0s, and the cache it fills is 167MB. That is comfortably inside "a few seconds", so the container-side pixi route is taken and the static-musl-binary mount stays a recorded alternative. No new upstream dependency, no host-side download.Seams I will test at, all three pre-named by the ticket:
Open question I am deciding rather than parking, and flagging for review: putting the agent inside a zellij pane would take its stdout and its exit status away from
dl, which every scripteddl <ws> -- cmddepends on. Since the ticket's own measurement showszellij -s <name> action new-paneworks from a command that is not in any session, the wrap ensures the named session exists and then runs the command beside it, stdio and exit status untouched. The capability the ticket is for is delivered exactly; the blast radius is nil. Details and what a bare attach does will be in the PR.blooop commentedon Aug 16, 2026
breadcrumb: Built and open as #243, all checks green.
The slice: zellij is provisioned container-side by pixi as a stage of the setup pass every
devpod upalready pays, so it adds no round trip and its failure is contained and named rather than able to fail a launch;DEVLAUNCH_ZELLIJ=1(default off) then makesdl <spec> -- <cmd>ensure the session exists before running the command.Route: container-side pixi. Warm install measured at 0.56s / 0.23s / 0.23s over three fresh containers, so the falsification condition does not fire and the static-musl mount stays the recorded alternative.
Three things a reviewer should look at, all argued in the PR body:
dl, and the capability does not need it ---s <name> action new-panealready works from a command in no session at all.dl <workspace> restart. The done-when said recreate, which was written for the mount route. The README documents the true, weaker requirement.REQUIRED_TOOLS, and there is a test whose whole job is to keep it out: that tuple drives the probe and the lend, so a row there would put every already-good container on the ~300MB lending path forever and still never install zellij.A bare
dl <ws>attach does nothing different, switched on or off -- it sends no command to wrap, which is exactly what earns it a pty from devpod. The session is reachable by hand from the shell it hands over.Also worth recording for whoever touches this next:
zellij attach -b <name>exits 1 when the session already exists, which is the common path, so the|| truein the payload is load-bearing rather than defensive.Fold the two-axis review's findings into the zellij stage (#242)
blooop commentedon Aug 16, 2026
Two-axis review (Standards + Spec) on PR #243: Approve — both axes independently, residues resolved on the branch at
c18ff8a.The falsification condition did not fire. An axis that did not write the code re-measured a warm
pixi global install zellijon its own fresh containers: 0.43s / 0.20s / 0.83s (cold 2.27s, filling 167MB), against the PR's 0.56/0.23/0.23 and 3.0s — same order of magnitude. So the container-side pixi route stands on measurement, and the static-musl-binary mount stays the recorded alternative.One correction to this ticket's suggested mechanism, made by the builder and upheld by review: zellij is not a third row in
REQUIRED_TOOLS. That tuple also drivesprobe_scriptand the lend path, so a row there would make every container that already hasghand a realclaudereport "tools missing", pay a ~300MB lend on every launch, and still never install zellij — a successful lend returns before the install trip. It ridessetup_stagesinstead, where failure containment already exists structurally.Also learned here:
zellij attach -b <name>exits 1 when the session already exists, which is the common path, so the|| truein the wrap is load-bearing rather than defensive.Merge pull request #243 from blooop/wayfinder/devlaunch-242