Skip to content

Latest commit

 

History

History
1969 lines (1590 loc) · 105 KB

File metadata and controls

1969 lines (1590 loc) · 105 KB

What every workspace gets, and how

dl launches arbitrary repos, so nothing here can depend on the image. This page is how each guarantee is actually delivered: the GitHub login, gh and claude, zellij, the terminal title, the shared pixi cache, and the dotfiles refresh.

GitHub authentication

Every workspace dl opens inherits the host's GitHub login, so gh is already authenticated inside the container and the devcontainer.json does not have to arrange anything for it. devpod forwards the ssh agent and git credentials on its own, but nothing else carries gh.

devlaunch takes the token from GH_TOKEN, GITHUB_TOKEN, or gh auth token, whichever answers first, and hands it to the container as GH_TOKEN. That reaches any image and any container user, unlike a bind-mount of ~/.config/gh, and it works whether the host keeps its token in hosts.yml or in a keyring. The token is passed to devpod through a private file and through devpod's own environment, never on a command line, so it does not appear in ps. dl installs gh itself (see Tools in every workspace), so the login has something to be spent on whatever the image ships. Check a workspace with:

dl <workspace> -- gh auth status

If no token can be found, dl warns on stderr and opens the workspace anyway rather than failing. The warning names the config directory gh consulted, because the usual cause is a shell that scoped XDG_CONFIG_HOME somewhere gh has no login, not a host that is actually logged out.

Who gets the token

Everything running in the container does, including a postCreateCommand from a repo you did not write. dl someone/repo builds and runs that project's devcontainer with your GitHub token in its environment, and a gh auth login token usually carries repo, workflow, gist and read:org scopes. devpod already forwards the ssh agent to every workspace, so this is not a new trust boundary, but it is a wider one. Skip it for a repo you have not read:

DEVLAUNCH_NO_GH_TOKEN=1 dl someone/repo
Variable Description
DEVLAUNCH_NO_GH_TOKEN=1 Do not forward the host's GitHub login into workspaces

When the token changes

dl refreshes the token on every start, so rotating it on the host is enough for any workspace that gets started or restarted afterwards. Attaching to a workspace that is already running skips that step, and the token it was given at startup stays in place, including one it was given before you set DEVLAUNCH_NO_GH_TOKEN. Run dl <workspace> restart to replace it.

The ssh-agent, and what dl does when there is none

Forwarding an agent into the container is devpod's job, not dl's: a devcontainer that wants one says so in its devcontainer.json, commonly by binding ${localEnv:SSH_AUTH_SOCK} at a path its containerEnv points SSH_AUTH_SOCK at. This repository's own devcontainer deliberately does not: it binds a path init-host.sh maintains, because binding the variable is what refused the create on a host with no agent in the first place. dl reads the variable for one purpose of its own, which is the identity of the ssh control socket: a reused master forwards whichever agent opened it, so two runs with different agents must not find one another's master.

What dl does do is make sure the variable names something. A manifest binding ${localEnv:SSH_AUTH_SOCK} on a host with no agent exported gets an empty source string, and docker refuses the whole run with field Source must not be empty: no container at all, rather than a container without a key. That is a fresh machine before any dotfiles have run, and it is not something the manifest can guard, because devcontainer.json has no conditional mounts. So the environment dl hands devpod up is the only place it can be answered once for every repo, and that is where it is answered.

When SSH_AUTH_SOCK is unset, or names something that is not a socket, the up runs with it pointing at a placeholder socket under devlaunch's cache directory. It is a real unix socket, bound and immediately closed, so nothing is listening on it: ssh inside the container is refused by connect() the way it is by any agent that has gone away, rather than failing on the file type. The workspace opens, and it opens without a key. dl says so once on the up, and says what still works:

ssh-agent: none on this host (SSH_AUTH_SOCK is unset), so this workspace has no SSH
key to push with. GitHub still works over HTTPS with the forwarded gh token. Start
an agent and export SSH_AUTH_SOCK to change that.

A host that has an agent is left exactly as it is. The variable is inherited untouched, devpod forwards the real socket, and no placeholder is made. The placeholder is not a login and holds nothing: it lives in the cache, dl --purge takes it with the rest, and the next launch that needs one makes it again.

Which agent a running workspace has is fixed when its container is created, because that is when docker resolves a bind source. Starting an agent afterwards reaches a workspace only once it is recreated.

Shared agent skills

The repo's local container feature mounts ~/.agents/skills for Codex alongside Claude's discovery directory. It also protects ~/.claude/shared-skills, so a relative link can point into that directory without making the skill body writable from the container. Both storage layouts work across different host and container usernames.

This is a feature opt-in, not a mount injected by dl into every workspace. Repos with container-local configuration can provision the same directories through dotfiles. Existing containers using the feature need recreation to receive its new mounts. The feature preserves existing skills and links; it does not install Codex or share its credentials and session state. Dotfiles installers should skip host-mounted skill roots rather than reconcile their contents through a read-only bind.

Claude authentication

claude starts in every workspace dl opens without asking for a login. The host's access token is read from its Claude configuration directory, which is $CLAUDE_CONFIG_DIR when the host sets one and ~/.claude otherwise, and forwarded as CLAUDE_CODE_OAUTH_TOKEN. Only the variable's name reaches a command line, which is the discipline the GitHub token keeps too, by a different route: that one is staged in a private file because devpod up needs it, and this one rides --send-env on the session, so the value travels in the environment and no file is written at either end.

dl launches arbitrary repos, so this cannot depend on the image and no repo has to add anything to its devcontainer.json. It used to depend on exactly that. A repo whose own devcontainer bind-mounts ~/.claude had a working claude and a working status line; a repo with no .devcontainer/ at all had neither, and the reported symptom was a blank status bar in a workspace where claude-statusline was installed and the settings.json naming it had never been applied.

Which credential, on a host that has moved it

$CLAUDE_CONFIG_DIR is Claude Code's own name for where its configuration lives, and dl honours it rather than setting it, the way it honours DEVPOD_SSH_CONFIG. The full order a launch reads:

  1. DEVLAUNCH_NO_CLAUDE_TOKEN, which forwards nothing at all.
  2. Any CLAUDE_CODE_OAUTH_TOKEN the host has already exported. This is what lets a dl running inside a workspace pass the token it was given further down, so a workspace can launch a workspace.
  3. $CLAUDE_CONFIG_DIR/.credentials.json, or ~/.claude/.credentials.json when the variable is unset. One step, not two: see below.

The variable replaces the default rather than being tried ahead of it. Claude Code does not fall back from $CLAUDE_CONFIG_DIR to ~/.claude, and neither does this. A fallback would forward a credential out of a directory Claude Code is not reading, which is the same defect as ignoring the variable and harder to notice, because it only shows itself on a host with two logins. So a $CLAUDE_CONFIG_DIR that names a directory holding no credential is a host that is not logged in, and the launch forwards nothing rather than the other account. It does that silently, which is deliberate and is the same silence a missing ~/.claude gets: on macOS the credential lives in the login keychain and no file is the ordinary state, so a warning there would fire on every correctly configured Mac.

An empty value counts as unset, which is what a shell exporting a bare variable means and the rule the XDG directories already follow here. The value is read as bytes rather than as text, so a directory whose name is not valid UTF-8 is opened as named.

The exported token stays above the variable because both are ambient, and the nested-workspace case has to keep working when no variable is set.

This closes an asymmetry rather than adding a feature. The probe dl runs inside a container has always read $CLAUDE_CONFIG_DIR, because a devcontainer feature may set it and Claude Code honours it there too. The host side read ~/.claude regardless, so a host that had moved its configuration reported itself as not logged in while holding a perfectly good login.

Naming a profile

--claude-profile <name> forwards a named login instead of the default one, for the case the order above cannot serve: two accounts on one machine, and a workspace that wants the one your host is not signed in to.

dl owner/repo --claude-profile work

The name is one directory under ~/.claude-profiles/, holding the .credentials.json a claude login writes. Each such directory is a CLAUDE_CONFIG_DIR of its own, which is what makes the logins independent.

That is somebody else's directory and dl only reads it. The layout and the CLAUDE_PROFILES_DIR variable belong to the tool that manages them, honoured here rather than set, the same arrangement dl has with devpod's own DEVPOD_SSH_CONFIG. An earlier version of this feature invented a devlaunch-shaped root under the config directory, and that was wrong: it made a third location for one concept and would have asked anyone with working profiles to log every account in again somewhere new.

So there is no writer. Creating a profile, seeding the config it shares with your main login, and deleting it belong to whatever made the directory; dl reads one file out of it. By hand that is CLAUDE_CONFIG_DIR=~/.claude-profiles/work claude, then a login.

Nothing dl deletes can reach them. dl --purge removes devlaunch's cache entire and dl --prune walks the clones inside it, and a login was never in either path.

Two variables, in this order: DEVLAUNCH_CLAUDE_PROFILES_DIR is devlaunch's own and wins, which is what lets a scratch run read and complete its own profiles rather than the real credentials; CLAUDE_PROFILES_DIR is the managing tool's and is honoured next.

--claude-profile default resolves the login you would get anyway and never consults a default/ directory. It exists as a word because a picker needs something to select, and a recalled line needs a way to say "not the profile I used last time".

Seeing which account a profile actually holds

$ dl --claude-profiles
NAME     STATE          ACCOUNT
default  authed         someone@example.com · Someorg · team_tier_1
work     authed         someone@work.example · Workorg · team_tier_1
spare    authed         someone@example.com · Someorg · team_tier_1
fresh    not logged in  -

'default', 'spare' are the same account, so all but one are spare.

That last line is the other thing a name cannot tell you. Two profiles of one account render identically to two colleagues who share an organisation, so the redundant one is invisible exactly where you are choosing between them. It is decided on the account's own id and never on a display field, because a shared organisation is two people and a shared name is nothing at all. A profile whose state file names no account joins no group, so no claim is made about a blank.

A profile's name is chosen by you and verified by nothing, which is the reason this listing exists. A profile called work holding a personal login reads as correct right up until the work is pushed from the wrong identity, and that is the failure profiles are meant to prevent. The name is what you type; the account column is what you get.

The account is read from .claude.json, the state file Claude Code keeps inside each config directory, and only three of its fields: the email address, the organisation and the seat tier. Nothing else is read and nothing is written. A - is a profile with no credential and so nobody to name; unknown is one that is logged in whose state file is absent or has moved on from the shape this reads, which stops nothing.

No token is read to build this. The authed column is the credential file's existence, never its contents, so a listing has not touched a secret. --claude-profiles is also the honest way to find a profile created and never logged in to, since a launch naming one of those refuses.

A name beginning with a dot is neither offered nor accepted. A <root>/*/ glob does not match a dot-directory, so neither this listing nor the shell completion would show one, and a profile you can launch but never see is a trap rather than a feature.

A named profile that holds no credential stops the launch. It does not fall back to your default login, and that refusal is the feature rather than a rough edge. Two accounts on one machine is what profiles are for, so a typo that silently forwarded the other one would be worse than a launch that fails: the launch you see, and the wrong account you find out about later and somewhere else. The opt-out still comes first, because a machine that has opted out has no account to choose.

It is read above an exported CLAUDE_CODE_OAUTH_TOKEN, unlike $CLAUDE_CONFIG_DIR, and the difference is where the two come from. A profile was typed on this command line for this launch, so nothing ambient should beat it, and a nested dl naming a profile is overriding exactly the token it inherited.

Not stored with the workspace, unlike --devcontainer. A profile describes this session and not the container, so storing it would mean a workspace quietly forwarding an account chosen weeks ago. Pass it per launch, or export CLAUDE_CODE_OAUTH_TOKEN in a shell profile if that is the shape you want.

Two things it does not change. It is not the claude.ai account the container's own claude is paired to for Remote Control, which lives in the container with the rest of the agent's state, so aid --claude-profile work still lists its session under whichever account the container is signed in to. And it does not weaken the check below: a repo whose devcontainer owns its Claude config forwards nothing, profile or no profile.

A verb that forwards no login at all (stop, kill, rm, rme) says it is ignoring the flag rather than failing, the way --devcontainer does there. A global command such as --ls refuses it outright, because there is no workspace for it to be about.

A variable, not the credential file

Claude Code authenticates from CLAUDE_CODE_OAUTH_TOKEN alone, with an otherwise empty $HOME. Writing ~/.claude/.credentials.json into the container would work too, and it is the worse trade twice over. It leaves a secret on the container's disk. And that file carries the refresh token as well as the access token, so a container that refreshed it could rotate the host's own login away. A variable carries one short-lived access token and nothing else.

The cost is that the access token is short-lived, hours rather than days, and a variable cannot be refreshed in place. This is the same caveat the GitHub token has, for the same reason: see "When the token changes" above.

The wizard in front of it

A token is not the only thing standing between claude and a prompt. Claude Code asks, before it consults a credential of any kind, whether first-time setup has been done in this config directory, and what it asks is hasCompletedOnboarding in .claude.json. A container whose config directory nothing has ever written answers no, so it shows the theme picker and then Select login method while holding a forwarded token that already works.

The split is what made this confusing to diagnose. dl <ws> -- claude -p "say OK" answers from the forwarded token in the same workspace, in the same second, that the interactive claude asks you to log in: print mode never asks the onboarding question. So "the token is not arriving" and "the token is arriving and the wizard is in front of it" look identical from the outside and are fixed by opposite things.

The setup pass therefore seeds that key, where there is no .claude.json at all. It records a fact and carries no secret. There is no environment variable that turns the gate off; the flag in that file is the only thing it reads. The same write carries a second key, for the second gate the next section is about.

Where that file is is not where the config directory is, and getting this wrong is a fix that reports success and changes nothing. Claude Code resolves the two halves of its configuration differently, and only one of them defaults into ~/.claude:

globalConfig: join(CLAUDE_CONFIG_DIR || homedir(), ".claude.json")
userSettings: join(CLAUDE_CONFIG_DIR || join(homedir(), ".claude"), "settings.json")

So with the variable set the file is $CLAUDE_CONFIG_DIR/.claude.json, and with it unset the file is $HOME/.claude.json, beside the config directory rather than inside it. The first version of this seeded the config directory either way and was measured doing nothing at all in a container with the variable unset.

Only where the file is absent, which is the whole of the condition and is what keeps it from needing the ownership question "The repo that arranged its own" answers below. Claude Code merges its own keys over a file it finds, so seeding ahead of its first run costs that run nothing.

Nothing is ever overwritten by that write, and that is the promise rather than the stronger one it would be nice to make. A config directory bind-mounted from your host that already holds a .claude.json is left untouched, which covers every host that points CLAUDE_CONFIG_DIR at ~/.claude. A host that does not, and so keeps its own config at ~/.claude.json, has a ~/.claude/ with no .claude.json in it: a container that mounts that directory and sets CLAUDE_CONFIG_DIR to it will gain one, and the file will therefore appear on your host too. It is the one boolean, it is what makes claude in that container work at all, and your own claude does not read it.

Neither opt-out reaches it, and both would be the wrong owner. DEVLAUNCH_NO_TOOLS=1 is about installing tools, and seeding a boolean installs nothing. DEVLAUNCH_NO_CLAUDE_TOKEN=1 says do not put your credential in this container, which is a different sentence from "make me answer the theme picker": a workspace opted out still reaches its login screen, because there is no credential rather than because a flag was missing.

Which repos this was actually visible in: the ones with no .devcontainer/ of their own. They get devpod's fallback image and a virgin config directory, where a repo whose devcontainer mounts ~/.claude was reading a .claude.json that had answered the question on the host months ago.

The trust prompt, which is keyed on a path

The wizard is not the last gate. Claude Code also asks "Do you trust the files in this folder?", and it keys the answer on an absolute path, in projects{} in that same .claude.json:

projects["/workspaces/devlaunch-nb99-hicj"].hasTrustDialogAccepted = true

dl mints one clone per workspace, so every workspace is a path Claude Code has never been told about and every first launch in one asks again. On a machine that opens a workspace per branch that reads as "every time".

Trusting the parent once does not close it. The check walks up from the directory it was given, but the walk is floored at the git root, and a devlaunch workspace is a clone: its git root is the workspace directory itself, so the walk starts and ends in one place and never reaches /workspaces. Inheritance from a trusted parent only helps outside a repository. Measured against Claude Code 2.1.270.

So the setup pass records the entry itself, and it takes two shapes because the config file does:

  • No .claude.json at all. The seed above writes both keys at once, which needs no JSON parser because there is nothing to merge with.
  • A .claude.json already there, which is what a container sharing your host's config directory has, devlaunch's own devcontainer included. A second stage merges the one key with python3 and leaves every other byte alone. A textual insert has no sound spelling: after the opening brace it loses to the original on last-key-wins, and before the closing brace it wins and takes every project you had with it.

A container with no python3 gets nothing from the merge and says nothing about it. Where a .claude.json is already there, which is what a mount of the host's config is, the prompt appears exactly as it did before: installing an interpreter on every cold launch to spare one keypress is the wrong trade, and a stage that failed would warn on every launch of an image that is working correctly. The other half needs no interpreter, so a virgin config directory is still seeded with the workspace recorded in it and does not prompt.

The path it records is the pass's own working directory, resolved, and never one composed from the workspace id. A devpod ssh given no --workdir lands in the workspaceFolder from devcontainer.json, so the pass is standing in the directory a session will start in, and a repo that sets workspaceFolder puts that somewhere /workspaces/<id> does not name. A path holding a quote or a backslash is left unrecorded rather than escaped: the prompt appears there, and the config file stays readable.

Only a git root is recorded, whatever the pass is standing in. The pass runs under bash -lc, so a container's login profile is sourced before either stage is given a working directory, and a profile that cds leaves it standing somewhere that is not the workspace. What makes an entry safe to write is the floor the walk stops at: outside a repository there is no floor, so an entry there trusts every directory under it that is not itself a clone, which is what trusting $HOME or / would do. A workspace is a clone, so requiring the floor and recording the workspace are the same requirement, and /srv or a projects folder is refused for the same reason as those two rather than by not being on a list. $HOME and / are still refused by name, because a machine whose home directory is itself a repository would otherwise clear the floor test.

This says the container is the trust boundary, which is a policy choice rather than only a convenience. A fresh clone of somebody else's repository is trusted because dl put it in a container, not because anyone read it. That is the same boundary the rest of dl already draws, since a stranger's postCreateCommand runs unread on every cold launch, but it is worth saying rather than leaving to be inferred from a stage name.

CLAUDE_CODE_SANDBOXED=1 turns the same prompt off and is a blunter instrument. It opens a second gate with it, project-scoped permission grants, so a repository's checked-in .claude/settings.json can grant itself allow rules and additionalDirectories merely because you opened it. dl opens arbitrary third-party repos, so that is a live consideration rather than a theoretical one. Seeding the path keeps the grant to workspaces dl created and leaves that gate shut.

A session rooted at $HOME is a separate case no seeded entry reaches: home trust is per session and never reaches the config file.

Who gets the Claude token

Fewer things than get the GitHub one, deliberately. The GitHub token goes into devpod's workspace environment at up, which is what makes it available to a repo's postCreateCommand. This one does not. It rides --send-env on the sessions dl itself opens, so dl someone/repo does not run a stranger's postCreateCommand with your Claude login in reach, and nothing is left behind in devpod's workspace configuration.

It is still a wider trust boundary than not forwarding it. Everything running in the session can read the variable, and a Claude login is not scoped the way a GitHub token is. Skip it for a repo you have not read:

DEVLAUNCH_NO_CLAUDE_TOKEN=1 dl someone/repo

A session dl did not open does not get it: devpod ssh by hand, or the VS Code window dl <workspace> code hands over to. Widening it to reach those would mean widening it to reach postCreateCommand too.

Variable Description
DEVLAUNCH_NO_CLAUDE_TOKEN=1 Do not forward the host's Claude login into workspaces

The repo that arranged its own

Some devcontainers mount ~/.claude from the host themselves. devlaunch's own is one of them. Forwarding into those would make things worse rather than better: Claude Code prefers the variable over the file, so a mounted credential that can refresh itself would be replaced by one that cannot.

So the setup pass asks. Its probe reports where the container's Claude config directory resolved to, what $HOME resolved to, whether the mount table could be read at all, and the source subpath of every mount touching that directory. The host reads those and decides, in one place, exactly as it does for "is this a real claude".

The rule needs both homes, and only the host has the second one. A mount's root is a path in the namespace it came from, so a bind of the host's ~/.claude reports the host's own path. Compared against the container's $HOME alone that reads as the container's own the moment the two spell the same, which remoteUser set to your username does, and so does a container running as root on a host that is. So a root convicts if it falls outside the container's home or inside the host's.

The scan reads /proc/self/mountinfo rather than asking findmnt about the directory, and it looks both ways. findmnt --target answers for the nearest mount at or above a path, and one shape that matters sits below one: before it switched to mounting the directory, devlaunch's own claude-code feature mounted nine individual paths underneath ~/.claude, settings.json read only and .credentials.json read write, and a question about the directory reports nothing mounted there. The other direction matters just as much. A devcontainer that binds the host's whole $HOME onto the container's home puts nothing under ~/.claude and owns every byte in it. So the scan matches a mount point that is the directory, one under it, and one above it.

Two answers are spared, because neither is evidence of another home: a mount whose root is /, which is the whole of a separate filesystem and so a volume or a tmpfs, and a root that is not an absolute path.

A pass that cannot find out forwards nothing, and it says which it was. An image without awk, a kernel without mountinfo, a $HOME that will not resolve: the probe reports the scan as not having run, because an empty list of mounts otherwise reads the same from a container with none and from one nobody looked at. Neither is evidence that the directory belongs to the container, and the cost of being wrong that way is a login prompt rather than a hijacked credential.

Workspaces that predate this

The answer is recorded on the host, beside the verdict a top up trusts, so skipping the round trip does not skip the decision. It is stamped with the container it was true of, the way that verdict is, so a devpod up devlaunch did not run leaves an answer that no longer applies and reads as no answer at all.

A workspace that is already up and finished creating runs no pass at all, though, and one created before this existed has no recorded answer. Those get no Claude login, and they pick one up on their next up, restart or recreate:

dl <workspace> up

Once, per workspace. A workspace created by a build that has this carries an answer from the pass that created it and never needs the step. The same step is what re-answers for a container somebody else's devpod up rebuilt, VS Code's or a hand typed one, since nothing about that touches the host's records.

Paying for a pass on the attach instead was tried and reverted. It puts two setup-stage warnings on the terminal of every first attach, on the hottest path dl has. Forwarding anyway when nothing is known was the other candidate, and it would override a mounted credential that can refresh itself, on every warm attach, for as long as no pass had run.

Codex authentication

aid --codex needs two things a fresh container has neither of: a codex binary and a login. dl arranges both, by two different routes, because they belong on different trips.

The binary comes from the network rung, as one more pixi global install. That is the same mechanism behind gh and claude, and it runs as a stage of the setup pass. It is not lent from the host the way claude is, and the size is the reason: the host's claude is a ~21KB shim, while codex is a 244MB binary that would be streamed over the ssh channel on every cold provision. It is installed only on a launch that runs codex, so a dl <ws> that wants a shell never pays for it.

One consequence worth knowing: a workspace first opened with dl <ws> and later used with aid --codex <ws> spends one extra pass, because the provisioning record remembers which stages a workspace was given and this launch wants one more. dl says so when it happens. The second aid --codex on the same workspace is warm.

The login is a file, and a smaller one than the host's

Codex has no environment variable to authenticate from. Measured against codex-cli 0.154.0 it reads $CODEX_HOME/auth.json and nothing else, and codex login --with-access-token is not a way in either: it wants an agent identity JWT and refuses an OAuth access token outright.

So dl writes the file. It does not copy the host's. It reads the host's ~/.codex/auth.json, keeps tokens.id_token, tokens.access_token, tokens.account_id and last_refresh, blanks tokens.refresh_token, and forwards that as one variable on the session. The container-side write happens before codex starts, under umask 077.

The refresh token is the point of the omission. It is what a container could use to rotate your own ChatGPT login out from under you, and it is why the Claude token is a variable rather than a copy of ~/.claude/.credentials.json. Claude Code's variable let dl avoid a file entirely. Codex leaves no such option, so the next best thing is a file that cannot mint another credential. What lands on the container's disk expires in hours.

The credential rides --send-env on the sessions dl itself opens, and does not go into devpod's workspace environment at up. So a repo's postCreateCommand never runs with your Codex login in reach, and dl <ws> up warms a workspace with no credential anywhere near it.

A login you made in the workspace is left alone

The file dl writes is marked by its empty tokens.refresh_token. A codex login run inside a workspace writes a populated one, so dl replaces only a file that is absent or one of its own. If you signed a workspace in to a second account by hand, that is what stays.

When the token expires

The access token is good for hours, and the file dl wrote cannot renew itself. A long-lived workspace that outlives it does not prompt: codex tries to refresh, fails against the blank refresh token, and reports a 400. The fix is another launch, which writes a fresh token. This is the cost of not shipping the credential that can refresh, and it is deliberate.

Turning it off, and when there is nothing to forward

DEVLAUNCH_NO_CODEX_TOKEN=1 opts a machine out entirely. $CODEX_HOME is honoured as Codex honours it, on both sides, so a host that has moved its configuration has moved the credential too. It replaces ~/.codex rather than being tried ahead of it, so a host with two logins cannot silently forward the wrong one.

A host that has never run codex login forwards nothing and says nothing: codex starts in the workspace and asks for a login itself. A host signed in with an API key rather than a ChatGPT account has no OAuth tokens in auth.json at all, and dl warns once, naming the file. Either way the workspace opens. A codex that has to be logged in by hand is a better outcome than no container.

aid --codex <workspace>            # installs codex if absent, writes the login
dl <workspace> -- codex exec "..." # the same two things, non-interactively

Gemini

aid --gemini gets neither half of this. gemini-cli is not on conda-forge, so the pixi rung has nothing to install, and nothing forwards a Google credential. A workspace that needs it has to bring its own.

Tools in every workspace

gh and claude are available in every workspace dl opens, in every kind of session: an interactive dl <workspace>, a one-shot dl <workspace> -- <command>, and aid. The repo's devcontainer.json does not have to provide them, and most do not. dl launches arbitrary repos, so a guarantee that depended on the image would not be a guarantee.

How they get there

On devpod up, at most three round trips, each one earning the next.

1. The setup pass, the only trip a ready workspace ever pays. One trip carries everything the host wants done on the way into a running container: the stages first, then the probe. Three stages exist today. Naming the container, which is the hostname your shell prompt shows, always runs; installing zellij and naming the terminal each run only when its own switch asks for it. They cost nothing extra because the probe was paying for the trip anyway. Each stage reports ok, failed with its exit status, or not reached; one that fails stops neither the stages behind it nor the probe, and dl says which one it was.

The probe is the tail of that trip. The container reports what only it can know: whether both tools answer at all, where its claude resolves to, and where ~/.local/share/claude/versions in its own home resolves to. It reports those and names no verdict; the host reads them, so "a real claude" is defined in exactly one place. The reading is one of three:

  • provisioned, when gh answers on the login PATH and claude resolves to a binary the official installer put in the versions directory. Nothing else happens.
  • lendable, when both names answer but that claude is a shim or a wrapper.
  • absent, when a tool is genuinely missing.

2. A lend, for lendable and absent. dl streams its own gh and claude into the container as a tar over the devpod ssh channel it already holds: a local pipe, no network and no download. Nothing lands outside a staging directory until both binaries have been run there once, so a container that cannot execute them (a different libc, a different architecture) is left exactly as it was.

3. The network install, for absent only. When the host had nothing to lend, or the lend was refused, pixi global installs both tools, and pixi itself first if the image has none. A lendable container never reaches this trip: it stops after the lend, or after the probe itself when the host had nothing to lend. A claude already answers there, and this install decides what to do with the same command -v that a shim satisfies, so the trip would install nothing.

Tools reach the PATH of a login shell through whichever of ~/.bash_profile, ~/.bash_login or ~/.profile bash actually reads. It sources only the first of those that exists, so an image shipping a ~/.bash_profile never reads ~/.profile.

An install that fails costs the workspace its tools, not its launch: dl logs a warning and hands you the session anyway.

The trip a launch can skip

Trip 1 is cheap but it is not free: about 1.7 seconds, almost all of it connection and process setup rather than the script it carries. A workspace that has had both tools in it for a week pays that on every dl <workspace> up to be told the same thing it was told last time. So when the answer was provisioned, dl writes it down and reuses it.

The marker is one small JSON file per workspace under ${XDG_CACHE_HOME:-~/.cache}/devlaunch/tool-verdicts/, holding the verdict and the modification time of devpod's own workspace_result.json for that workspace. devpod rewrites that file on the way out of every completed up, whoever ran it: dl, VS Code, a hand-typed devpod up, a --recreate. So a container that has been rebuilt has a result file whose mtime no longer matches, and the marker stops being believed. Anything else unexpected also stops it being believed: no marker, an unreadable one, a workspace dl cannot find one result file for. Every one of those falls back to making the trip, which is exactly what happened before the marker existed.

Only a launch that finds the workspace already running can skip it, and that is a smaller claim than it sounds. The container's hostname lives in a namespace docker rebuilds from the container's config on every start, so a devpod up (creating a container or starting a stopped one) loses the name and the pass has to run again to set it. The two paths that skip are dl <workspace> up against a container that is already up, which is the pre-warm and where the 1.7s is paid most often, and a launch that waited on a sibling which had already brought the workspace up. Nothing is ever skipped after this launch's own devpod up.

Nothing has to be cleaned up, and there is nothing to invalidate by hand: the markers are compared, never trusted on age, and deleting the whole directory costs one round trip on each workspace's next launch.

A pass that was interrupted

A pass is up to three trips and the slow ones are the last two: the lend streams the host's binaries, and the install fetches a claude of a few hundred megabytes. A Ctrl-C lands in the middle of that often. What it leaves is the awkward case: devpod up has already finished, so devpod wrote its create result and the container is running, and everything the Ctrl-C stopped was dl's. The container looks finished to everything that can be asked about it, so a later launch takes the fast path and provisions nothing, forever.

So a pass writes down that it is running before its first trip, in <workspace>.pass beside the marker, and removes it after its last. dl's signal handler exits without unwinding, which is why the evidence has to be a file left standing rather than one written on the way out: nothing runs on the way out. A launch that finds one still standing for the container standing now runs the pass before it hands over a shell, and says so.

The record is anchored to the same workspace_result.json mtime the marker is, so one left by a container that has since been rebuilt describes nothing that exists and is ignored. It is read as positive evidence and never as the absence of it: a workspace brought up by VS Code, by a hand-typed devpod up, or by a build older than the record has no record either, and those keep attaching in one round trip.

A pass whose install ran and failed closes the record like any other: it happened and it said so, and re-running it on every attach afterwards would be a container re-attempting one failing thing forever. The one outcome that leaves the record standing is a trip the OS would not make at all, because nothing was learned about the container and nothing was done to it, which is the state the record is for.

What to bake so a launch does no work at all

To make every dl launch of an image stop at trip 1. The probe asks a login shell to resolve each name, so every bullet here is about what a login shell can find:

  • gh anywhere on the login PATH.
  • claude in the layout its official installer creates: the binary at ~/.local/share/claude/versions/<version>, a direct child of that directory named for the version, with ~/.local/bin/claude symlinked to it. Nested any deeper, as in versions/<version>/bin/claude, the shape a downloader parked there would take, is read as somebody else's tree that merely starts with the official path, and does not count.
  • ~/.local/bin on the login PATH. The symlink above is how claude answers at all; a login shell that cannot find that directory reads the image as absent however carefully the rest was baked, and it pays the full lend. Ubuntu's stock ~/.profile prepends ~/.local/bin itself, but an image shipping a ~/.bash_profile never reads ~/.profile (above), and then nothing does.

Nothing else counts as a claude, and that is the point. A shim, a small launcher that downloads the real binary the first time it is called, answers command -v claude exactly as the real thing does, while the workspace still owes a multi-hundred-megabyte download at the least convenient moment. So dl resolves the name rather than running it (running a shim is the download), reads a shim as lendable, and sends the host's real binary. The lend prepends ~/.local/bin to the login PATH, which is what puts the lent binary in front of the shim from then on. That is intended, and the reason the next launch probes provisioned and the transfer is paid once rather than forever.

This repo's own devcontainer feature bakes a shim. .devcontainer/claude-code/install.sh installs claude-shim, so an image built from it does not meet the contract by itself: its first dl launch is lent a real claude, and only launches after that do nothing. Build the official layout into the image if you want the first launch free too.

What this deliberately does not do

  • No per-tool transfer. The lend is all-or-nothing, so an image with a real gh but a shimmed claude is sent both. Splitting the payload would save part of one transfer, paid once per workspace, in exchange for a matrix of half-lent states every later step would have to reason about. (The network install is already per tool: each install guards itself with its own command -v.)
  • No version sync. A real claude already in the container is left alone whatever its version. dl lends what is missing; it is not a package manager, and keeping versions in step would mean deciding what to do when the container is the newer one. The official binary self-updates in a long-lived workspace, and rebuilding one re-provisions it from scratch. The single upgrade dl does perform is replacing a shim with a real binary.

Turning it off

DEVLAUNCH_NO_TOOLS=1 dl someone/repo
Variable Description
DEVLAUNCH_NO_TOOLS=1 Do not install gh or claude into workspaces. The setup pass still runs, one trip per up, which still names the container; only the installing is skipped

There is no narrower variable for zellij, because there is nothing to turn off: installing it is opt-in, under A terminal beside the agent. DEVLAUNCH_NO_TOOLS=1 also overrides a launch that did ask for it, since installing zellij is tool provisioning too.

Attaching to a workspace that is already running skips devpod up, and so skips this too. A workspace started by something other than dl, or created before this existed, picks the tools up on its next dl <workspace> restart.

A terminal beside the agent

Ask for it and a workspace gets zellij on PATH, which buys one thing the other tools do not: an agent running in a container can open a second terminal next to itself, in the same container, and you can attach to it from anywhere to watch or to type.

DEVLAUNCH_ZELLIJ=1 dl someone/repo -- claude -p "do the thing"

Asking is what this needs, and asking is all it needs. One variable, and then nothing has to cooperate: it does not come from your dotfiles, it does not need an edit to any repo's devcontainer.json, and it works in images dl has never seen. That is the same argument the rest of "Tools in every workspace" makes, for the same reason: dl launches arbitrary repos. Set it once in a shell profile and it is there in everything you open.

Unlike gh and claude, it is not on by default, and the reason is that until you ask, nothing would use it. Creating the session an agent opens panes into has always been opt-in, so an install that was opt-out spent what it costs on every cold launch to provision a capability the same defaults guaranteed nothing would touch. One variable now answers both halves.

Opening a pane from inside a session

From anywhere inside the container, including from a completely non-interactive command with no terminal attached to anything:

zellij -s devlaunch action new-pane -- htop

-s <name> is the form to use and the only one worth depending on. Bare zellij action new-pane happens to work by falling back to the single running session, which stops being a single session the moment there are two of them.

devlaunch is the session name dl creates and the one to name here.

The one switch, and both things it does

Variable Description
DEVLAUNCH_ZELLIJ=1 Install zellij into the workspace on the setup pass, and, before running dl <spec> -- <command>, make sure a session named devlaunch exists in the container for the command to open panes into

With it off, no invocation changes meaning at all and no launch pays for a zellij. That is what off means here, and it is why the switch exists rather than the behaviour simply being on.

One variable and not two, and that is a deliberate retirement. The install used to have an opt-out of its own, DEVLAUNCH_NO_ZELLIJ, which is now read by nothing: a stale DEVLAUNCH_NO_ZELLIJ=1 left in a profile does nothing at all, and in particular never turns provisioning back on. Two switches made four combinations of which two were incoherent, the worse being "install nothing, then start a session in it", which the old page described honestly as a session setup that fails and a command that runs anyway. Asking for the capability now gets you all of it, with one residue: DEVLAUNCH_NO_TOOLS=1 still overrides the install while the session setup still runs, so that pair reaches the same incoherent state by the one route left. It fails soft the way it always did, and it is a state you have to ask for in two variables rather than the default shape of one.

The command runs beside the session, not inside a pane of it. That is deliberate. Putting the command in a pane would hand its stdin, stdout and exit status to zellij, and all three are things dl promises to leave alone: dl <ws> -- cmd > file has to put the command's own output in the file, and a failing command has to come back with its own status. Since zellij -s <name> action new-pane works perfectly well from a command that is in no session at all, running beside the session costs nothing and delivers the same pane.

The interactive session of a bare dl <workspace> is untouched, switched on or off. An interactive attach sends no command for the wrap to attach to, which is exactly what gets it a terminal from devpod, and giving it one would cost either the terminal or a round trip in front of every shell. What the variable still buys an interactive attach is the binary: with it set you land in an ordinary login shell with zellij on PATH, so zellij attach -c devlaunch gets you the session, and any panes an agent has opened in it, whenever you want them. Without it set, that command is a command not found, which is the one place this default costs a human something rather than saving them something.

There is one exception, and it is a pleasant one: if you also run with DEVLAUNCH_DOTFILES_ON_ATTACH=1, that refresh is a command, so it gets wrapped like any other and the session is already there when the shell arrives.

Existing workspaces

zellij arrives on the setup pass, and dl <workspace> up runs one against a workspace that is already up. So that is all it takes: set the variable, run the prewarm verb, and the stage lands on a container that never stopped. A dl <workspace> restart works too and is the more expensive way to get there, because it stops the container and kills whatever was running in it; a full dl <workspace> recreate is more expensive again and buys nothing here, since nothing on this page is a bind mount and mounts are the thing that only lands at container creation.

That is not a special case for zellij. dl remembers that a container was found provisioned so the next top-up can skip the round trip that found out, and what it remembers includes which switches the pass ran under. Turning this one on disagrees with what every previous launch recorded, so the remembered answer stops being trusted and the pass travels again. Turning it back off costs the same redundant trip once, on a pass that then carries no stage at all.

An attach against a workspace that is already running runs no setup pass at all, whatever shape it takes: dl <workspace> and dl <workspace> -- <cmd> both go straight to the attach. That is what makes the up necessary rather than automatic, and it is worth knowing in the other direction too: setting the variable and then running dl <ws> -- <cmd> against a container that is already up wraps a command in a zellij that was never installed, and the wrap fails quietly. Bring it up once first.

What it costs

The reason this is opt-in, and it was measured rather than assumed. A/B'd on cold launches of one repo on one box, reading the setup pass's own round trip, the stage is 2.2s warm to 3.5s cold. Splitting it by hand over three fresh containers off a stock base image, with the shared cache bound in the way a launch binds it:

Bootstrapping pixi (curl | bash, no pixi in the image) 1.70s / 1.79s / 1.70s
Installing zellij (shared cache populated) 0.48s / 0.49s / 0.51s
The whole stage 2.19s / 2.27s / 2.21s
On an empty shared cache the stage reaches 3.5s and fills 168MB of it
Every launch after the first one command -v, and nothing else runs

The bootstrap is the dominant term and the one the shared package cache cannot help with, because it is pixi's own installer over the network rather than a conda package. It is also, on the common cold launch, the only thing that puts pixi in a container at all: a container that already has gh and a real claude is never sent the tools install, so this stage is where pixi arrives. An image that ships pixi pays only the install half, which is very likely how the older figures on this page came to be measured.

It can never fail a launch. Provisioning zellij is a stage of the setup pass, so a container with no network, no pixi and no way to get either reports the stage as failed, by name, and then opens exactly as it would have. A container that ends up without zellij still works, and the command still runs, because the session setup is allowed to fail and the command runs regardless.

DEVLAUNCH_NO_TOOLS=1 overrides a launch that did ask, along with the rest of tool provisioning. Installing zellij is tool provisioning, where naming a container is not, so neither switch touches the hostname stage: a host that wants no zellij has not thereby asked for unnamed containers.

Both variables read the same values: anything but empty, 0, false or no counts as set. DEVLAUNCH_ZELLIJ is a consent and DEVLAUNCH_NO_TOOLS a denial, so =0 means "no zellij" in the first and "install normally" in the second, which is what each of them reads as unset.

Naming the terminal after the workspace

Every launch names the terminal after the workspace it is opening, just before the session takes over:

ESC ] 2 ; devlaunch@main BEL

That is one escape sequence to whichever stream dl was given, and the point of doing it that way is that dl does not have to know what is reading it. zellij and tmux both take OSC 2 as the focused pane's title, and a bare terminal takes it as the window title. So dl names the pane in zellij, in byobu-on-tmux, and in a plain kitty or xterm window, with one write and no detection.

It is on unless you turn it off:

Variable Description
DEVLAUNCH_NO_TITLE=1 Do not name the terminal: neither the escape below nor either of the profile lines under What keeps it named. Everything else about the launch is unchanged

A "no" variable, where DEVLAUNCH_ZELLIJ is an opt-in one, because the two are not the same size of decision. That one installs a package into a container and starts a session; this one writes an escape sequence and two lines into a profile.

It is the workspace read for a person: <repo>@<branch>, the branch spelled as you typed it and the hashed suffix off. The renderings table is where the three are written down side by side and where the spelling is decided; the escape sequence above and the profile line below show it in place rather than settle it.

What goes is what a glance cannot use. The suffix carries the workspace's identity and none of its meaning: it is what keeps two branches whose readable halves cut to the same string in two containers, and by the time you are looking at a tab you have told them apart by the branch. What comes back is the branch. devlaunch@main reads as the branch it is where devlaunch-main reads as one dashed word, and devlaunch@feat/ABC_123 reads as the branch it is where devlaunch-feat-abc-123 reads as the name of a different branch the repository could have.

An id has to be a DNS label, since it names a devpod workspace and is the container's hostname, and that is the whole reason it lowercases and flattens. A tab is neither, and every character the id has to give up is inert in all three places a title lands: the OSC 2 escape above ends at a BEL, herdr tab rename takes the title as argv with no shell, and the profile line below is re-expanded rather than re-parsed, so only $, a backtick and a backslash would ever act and a branch name cannot hold one. The repo half is the exception, and it stays flattened: owner and repo are matched case-insensitively, so one workspace has several spellings of them and no particular one is the one you typed.

The branch is still cut to the id's budget, so a long branch is cut and a middle segment the id dropped is dropped here too: dependabot/github_actions/codecov/codecov-action-6 reads as devlaunch@dependabot/codecov-action-6. That is the tab bar's constraint rather than a shortfall: a name in a tab shares a strip of screen with a dozen others and is read at a glance. Where the whole branch matters, the selector spells it out.

A slash lands where the branch had one and nowhere else. The separator comes from the same segment split the id is cut by, not from a pass over the finished id, so nothing inside a segment turns into one: github_actions reads as github_actions in a label, never as github/actions.

It used to be the whole spec you typed, resolved, blooop/devlaunch@main, and the reason that is not what came back is length. A triple is checked for the characters it holds and not for its length, so a 200-character branch made a 200-character tab. A label inherits the id's bound instead: at most 47 characters less the five the suffix and its dash take. What stays lost with the owner is the fork: an id has never carried one, so blooop/devlaunch@main and a fork of it read alike.

dl ./some/dir and a plain URL never had a branch for an @ to precede, so those are titled by id, exactly as before. So is a workspace you name by its id on the command line: dl devlaunch-main-3j1t is handed a name and nothing else, and a branch cannot be read back out of an id (the repo slug holds dashes of its own, so my-repo@main and my@repo-main are one id read two ways).

The selector is the exception, and it is the one that matters, because dl with no arguments is how a workspace is reopened. It hands the launch a workspace id like any other, but it had the triple a moment earlier: it read the owner and repo out of the cache layout and the branch out of the clone's HEAD to draw the row you picked. That travels with the pick, so a workspace opened from the selector is titled exactly as one opened as dl blooop/devlaunch@main is.

It is checked rather than trusted. HEAD is the branch checked out now, so a git switch inside the container leaves a triple that derives some other workspace, and a triple that does not derive this very id is dropped in favour of the id. That is the same check a workspace addressed by a recorded id gets.

Written to stderr, and only when stderr is a terminal. stdout belongs to the completion machinery and to wf, which parse it. The tty check is on stderr for the same reason: dl <ws> -- make test > log has redirected stdout and still has a terminal worth naming, while a run whose stderr is a pipe would only be writing escapes into somebody else's capture.

What keeps it named

A terminal title has exactly one value and the last writer sets it. An interactive shell overwrites dl's within a second of arriving. Ubuntu's stock ~/.bashrc puts \e]0;\u@\h: \w\a at the front of PS1, so every prompt renames the pane after the container's hostname and the working directory, which is more than the tab wants and in a different shape.

So the setup pass appends one line to the profile a login shell reads:

case $- in *i*) [ -n "$BASH_VERSION" ] && PS1="$PS1\[\e]2;"devlaunch@main"\a\]" ;; esac

Appended, and that is the whole mechanism: two escapes in one prompt are applied in order, so the last one sets the title. Nothing is rewritten. The visible vscode@devlaunch-main-3j1t:~/repo$ still says the hostname, which is the id, and only the tab changes. (A PROMPT_COMMAND cannot do this job: bash runs that before it prints PS1, so the stock escape would land afterwards and win.) Interactive bash only. bash -lc reads the same profile on every dl <ws> -- cmd one-shot, and \[, \e and \a mean nothing to dash, which is /bin/sh and which reads ~/.profile too, so an unguarded line would print the escape at every prompt instead of acting on it.

It is written once, since the line carries a content-hash comment the next launch recognises, and it rides the same round trip as the hostname stage, so it costs no extra trip.

The line and the escape carry the same string, or the tab would change the moment the first prompt painted. Both come from the placement, which decides the name once, where the launch still knows whether it resolved a branch.

That leaves one rough edge, and it is the price of the @. The line is recognised by a hash of its own text, so a second, different name for one workspace does not replace the first, it sits after it, and the last one wins. Every launch that resolves a branch derives the same label, and the arms that never had one all use the id, so those agree among themselves. What does not agree is one workspace opened both ways: dl blooop/devlaunch@main installs the readable name, a later dl devlaunch-main-3j1t installs the id, and the tab reads as the id from then on. It costs one extra line in the profile and a less readable tab, in a case most workspaces never reach.

It is installed when a workspace enters Running, not on every attach. A workspace that is already up keeps whatever its profile was given, so DEVLAUNCH_NO_TITLE=1 dl <ws> silences dl's own escape and leaves the prompt's; dl <ws> recreate is what re-decides it. That is the same bargain the hostname stage makes, and for the same reason: the alternative is a round trip per attach.

The one other writer worth knowing

claude writes the title continuously from its own read of what the session is doing, which would leave a dl <ws> -- claude pane named after the task rather than the workspace within a second. The PS1 line above cannot help there, because it only repaints at a prompt and claude overwrites between prompts. So the title stage appends one more line to the same profile:

export CLAUDE_CODE_DISABLE_TERMINAL_TITLE=1

and the workspace name is what stands, whoever started claude. What claude is doing is on screen inside the pane; which workspace the pane is is not otherwise anywhere.

It used to be aid's doing alone, and that only ever covered the sessions aid launched. A claude you type at the prompt yourself, or a dl <ws> -- claude ... you wrote out in full, is your command, and dl does not rewrite it. That rule has not changed: an export in the profile is the environment your command inherits, not an edit to your command. aid still sets the variable on the claude it starts, since a prefix and an inherited value of the same variable agree, and the prefix works on a container that has not been re-entered yet.

DEVLAUNCH_NO_TITLE turns this off with the rest of it, because it is one feature and not three. And it reaches login shells that start after it was written, so a workspace that was already running when this arrived wants a re-login or a dl <ws> recreate. Same bargain the PS1 line makes.

Two multiplexer limits are worth stating, because neither is dl's to fix:

  • In tmux the window name needs allow-rename on (off by default in recent tmux), and the outer terminal title needs set-titles on. The pane title always takes it. Both are your tmux config.
  • GNU screen, byobu's other backend, names windows with ESC k <name> ESC \ and ignores OSC 2. Emitting both sequences would put stray text in any terminal that groks neither, so screen is out of scope rather than half-served.

zellij tab names are not this. A zellij tab is renamed only by zellij action rename-tab or a plugin; no escape sequence reaches it, which is why this names the pane instead. The window title zellij then publishes to the outer terminal is <session> | <pane title>, so the workspace id is what shows up in a kitty tab bar, provided zellij is the innermost multiplexer. Nest another one inside it and the escape stops there instead, which is the next section.

The herdr tab, which is renamed and not written to

herdr is the one multiplexer dl names with a command rather than an escape, and it is worth saying why, because the paragraph above declines to do exactly that for zellij.

The escape reaches herdr and stops there. herdr reads OSC 2 and keeps it: after dl rocker@nb1 in a herdr pane, herdr pane list reports that pane's terminal_title as rocker@nb1, unmangled. What it does not do is show it. A herdr tab label is a field of its own, custom_name in herdr's session.json, which falls back to the tab number when unset, and herdr tab rename is the only thing that writes it. So the name dl computed sits one field away from the tab strip with nothing to carry it across. dl carries it, with the same name and under the same switch.

Detection is what made this worth doing. The argument against zellij action rename-tab is that it costs a probe to find out whether zellij is even there. herdr exports HERDR_TAB_ID into every pane it spawns, so dl reads one variable it already has and does nothing at all when it is absent. HERDR_BIN_PATH, exported beside it, is the herdr asked; a PATH lookup for herdr is the fallback for a herdr too old to export one.

Stacking is where this stops being a nicety. The escape goes to whichever multiplexer owns the pty, and that is the innermost one. Under kitty -> zellij the escape reaches zellij, which takes it as the pane title and publishes <session> | <pane title> upward, so the workspace name lands in the outer terminal exactly as the paragraph above promises. Put herdr inside that zellij and the same escape now stops one layer further in, at herdr, and the layers that used to display it never see it. Nothing about dl changed in that story; a layer was added. The tab strip herdr draws is then the only place left to put the name.

It is best-effort and it is the same feature. A stale tab id, a herdr server that has since exited, a binary that moved: none of them may cost you a workspace, so the rename is spawned and never waited on, and nothing is reported about whether it worked. DEVLAUNCH_NO_TITLE=1 turns it off with the escape and the PS1 line, because it is one feature and not three, and a run whose stderr is not a terminal renames nothing for the same reason it writes no escape.

Unlike the escape, the rename sticks. herdr persists custom_name, so a tab named by a launch keeps that name after the workspace is closed, until something renames it again.

The window aid used to leave unnamed

Naming happens on the launch, and aid with no prompt on the line does not launch straight away: it boots the workspace in the background and asks for the prompt while it does, which is the whole point of that mode. So there was a window with a booting container, a person typing, and a tab still showing whatever it showed before. Measured on live herdr 0.8.2, with dl and aid built from one commit:

launch tab label
dl blooop/rocker@nb1 rocker@nb1 within 6s
aid --claude blooop/rocker@nb1 'prompt' rocker@nb1 within 6s
aid blooop/rocker@nb1 unchanged for the whole editor window, 40s in
the same tab, after the prompt was submitted rocker@nb1 within 6s

aid names both halves itself now, before the banner and after the boot spawns, through the same pair the launch uses. The boot child cannot do it: its stdout and stderr are a log file, so the gate refuses it a name, and correctly, because an OSC escape written into a log is not a title.

The name is what the spec says rather than what it resolves to, because resolving it costs a record lookup and, for an owner/repo with no ref, a git ls-remote for the default branch. An editor may not wait behind either.

spec named while you type the launch then says
owner/repo@ref repo@ref the same
owner/repo repo repo@<default branch>
an existing workspace name itself the same
a path, or a source URL nothing the leaf devpod resolves

So two rows are corrected a moment later, and a tab reading rocker while you type and rocker@main afterwards beats one reading 7. A spec that names nothing dl will accept is not named at all, because the name is derived through the same plan that refuses it.

The tab and the pane are still never given different answers: both are written together at both points, from one call that has no way to return half an answer.

Telling a session manager which agent is running

Session managers like herdr, and the tmux-and-worktree family generally, put each agent in a pane and tell you which ones are working and which are stopped waiting for you. Under dl they used to tell you nothing, and the reason is narrower than it looks.

A manager learns what an agent is doing in two steps. First it decides which agent a pane holds, usually from the pane's foreground process. Then it matches that agent's rules against the pane's screen. Under aid the first step fails and takes the second with it: the foreground processes are aid, ssh and two devpods, with no claude anywhere, so no agent is identified and no rules are ever run.

The second step was never broken. dl <ws> -- <agent> pipes the agent's own TUI through the pane, so the pane holds the real screen, prompt box and permission dialogs included. Only the name was missing.

So the launcher supplies it. Every aid launch that starts an agent exports HERDR_AGENT=<agent> for the session, naming whichever agent it picked, and dl does the same for a command whose program is an agent by name:

aid blooop/devlaunch@fix/42        # exports HERDR_AGENT=claude
aid --codex blooop/devlaunch       # exports HERDR_AGENT=codex
dl blooop/devlaunch -- claude      # exports HERDR_AGENT=claude
dl blooop/devlaunch -- make test   # exports nothing: make is not an agent

Nothing else changes and nothing has to be turned on. A herdr pane running the line above reports the workspace's agent as idle, working and blocked, tracking Claude's own screen through the container. Together with the pane title from the section above, which is already the workspace id, a manager can say both which workspace a pane is and what its agent is doing.

Why it is written every time

aid writes the variable on every launch that starts an agent, over whatever the environment already held, and without checking whether a manager is running.

Writing over is the point. aid is the thing that decides which agent starts, so it is the only thing that can be right about it. A HERDR_AGENT=codex left in a shell profile for some other wrapper is wrong the moment aid --claude runs, and a manager that believed it would match codex's rules against Claude's screen, which classifies nothing and says nothing about why.

Not checking first is the same argument. Detecting herdr would be a second thing to be wrong about, and being wrong about it fails silently, which is the failure this whole section exists to remove. A machine with no session manager pays one setenv for a name nothing reads.

A line that starts no agent writes nothing, and that includes a retired spelling dl is about to refuse. There is no session there for anyone to classify.

What dl reads, and what it refuses to guess

dl did not pick the agent, so it reads the command it was handed. Leading NAME=value assignments are the shell's rather than the program's and are stepped over, the program is compared by its last path component so /usr/local/bin/claude and claude answer alike, and everything past the program is ignored because nothing after it can change which agent starts.

Only a name on the list dl and aid share is a name it will write, which is the one part of this worth arguing about. dl <ws> -- make test could as easily export HERDR_AGENT=make, and that would be worse than the silence it gets: a manager would be told the pane holds an agent it has never heard of, look for detection rules under that label, and find none. So the list is the list of agents devlaunch knows, aid's own table is held against it by a test, and a command naming anything else is a command dl says nothing about.

It is still a reading and not a parse. A command holding a pipe or a && gets the answer for its first program, which is the one whose screen the pane holds when it starts, and a command that reaches an agent halfway through a chain is not named.

Both transports carry it, which is worth saying because the launch picks the transport and you do not. A command normally goes over OpenSSH against the alias devpod published, and when there is no alias to use yet, no ssh config, or no terminal, the same command goes as devpod ssh --command. The name travels in that child's own environment either way and on no flag, because what reads it is the manager on this host: it walks dl's descendants and reads their /proc/<pid>/environ, and the descendant it finds is the ssh or the devpod. Sending it into the container instead would name the agent to the one place that is not running it.

What this does not do

It is one manager's variable. HERDR_AGENT is herdr's name, and one of the manager-specific words in the two binaries: the others are the HERDR_TAB_ID and HERDR_BIN_PATH that the herdr tab and a new pane both read. The direction is opposite in the two cases, which is why they stay separate features. This one is a name aid writes for herdr to read, and it is written whether or not herdr is there. Those two are names herdr exports for dl to read, so their absence is the whole of the detection. A manager with an equivalent override needs that override set some other way; a manager with none is still blind, and no amount of cooperation from dl would change that.

Reading two of herdr's exports is still a small thing to know about it. The container-side half below is not: it speaks herdr's socket protocol, installs herdr's binary and writes a hook that calls herdr's own subcommands. The plan had been a facility that knew no manager's name -- a list of variables to forward and a socket to carry, with the manager-specific part left outside dl entirely. What retired it was a measurement rather than a change of mind (#549): a general container has no python3, no jq, no socat and no nc, so there is nothing in there for a generic facility to hand the socket to. Something has to speak the protocol, and the only thing that can is the manager's own binary, lent in and driven by a hook that knows its subcommands. A second manager is a second client module beside clients/herdr.rs, which is the cost that buys.

It only covers an agent named on dl's own command line. dl <ws> opens a shell, and a claude you type at that shell is a process inside the container that no host manager can see. Naming it is impossible from out here, so that case is served by a different mechanism, in the next section.

Nothing is forwarded into the container. The variable is set on the host, for the host process a manager inspects. It is not in the workspace's SendEnv permit list and no agent inside sees it.

Reporting an agent started inside the workspace

The section above covers an agent dl or aid started, whose screen the pane already holds. It cannot cover the other way of working, which is the common one:

dl blooop/devlaunch      # a shell in the container
claude                   # started in there, by hand

That claude is a process in the container. herdr walks the host's processes and finds dl, ssh and two devpods, and its own documentation is explicit that HERDR_AGENT "cannot see it if you set it only inside a VM or container". No host-side variable reaches this. So the container reports for itself:

DEVLAUNCH_HERDR=1 dl blooop/devlaunch

Set that, from inside a herdr pane, and the pane tracks whatever agent you start in the workspace: idle when it is waiting for you, working while it works, blocked when it wants a decision, and released when the session ends.

What it does, once per workspace

Four things have to be true inside the container, and the launch arranges all four.

The manager's socket has to be reachable. dl forwards it with ssh -R, over a connection of its own that lives exactly as long as the session. A connection of its own rather than a flag on the session, for two measured reasons: devpod's own -R hangs with no output for a unix socket, and a bare dl <ws> is a devpod attach, so a flag would serve one route and not the other. Being separate also keeps it out of the multiplexed control socket, where a forward outlives the trip that asked for it and is inherited by a later launch that asked for nothing.

Something has to speak the protocol. A general container has no python3, no jq, no socat and no nc, which is also why herdr's own Claude Code hook can never fire in one: its first act is command -v python3. So the herdr binary itself is lent in, the way gh and claude already are, at about 1.4 seconds for 17MB.

The coordinates have to arrive, with the socket and binary paths rewritten to the container's: HERDR_ENV, HERDR_PANE_ID, HERDR_SOCKET_PATH, HERDR_BIN_PATH.

Something has to fire. A Claude Code hook, installed at /etc/claude-code/managed-settings.json inside the container. That location is the point rather than a detail: this repo's devcontainer bind-mounts the host's ~/.claude into the container, so a hook written to ~/.claude/settings.json from in there would be an edit to your own machine's config.

The lend and the install happen at most once per workspace per version of herdr, and a launch with the variable unset costs nothing at all. A launch with it set costs one round trip: dl asks the container what it already has rather than trusting a note it wrote to itself last time. A cache keyed on the workspace id cannot answer this, because the id outlives the container. dl <ws> recreate, dl <ws> reset, a devpod delete or a rebuilt image all replace what is inside while the id stays put, and a note that went on describing the old container bought silence: dl would say it was reporting agents to your pane while nothing in there had ever heard of herdr.

What it does not do

It is one manager and one agent. The hook reports claude, because the events it hangs off are Claude Code's. Another agent needs its own hook.

A failure costs the reporting and never the session. A container whose sudo wants a password, a workspace devpod has published no alias for, a forward the container user cannot bind: each of those prints one line saying agents here will not be visible, and then opens your shell as usual. The alternative is refusing a shell because a status indicator could not be wired up.

The state is authoritative, not read off the screen. That is a gain over the host-side half, and it is also why the mechanism is pane report-agent rather than pane report-agent-session: the second reports a session id for a pane that already has an agent and does not establish one, so a container sending only those stays invisible.

Opening a new pane in the workspace its tab already holds

The two sections above are about watching a pane. This one is about working in one. Split a pane in an aid tab and you used to get a shell on this host, several namespaces away from the container the tab is about:

aid blooop/devlaunch@fix/42   # a tab, an agent, a container
# split the pane, and the new one is on your laptop

The container was always reachable. dl <ws> in any terminal is a second shell in it, and has been for as long as dl has existed. What was missing is that nothing told the new pane which workspace, so you retyped it or you lost the tab.

Point herdr's default_shell at dl-herdr-shell and the new pane asks:

[terminal]
default_shell = "/home/you/.local/bin/dl-herdr-shell"

dl --herdr-setup installs the script and writes that setting while preserving other settings and comments. dl --install still prints the line for manual setup. Run herdr server reload-config afterwards and every pane created from then on, by key, by mouse, by herdr pane split, opens in the container when its tab holds a devlaunch session and opens your ordinary shell when it does not.

Opening an editor beside an agent

Set NVIM_SPLIT to give agent launches a side-by-side editor. It is off by default:

export NVIM_SPLIT=1
aid blooop/devlaunch@my-branch

This applies to aid and to a direct dl <workspace> -- claude, codex, or gemini launch. dl waits until Herdr recognises the agent, splits its pane to the right with --no-focus, and starts the editor there. The new pane passes through dl-herdr-shell, so it enters the same devlaunch container as the agent. The agent remains the focused pane.

Only a one-pane tab is changed. If the tab already has another pane, dl leaves its layout alone, which prevents a resumed agent from adding another editor. The editor is $VISUAL, then $EDITOR, and finally nvim. Arguments are allowed, because herdr pane run takes command text, so VISUAL="nvim -p" starts nvim -p in the new pane. A variable that is set but blank, or that holds a control character such as a newline, is skipped and the next one is used, so a value the split cannot run never turns the split off. Empty, 0, false, and no disable the split; any other NVIM_SPLIT value enables it.

Why a second name, and not a flag

default_shell takes an executable, not a command string, so default_shell = "dl --herdr-shell" cannot work. Measured on herdr 0.8.2: the whole string is treated as one path and the spawn fails with

Unable to spawn /usr/bin/env FOO=1 /bin/bash because it doesn't exist on the
filesystem (ENOENT: No such file or directory)

and it fails at the moment a pane is created rather than at config load, where herdr config check still says config: ok. So the entry point has to be a name that needs no arguments.

Why a script, and why in ~/.local/bin

It was a symlink beside dl first, with dl reading its own argv[0]. That cannot work on the install this project documents, because pixi global does not symlink, it trampolines: ~/.pixi/bin/dl is a small binary that execs ~/.pixi/envs/devlaunch/bin/dl, and it replaces argv[0] with the target's own path on the way. Measured with a trampoline pointed at /bin/sleep: the child's /proc/<pid>/cmdline reads /bin/sleep 30, not the name that was run.

Two failures came out of that. current_exe() answers the environment path, so the link landed in ~/.pixi/envs/devlaunch/bin, which is off PATH and is a directory pixi global update rebuilds, after which default_shell names a path that is gone and every pane on the machine stops opening. And a link placed in ~/.pixi/bin by hand fails the other way: the trampoline rewrites argv[0], the name is never seen, and every pane silently gets a host shell.

So the installed thing is a two-line script that runs dl --herdr-shell, nothing reads argv[0], and it goes in ~/.local/bin, which is the user's own and which nothing but the user rewrites. The script resolves dl through PATH rather than by absolute path, for the same reason: an absolute path is what an update invalidates.

It falls through to a shell itself when dl cannot be found, because a default_shell that will not start is the one failure that costs a pane its existence rather than its container.

dl --install writes the script and leaves the config alone. dl --herdr-setup uses $HERDR_CONFIG_PATH when set, otherwise $XDG_CONFIG_HOME/herdr/config.toml, or ~/.config/herdr/config.toml when XDG_CONFIG_HOME is unset. If chezmoi manages a regular config file, setup refuses the edit and names the source file. Change terminal.default_shell in that source, apply it, and run dl --install to install the launcher. A symlink is different: setup preserves the link and updates its target.

A chezmoi that cannot answer is refused too, rather than read as "unmanaged". chezmoi source-path <file> exits nonzero both for a file chezmoi does not manage and for a chezmoi that is broken, so setup asks a second question, bare chezmoi source-path, which prints the source directory when chezmoi is healthy and fails with the same complaint when it is not. Only a healthy chezmoi saying nothing manages the file lets setup write. The refusal repeats what chezmoi said. One case stays invisible: a source directory that has been moved or deleted makes chezmoi itself report every file as not managed, and nothing downstream can tell that apart from the truth.

Re-running setup preserves an already current file. A custom default_shell is refused with the manual replacement instruction, rather than silently replacing the user's launcher.

Workspace environments and Claude logins

Run setup on the host once, then select an existing login in each Herdr workspace:

dl --herdr-setup
herdr server reload-config
dl --claude-profiles
dl --herdr-env profile work

New tabs and splits read the saved setting, including panes opened through Herdr's keyboard shortcuts. Host Claude processes use that profile's configuration directory. Container panes use devlaunch's existing Claude token forwarding. No credentials are copied, no browser login is automated, and running shells or agents keep their account. A named profile directory must already exist. Sign in with Claude itself if needed.

The command also stores ordinary host environment variables:

dl --herdr-env set RUST_LOG=debug
dl --herdr-env unset ANTHROPIC_API_KEY
dl --herdr-env show
dl --herdr-env profile default
dl --herdr-env clear
dl --herdr-workspace w2 --herdr-env profile personal

set accepts one literal KEY=VALUE, and unset removes that variable from new shells even if the Herdr server exported it. show prints saved overrides as JSON. clear removes all overrides and restores the server's inherited environment. profile default removes saved Claude configuration and authentication overrides, retains other variables, and uses the server's default Claude environment. That includes a custom CLAUDE_CONFIG_DIR. It never selects a directory named default under the profiles root. Existing shells keep their old environment until reopened.

A named profile removes inherited CLAUDE_CODE_OAUTH_TOKEN, ANTHROPIC_API_KEY, and ANTHROPIC_AUTH_TOKEN so they cannot silently select a different account. For a new container pane, the saved profile takes priority over the profile found in a sibling pane. An explicit dl <workspace> --claude-profile NAME keeps its normal precedence. Generic variables affect the host shell and the dl process; container forwarding still follows devlaunch's existing rules, so arbitrary variables such as RUST_LOG are not forwarded into the container.

Run environment management commands in a host Herdr shell. IDs belong to the current Herdr server; --herdr-workspace targets another workspace on that server. Overrides live under $XDG_STATE_HOME/devlaunch/herdr-environment, falling back to ~/.local/state/devlaunch/herdr-environment. Each server socket path and workspace ID has its own file. Writes are locked and atomic with private file permissions. Herdr's IDs and socket path define persistence, so reusing both reuses the saved overrides. clear removes them when a workspace no longer needs them.

HOME, XDG_*, HERDR_*, CLAUDE_PROFILES_DIR, and DEVLAUNCH_CLAUDE_PROFILES_DIR cannot be overridden. Keeping those selectors stable means commands from new shells can still locate and clear their saved settings. Malformed state and a disappeared selected profile stop the pane launcher with an error rather than silently opening Claude under a different account. clear can recover malformed state. Values are stored as plain text; do not use this file as a credential store.

If you used the earlier herdr-workspace-env and herdr-workspace-shell scripts, setup recognizes ~/.local/bin/herdr-workspace-shell and replaces its config entry. Reapply each workspace's settings with dl --herdr-env before reloading Herdr. The old scripts and state are left in place, but the new launcher does not read them. No particular account or workspace names are built into devlaunch.

Container destinations follow live panes

The pane shell reads HERDR_TAB_ID out of its own environment, asks herdr which panes that tab holds, and asks what each of them is running. A tab is devlaunch's if one of its panes has a dl transport in its foreground right now:

transport what the pane is running
a bare dl <ws> devpod ssh <workspace-id> ...
dl <ws> -- <cmd>, which is every aid ssh ... <workspace-id>.devpod ...

Both are argvs dl itself builds, so reading the workspace out of them is dl reading its own writing. herdr publishes a pane's whole foreground chain, which is the same reading it does to decide which agent a pane holds, and the transport is in there under the dl that started it.

The alternative was a note kept against a tab id, written at launch. It is a smaller lookup and it is wrong in a way that has no floor: a tab whose session has exited, or that you reused for something else, goes on naming a workspace nobody in it is in, and every pane you open there lands in the wrong container. Reading it back live has no such state to be stale. A tab whose session has ended answers "no workspace" the moment it has ended.

The cost is one pane list and one process-info per pane in the tab, which is usually one to three round trips on a unix socket. The whole of it is bounded at half a second.

What it does not do

A failure costs the container and never the pane. No herdr, no tab id, a socket that will not answer, an answer that is not JSON, a herdr that is not installed, a timeout: every one of them opens the shell you would have got anyway. This sits in front of every pane on the machine, so there is no failure it may turn into a pane that does not open.

It is one manager's variable. HERDR_TAB_ID is herdr's, and so is the default_shell field. A manager that spawns a program per pane and identifies the tab in its environment would need a client module of its own, which is the same answer the section above gives.

A pane outside a devlaunch tab costs nothing at all. With no HERDR_TAB_ID in the environment, no herdr is spawned and no question is asked, which is the ordinary case for most panes on a machine that has one.

It does not go the other way. Something inside the container asking herdr to open a pane is a different feature, and the socket DEVLAUNCH_HERDR forwards is for reporting rather than for control.

The pane's shell is a shell. It is not an agent, and nothing is typed into it. A claude you start in there is covered by DEVLAUNCH_HERDR, exactly as one started in any other devlaunch shell is.

The shared pixi package cache

Every container dl creates gets one host directory bound into it, and PIXI_CACHE_DIR pointed at it, so that dotfiles which provision their tools with pixi global sync download each package once per machine instead of once per container:

On the host $XDG_CACHE_HOME, or ~/.cache, then devlaunch/pixi
In the container /var/tmp/devlaunch-pixi

Measured on the profile this was built for, 23 pixi-global environments: a container with a cold cache spends 62s to 113s and downloads 1.2 GB; one that finds the packages already there finishes in 18s to 28s and fetches nothing. Two containers syncing against it at the same time is fine, since the downloads are content-addressed and rattler takes a lock per package.

Deleting it is always safe, at any moment, including while containers are running. It holds nothing but downloaded package archives, every one of them re-fetchable from the network, and none of them referenced by a path anything inside a container has stored. The worst a deletion costs is the next container's download.

rm -rf ~/.cache/devlaunch/pixi

dl --purge takes it away with the rest of ~/.cache/devlaunch/, for the same reason.

Two things it deliberately is not. It is not the host's own ~/.cache/rattler/cache: containers write into it as their own remote user, whose uid only happens to match yours, and a pixi clean cache you run for your own reasons must not be able to pull packages out from under a running container. And it is not a shared PIXI_HOME. The installed environments and their trampolines are baked with absolute paths, and two containers sharing one environment tree is pixi#5476. Only the download cache is shared, which is the part that is safe to share.

That is why a project environment installed inside an agent worktree is a real copy and costs what it costs, and it is why dl --prune reclaims those copies rather than pointing them at anything: see "The regenerable subtrees inside a worktree it keeps" in cleanup.md.

If the directory cannot be created, or is not there when the launch reaches it, because of a full disk, a read-only cache home, or a cache swept between the two, the launch goes ahead without the mount and the container downloads its own packages, exactly as it did before this existed.

Sharing requires the container's user to be able to write the directory, which in practice means its uid matches yours or it is root. The mount carries host ownership through unchanged, and pixi does not degrade to reading a cache it cannot write: pointing PIXI_CACHE_DIR at a directory owned by another uid fails the install outright (Permission denied on the repodata, exit 1) even when every package it wants is already in there. So an image whose remote user is neither root nor your uid does not merely lose the sharing. Its pixi global sync fails, and its tools do not get provisioned.

dl cannot see the container's uid before it launches, so it cannot decide this for you. In practice the common case is safe: every mainstream base declares a remote user at uid 1000, which is the first human user on a Linux host. If you hit the failure, the fixes available to you are to run that image as your own uid, or to take the cache out of play for it (rm -rf ~/.cache/devlaunch/pixi recovers a directory an earlier container left owned by someone else).

The case that is not a developer's machine is CI. What makes the common case safe is that uid 1000 is both the base image's remote user and the first human user on a Linux host, and on a hosted runner it is only the first of those. A GitHub runner's own user is somebody else, so a launch there hits this on its first container and every container after it. This repo's own launch benchmark did exactly that for twenty consecutive merges to main: failed to create directory /var/tmp/devlaunch-pixi/pkgs: Permission denied, from the benched repo's pixi install, before anything was timed.

Where you know the uid you are handing the directory to, there is a third fix the list above does not offer, and it is what .github/workflows/bench.yml now does. Create the directory yourself and widen it, before the first launch:

mkdir -p ~/.cache/devlaunch/pixi && chmod 1777 ~/.cache/devlaunch/pixi

dl's own mkdir does not re-mode a directory it finds, so the mode survives every launch after it. 1777 is what /var/tmp carries at the other end of the same mount, and it makes the same trade: every uid can write, and the sticky bit means none of them can unlink another's entries.

Where the tools themselves land

The cache above is shared; the environments devlaunch installs into are not, and they are not the container's ~/.pixi either. gh, claude and zellij go into ~/.devlaunch/pixi, a pixi home of devlaunch's own, because pixi global install is not only an install. It is an edit to $PIXI_HOME/manifests/pixi-global.toml, a declarative file that in a container already has an owner. Writing there made devlaunch a second author, and cost something in both directions:

  • pixi global sync removes every environment the manifest does not list, so a dotfiles apply that rewrites the manifest and syncs uninstalls the zellij devlaunch just installed, and the next launch reinstalls it, forever.
  • The manifest is not always a file. A devcontainer is free to symlink ~/.pixi/manifests/pixi-global.toml onto a tracked file inside the checkout, and one does; the append then landed in the work tree and every git status in the workspace came up dirty.

Neither is expressible against a home devlaunch created: nothing syncs that manifest, and no repo state can sit under that path. It costs a duplicate extracted prefix in the one case where a tool is installed but unreachable from a login shell, disk only since PIXI_HOME does not move the download cache, and that is the case where the old behaviour reinstalled on every launch anyway.

Not ~/.local/share/devlaunch/pixi, which is the conventional path and the wrong one here: containers bind-mount ~/.cache, ~/.config and ~/.local/share straight from the host, so a prefix tree under one of them would be shared by every container on the machine and written into your own home. That is pixi#5476 again, the hazard the cache mount is careful to keep PIXI_HOME away from.

PIXI_HOME is set only for devlaunch's own install scripts, never exported into the login profile, so your own pixi global install in a workspace still goes to your own ~/.pixi. Only the bin directory goes on PATH.

Existing containers, and what a recreate is for

A mount lands only when a container is created. devpod re-applies --workspace-env on every up, but it will not add a bind mount to a container that already exists; passing --mount there is a silent no-op. So a container built before this feature, or before a change to where the mount lands, keeps whatever it was created with until dl <workspace> recreate, and only then picks the current arrangement up.

In between, PIXI_CACHE_DIR points at /var/tmp/devlaunch-pixi with nothing mounted on it. That is a working private cache, not a failure: /var/tmp is world-writable in every image, so pixi creates the directory and fills it. The container re-warms itself and simply never shares, abandoning whatever pixi had already warmed in its default location. This is the reason the container-side path is under /var/tmp rather than somewhere tidier like /var/cache: a target whose parent is root-owned is a hard pixi global sync failure on every container that predates it, not a lost optimisation.

One older breakage needs the recreate rather than a restart. Devlaunch briefly mounted this cache inside ~/.cache, which left that directory root-owned in any image that ships no ~/.cache of its own. $HOME lives on the container's own layer, so dl <workspace> stop and a fresh up keep the root-owned directory; dl <workspace> recreate gets a new layer where ~/.cache is the user's own again.

Where the dotfiles setting comes from, and how to see it

dl forwards --dotfiles and --dotfiles-script to devpod up from devpod context options and from nowhere else. Not from the process environment: exporting DOTFILES_URL in your shell sets nothing. Not from ~/.devpod/config.yaml either, which dl only ever stats, to decide when its own copy of those options has gone stale. And ~/.cache/devlaunch/context-options.json is that copy rather than an input, so hand-writing devpod's nested shape into it sets nothing and reports nothing: the read fails silently and dl asks devpod, which is the only answer that ever counted.

devpod context set-options -o DOTFILES_URL=https://github.com/you/dotfiles
devpod context options --output json      # the one question dl asks

Every devpod up dl runs now says which of the two it did, so the flag and the line cannot disagree:

dotfiles: https://github.com/you/dotfiles (devpod context options), passed to devpod up; devpod installs them when it creates the container.
dotfiles: none set in devpod context options, so this up asked for none. 'devpod context set-options -o DOTFILES_URL=<repo>' is the only place dl reads it from.

An attach that runs no up prints neither, because it asked devpod for nothing either way.

Refreshing dotfiles on attach

Under chezmoi, the refresh is chezmoi update. If that fails and the chezmoi source directory is a git repository, it regenerates the workspace's chezmoi.toml with chezmoi init and tries once more. That second attempt is for one failure in particular: the dotfiles repo grew a template variable the workspace's config predates, so every apply dies rendering a config that has no entry for it. It is guarded on the source directory already being a repository because chezmoi init with no repo argument would otherwise create an empty one, which has no upstream and can never update again.

devpod applies dotfiles when it provisions a workspace, so a workspace that has been up for a fortnight still has the dotfiles it was born with. dl <ws> dotfiles fixes that when you think of it; DEVLAUNCH_DOTFILES_ON_ATTACH=1 makes dl think of it for you, running the same refresh just before it hands you the shell.

DEVLAUNCH_DOTFILES_ON_ATTACH=1 dl someone/repo

It is off unless you set it, and that is the point rather than caution. The refresh is a devpod ssh round-trip, measured at ~1.7s and almost all of it connection setup, with a git pull behind it, and it would otherwise be charged to every attach on every machine to close a gap most people do not have.

Two things it deliberately does not do:

  • It never runs for dl <ws> -- <command>. A one-shot command renders no prompt and sources no interactive shell, so refreshing in front of it would buy that command nothing and cost it the round-trip. That path is the one agent launchers use, and it stays exactly as fast as it was.
  • It never holds the shell hostage. The refresh gets 60 seconds; an unreachable dotfiles remote, or one that wants a password nobody is there to type, means a pause and then your shell, not a hang. Failure is a warning, and you get the workspace either way.

Refreshes run every time you attach, with no cooldown, because you asked for them. If that is too often, unset the variable and use dl <ws> dotfiles.

Shell completion

After running dl --install, tab completion offers:

  • Workspace names from your devpod list
  • Known GitHub owners and repositories from your workspaces
  • File/directory paths when starting with ./, /, or ~
  • All global flags (--ls, --install, etc.) and workspace commands

A flag before the spec does not move it

Both command lines take flags ahead of the workspace, and the completion reads them rather than counting words:

$ aid --codex kin<TAB>
kinisi-robotics/
$ dl --devcontainer robot kin<TAB>
kinisi-robotics/

Counting is what this used to do, and it put the spec at the second word alone, so every one of those lines completed nothing. The rule each command actually follows is different. aid reads its leading flags and calls the first word that is not one the spec, which is parse_aid_args, and dl is a clap grammar, which lets an option sit anywhere among the positional words.

The flags a spec may follow are the ones that modify a launch: --rm, --devcontainer and --claude-profile for dl, and for aid those two plus the agent flags and either polarity of --remote-control. Every other flag ends the line, and nothing is offered after one:

$ dl --ls <TAB>
$ dl --json <TAB>
$ dl --repos <TAB>

Listing the flags a spec may follow, rather than the flags that end the line, is the load-bearing choice. The flags nobody thinks to list are all on the ending side, and each of them refuses a workspace for a different reason: --repos answers "--repos takes no workspace", --json is an error about the missing --ls it requires, --yes is refused as meaningless for a workspace command, and a leading --force is not a modifier at all but the workspace slot itself, so dl --force my-ws answers "Unknown workspace '--force'". Tabbing to a name and then being refused for a word you never typed is worse than no completion, which is the same bar the profile names are held to under Naming a profile.

Once one modifier is on the line the only flags still offered are the other modifiers, since dl --rm --ls is refused and aid --codex --help is not aid's help but an unknown option handed to dl.

Owners come before workspace ids, and why

The first word of a dl line can be two different things, a spec or the id of a workspace you already have, and for one repository they can start with the same letters. A workspace id is <repo-slug>-<ref-slug>-<suffix>, and the slug turns _ into -, so the repository kinisi-robotics/kinisi_ros produces ids that all begin kinisi-ros. That is nine characters of the owner's own name, kinisi-robotics. Offer both as one list and bash completes to the longest prefix they share and stops:

$ dl kin<TAB>
kinisi-ro
kinisi-robotics/                            kinisi-ros-nb2-lobijate
kinisi-ros-feature-robot-id-dahenego        kinisi-ros-remove-pins-tihagada

It takes one repository to do that. There is no fork involved and no second owner: the two names in the way of each other are an owner and the ids of its own repository, so typing more does not help until the spellings diverge.

Owners are offered first now, and workspace ids only for a prefix no owner matches:

$ dl kin<TAB>
kinisi-robotics/
$ dl kinisi-robotics/<TAB>
kinisi-robotics/kinisi_ros

The owner wins the tie because it continues. A / is the next keystroke and the repository completes from there, so a spec that matches is always on the way somewhere, while an id is a finished word. Ids are held back rather than dropped, and one keystroke brings them back: dl kinisi-ros<TAB> matches no owner, so it offers the three ids and nothing else. An id copied out of dl --ls still completes from any prefix long enough to leave the owner behind, and dl <id> is still a way to name a workspace.

The hold-back applies only to a prefix. dl <TAB> with nothing typed is not a collision to resolve, so it lists both, the way it always did. An id typed out in full is offered too, beside the owner: DL_WORKSPACES is every devpod workspace, including names devpod was given by hand rather than derived by dl, so a workspace really can be called blooop on a machine whose cache knows the owner blooop. For that one no longer prefix exists, and holding it back would not delay it, it would hide it.

Nothing about the cache changed, so a completions.bash written by an older dl works with this script and the other way round.

How the completion cache stays current

The data behind completions lives in ~/.cache/devlaunch/completions.json, and building it means a git ls-remote per known repo, which is seconds of work. So it is rebuilt in the background at most once an hour (the same interval the background fetch sweep uses, see How fresh a launch is), and at most once per dl invocation. Commands that change your workspaces, by starting, stopping or deleting one, rebuild it as soon as they finish, regardless of when it was last built. Commands with no use for it, dl --help and dl --version, do not touch it at all.

A branch created on a remote in the last hour may therefore not be offered yet. dl --refresh rebuilds the cache immediately and ignores the interval.