README has the commands you need. This page is the rest: how the
selector decides what you picked, which verb refreshes git state and which only
touch the container, which commands get a terminal, what --rm promises and where
it stops, which exits fire it, the spellings that were retired and what they say
now, which full-auto flag aid starts each agent with and why codex gets the one it
gets, what aid's Remote Control default starts and how to turn it off, what
kill does to a workspace that will not answer, and what happens when devpod is
missing, will not answer, or injects the wrong agent binary.
The selector is built in. There is no fzf on PATH and no iterfzf, which is
why there is nothing to install for it, and why dl with its input redirected
away from a terminal simply declines to open one.
It is a table. Each row is <owner> | <repo> | <branch>, under headings that say so:
Select workspaces (type to filter, TAB to mark several):
OWNER | REPO | BRANCH
blooop | devlaunch | main
blooop | wayfinder | wayfinder/devlaunch-467
myfork | bencher | fix/thing
- | someones-project
The owner and the repo are read off the clone's place in dl's layout,
<cache>/repos/<owner>/<repo>/<id>, and the branch is read out of the clone's own
HEAD. The hashed suffix does not appear: it is there to keep two branches from
sharing an id, and reading it is no part of choosing a workspace.
Every cell is padded to its column's widest entry, the heading counted as one of
them, so a column starts in the same place on every line. The headings are drawn in
the picker's header rather than offered as a row, which is what keeps them from
being filtered away, marked with TAB or picked. A column is named exactly when some
row has something in it, so a list of nothing dl cloned is headed OWNER | REPO and
stops there.
The branch is the one checked out now, not the one the workspace was made for,
so a git switch inside a container shows up here. The columns used to be recovered
by taking the id apart instead, which could only answer with a slug: feature/auth
read as feature-auth, indistinguishable from the branch of that name, and a long
one read short. Reading HEAD costs one small file and answers exactly.
The row is still three columns rather than owner/repo@branch, because that reads
like something you could retype and the picker is not a place to retype anything. To
act on what you picked, pick it.
Two rows can still be drawn alike, when two workspaces of one repository sit on one branch. The id-scheme migration leaves exactly that pair for a while: the renamed clone under its new id, and the container still on the old one. When it happens both rows gain a fourth column holding their whole id, and the table gains the heading for it:
OWNER | REPO | BRANCH | WORKSPACE
blooop | devlaunch | main | devlaunch-main-3j1t
blooop | devlaunch | main | devlaunch-main-legacy
The row's own text is how dl knows which workspace you picked, so two rows reading
the same would be one workspace deleted in place of another, and the id is what
settles it. It is appended rather than replacing the columns, and that is the
correction to what this used to do: both rows collapsed into their bare ids, which
took the branch off screen to fix an ambiguity the branch never caused. Both of those
rows are on main. Picking between two ids is harder than picking between two rows
that say main and carry a tiebreak, and dl rm is one of the verbs this opens for.
Only the rows that collide grow the column. A third workspace of the same repository on another branch keeps its three.
A row whose clone is not on disk shows its whole id in the repo column and stops
there, since there is no HEAD to read and so no branch to draw. That is the honest
answer: the workspace's source is gone. It sits in the column rather than running on
past it, which is what puts every row on one grid, and the price is that a long name
widens the repo column for the rows around it. REPO heads it either way, and that
is the one place the heading is looser than the cells under it.
Nothing here is short of room, and that is why the picker spends it differently from the terminal tab. A row is read one at a time down the terminal, so the branch is spelled in full, slashes and all. A tab is a handful of characters read at a glance next to a dozen others, so it takes the truncated slug and drops the suffix. Same workspace, two jobs.
Every verb on this page acts on a container. rm is the only one that refreshes
git state, because it deletes the clone along with the workspace and the launch
after it is a cold one. restart, recreate and reset all reach a workspace
devpod already knows, and that path runs no git at all: no fetch, no ref update,
no checkout. So a container can be rebuilt repeatedly from a checkout that never
moves, and reset in particular is a clean slate for the container and its
volumes rather than for the code in it.
dl reports the part of that you cannot see. Launch owner/repo@branch against a
workspace devpod already has and, when the checkout is behind the origin/<branch>
that clone last fetched, the attach says how far behind before it hands over the
shell. How fresh a launch is is the whole
of the freshness rules, and the section under it names which verb moves what.
The command and its arguments, one word each. dl quotes every word on the way
into the remote payload, so a quoted argument stays one argument: dl <ws> -- claude 'fix the bug' runs claude with a single argument, and a word holding a
space, a #, a $(...) or a backtick is that word rather than shell syntax.
It has to be built that way because the payload is one bash -lc <line> for both
transports. The words used to be rejoined with plain spaces, which gave the remote
shell back every separator your own shell had already consumed: quoted arguments
were re-split, a # commented out the rest of the line, and a $(...) ran.
One exception, and it is deliberate. The quoting leaves a word alone when it needs
none, and = counts as needing none, so a leading NAME=value still reaches the
shell as an assignment prefix and sets that variable for that command only. That
is what makes dl <ws> -- IS_SANDBOX=1 claude ... work, which is the spelling
aid uses and the one the README shows.
The exception ends where the quoting begins, and it ends abruptly. It is the
whole word that has to need no quoting, value included, so FOO=bar is an
assignment and FOO='a b' is not: the value's space makes the word
'FOO=a b', and a shell reads a quoted word as a program name, so the command
exits 127 with the variable never set. Values made of [A-Za-z0-9_@%+=:,./-]
are the ones that survive. For anything else, name the shell and write the
assignment inside it: dl <ws> -- bash -lc 'FOO="a b" cmd'.
A shell snippet is a command like any other, so name the shell: dl <ws> -- bash -lc 'a && b'. Redirections and pipes typed on your own command line belong to
your own shell and never reach dl, which is what makes dl <ws> -- ls > files.txt write the file here.
dl <ws> -- <command> gives the command a terminal whenever dl itself has one,
so interactive programs start and stay up instead of exiting immediately. A coding
agent, htop, git rebase -i, a REPL. Redirect the output and the terminal
goes away again, so dl <ws> -- ls > files.txt stays free of escape sequences.
This needs the ssh host alias devpod up writes. Where it writes it is devpod's
choice and dl follows it: SSH_CONFIG_INCLUDE_PATH from devpod's context
options, else $DEVPOD_SSH_CONFIG, else the SSH_CONFIG_PATH context option,
else ~/.ssh/config. devpod writes to whichever of those it picks and to no
other, so a host that exports DEVPOD_SSH_CONFIG has no ~/.ssh/config for dl
to read. The context options are read from the copy dl has already cached, never
by asking devpod again, because that question costs more than the terminal it
decides.
That same file is then handed to OpenSSH as -F <path>, because OpenSSH reads
none of the above: it resolves ~ through getpwuid, so the config dl decided
from has to be named on the command line or the alias does not resolve at all.
One consequence worth knowing: a session over this transport is built from that
file alone. /etc/ssh/ssh_config never applies, because naming any file with -F
makes OpenSSH skip the system config, and that holds even when the file named is
your own ~/.ssh/config. A Host * block of your own applies only while devpod
publishes into the file dl names, so it drops out as soon as devpod publishes
elsewhere.
If a workspace has no alias, dl says so and falls back to the plain devpod ssh
transport, which has no terminal; dl <ws> restart republishes the alias. If
there is no ssh config at all, dl says that instead and names the file it looked
in, and the advice it gives is qualified: a restart publishes there only if devpod
writes to that same file, so a notice that comes back means DEVPOD_SSH_CONFIG or
one of devpod's ssh-config context options names a different one. Set
DEVLAUNCH_NO_TTY=1 to force the fallback everywhere.
A terminal is not only a stream. A full screen program switches modes on in the emulator for its own use, the kitty keyboard protocol, bracketed paste, mouse reporting, the alternate screen, and is expected to switch them off again on the way out. One that is killed never gets the chance, and those modes live in your terminal rather than in the connection, so nothing between the program and the glass undoes them.
That is the failure you see when a container goes away underneath a live session.
devpod reports error tunneling to container: exit status 137, the agent inside
dies unceremoniously, and what is left behind is baffling rather than obviously
broken: ssh restores the tty settings on its way out, so the shell still echoes
and still edits lines, and yet Ctrl-C does nothing and ordinary keys print things
like 9;133u at the prompt. Those are kitty keyboard protocol key reports, still
switched on. Ctrl-C is one of them, arriving as an escape sequence instead of as
the byte that raises SIGINT.
dl is the last process holding that terminal, so dl repairs it. Every session
ends with a short restore written to the terminal, over either transport, and the
interrupt handler writes the same thing before it exits. It goes out after a
clean session too, because ssh's exit status does not say whether the far end
cleaned up after itself. That costs nothing: every sequence in it is chosen for
doing nothing when the mode it names is already off. The one obvious candidate
that is not, ESC [ ? 1049 l, is left out on purpose, because it restores the
cursor as well as the screen and would scramble the display on every ordinary
exit.
Nothing is written when dl's own output is not a terminal, so dl <ws> -- ls > files.txt keeps its output free of escape sequences. If you are ever left with a
wrecked terminal some other way, printf '\033[<u' undoes this particular one
and reset undoes everything.
--rm deletes the workspace once the session ends, the way docker run --rm does. It
applies to the two forms that hand a session over and come back from it:
dl kinisi/repo@fix/x --rm # shell; the workspace goes when you exit
dl kinisi/repo@fix/x --rm -- make test # one command, then the workspace goes
aid kinisi/repo@fix/x 'fix the flaky test' --rmThe word and the flag are docker's two commands, not two spellings of one.
docker rm deletes a container now; docker run --rm deletes one when what it ran
has finished; and no docker subcommand takes a --rm meaning the first of those. Here
too: dl <ws> rm deletes now, dl <ws> --rm deletes after, and neither has to be read
twice to work out which was meant. --force follows docker as well. It belongs to
dl <ws> rm --force, never to --rm.
It stops at work that is nowhere else. The removal is dl <ws> rm's, guard included,
so a clone holding uncommitted or unpushed work, or one git could not read to find out,
refuses, says which, and leaves the workspace standing:
--rm: the session has ended, removing kinisi/repo@fix/x.
kinisi-repo-fix-x-1a2b holds 1 uncommitted change(s) (scratch.txt). Push or commit it,
or run: dl kinisi/repo@fix/x rm --force
That is what makes it safe to leave on a line you recall: the flag never decides that
your work was disposable. For the same reason --force does not compose with it. A
--force habitually appended to a recalled --rm line would destroy work hours
later, unattended, with nobody reading the sentence explaining it. Run
dl <ws> rm --force when that is what you mean.
A build that failed is collected too. The removal runs whenever the launch got as
far as asking devpod for the workspace, including when devpod up died in
postCreateCommand, which leaves the container running and the clone cut. That is
the case an unattended dl owner/repo --rm -- make test in CI most needs covered.
A launch that stopped earlier, an unknown workspace, a branch that could not be named,
a devpod that would not run, created nothing, so nothing is removed and nothing is
said about it.
Three more things it does not promise:
- The exit code is the launch's.
dl repo --rm -- make testexits with the test's status, and a failed build exits with devpod's; a removal that refused is never what the code reports. The refusal is on stderr and the workspace is still there. - It is best-effort, by construction. Ctrl-C out of a session is not one of the gaps, though. See "How you exit decides whether it fires" below.
- It does not know about your other shells. Nothing serialises two sessions on
one workspace, since the launch lock covers the build rather than the session, so a second
dl <ws>in another terminal is attached to the same container, and the--rmrun exiting first removes it from under that one. Use--rmfor the workspace you opened to throw away, not for one you may already be sitting in elsewhere.
On an aid line it is appendable, and it keeps the prompt: recall the line, type
--rm at the end, and the agent still runs, with the workspace going when it is done. That
is the shape a shell makes cheap, appending to the previous line rather than editing
the front of it. Note that a -- command tail is not appendable this way. Everything
after -- belongs to the workspace's command, so a --rm typed there is an argument
to that command.
The removal runs when dl gets control back, so what matters is whether your exit ends
the session or kills dl.
Ctrl-C out of the program you were running: fires. Both session transports allocate
a pty. A bare dl <ws> runs devpod ssh <id>, and dl <ws> -- <cmd> on a terminal
runs ssh -t. That puts your local terminal in raw mode and clears ISIG, so Ctrl-C is
a byte travelling to the remote pty rather than a signal to dl: the program inside the
container gets the interrupt. So aid repo 'fix it' --rm and Ctrl-C twice to leave
Claude Code ends the remote command, ends the session, and the workspace goes. In an
interactive shell Ctrl-C just hands you a fresh prompt, and exit or Ctrl-D is what ends
that session, either of which fires the removal.
These do not fire, because dl itself takes the signal and its handler cannot run a
removal (a signal handler may not allocate or lock, and this one _exits):
- Ctrl-C during the clone or the container build, before any pty exists.
kill <dl>from another shell, and a supervisor or CI runner cancelling the job.- Closing the terminal window.
What all three do run is the cleanup the removal is not: the staged plaintext
GH_TOKEN file is unlinked and the devpod up child is killed, so none of these three
leaves a credential on disk or a build running behind you. The one exception is a run
whose SIGTERM was disarmed before it started. The drain fells the build with a
killpg(…, SIGTERM), so disarming that signal disarms its own reach into the child too.
Ctrl-\ (SIGQUIT) is not one of them and still does mean "die now and dump core", where
tidying up first is not what it asks for. The workspace is what stays, still there
under its name, and dl <ws> rm is how it goes.
They are told apart by the exit code, which is 128 + the signal number: 130 for
Ctrl-C, 143 for a kill, 129 for a closed terminal.
Two of the three can be switched off in the ordinary way, and one cannot. If a SIGTERM
or a SIGHUP was already set to be ignored when dl started, which is what
nohup dl … does to SIGHUP, that stays ignored and ends nothing, so nohup still
outlives the terminal it was started from. Ctrl-C is not switchable like that, and
that is deliberate rather than an omission: a shell script backgrounding a job (dl … &)
hands its child an ignored SIGINT whether or not anyone wanted one, so honouring it there
would quietly stop the cleanup for every dl run from a script or a CI step. Ctrl-C
behaves exactly as it always has.
One-line check for your own setup: start dl <ws> --rm and press Ctrl-C once. A
fresh prompt inside the container means Ctrl-C is being forwarded and the removal will
fire when you leave. Landing back on the host means it reached dl, and it will not.
Those two forms and no others. Every verb word refuses the flag rather than ignoring it,
and code is the one worth knowing about: it returns while VS Code is still connecting,
so honouring --rm there would delete the container out from under a window that is
still opening. restart, recreate and reset do end in a session and would work, but
they are out too, because --rm is the throwaway workspace and not a cleanup modifier
on every verb that ends in a shell.
dl <ws> rme is the rm verb with one thing added at the end: on a removal that
worked, it sends SIGHUP to whatever started dl. For the line it exists for that is
an interactive shell, so the shell ends and the terminal tab it was sitting in closes
on its own.
dl blooop/devlaunch@fix/x rme # delete it, and the tab goes with it
dl rme # pick, TAB to mark several, then the tab goesIt is for the tab opened for one workspace. The delete is a container teardown, which
is seconds and sometimes rather more; the exit after it is a keystroke you are only
there to type. rme is the pair as one word.
The removal is rm's, and so is everything that can stop it. Same guard, same
--force, same refusals, same exit codes. The hangup is reached only when the removal
itself came back clean, and the reason is the one thing the terminal is still needed
for: every way this can go wrong writes a sentence to stderr, and closing the window
that sentence was written to is a guaranteed way for nobody to read it.
--force is the one exception, and it is rm --force's hazard with the receipt's
reader removed. --force passes devpod's own --ignore-not-found, so a workspace
that was never there counts as deleted, and a ./path target is resolved without
asking devpod anything. So dl ./wrong-directory rme --force deletes nothing,
succeeds, and closes your terminal, taking with it the Workspace <id> is gone. line
that rm --force prints instead of Removed for exactly this reason. It is left
standing rather than special-cased: absence is what --force asks for, and the
ordinary forced run is a real workspace whose uncommitted work you have decided
against, which is the run most in need of the tab closing. Type the path carefully,
or drop --force and let the guard resolve it.
$ dl devlaunch-dirty rme
devlaunch-dirty holds 1 uncommitted change(s) (scratch.txt). Push or commit it,
or run: dl devlaunch-dirty rme --force
$ # still here, and so is the workspace
A batch is one hangup. dl rme with five rows marked removes all five and then hangs
the shell up once, when the last of them has gone, which is the wait the verb saves
most of.
What it hangs up is dl's parent process, whatever that is. There is no way to
ask whether a parent owns a terminal, so dl does not guess: it signals the process
that started it and names the pid on the way past.
Hanging up the shell dl was called from (pid 48213).
Which process that is depends on the shell, not on the line, which is the reason
the pid is printed at all. A shell running a single command in a subshell usually
replaces the subshell with it rather than forking, so $(dl <ws> rme) signals the
shell that typed the line and your terminal closes after all. Write the same line
with a redirection, or with a VAR=x prefix, and the subshell survives to take the
signal instead. Both measured, on bash 5 and dash. In a script it is the script's
shell that goes, and your terminal is untouched.
Two endings print instead of the signal, and both say the removal is done: a parent
that had already exited by the time dl looked prints rme: dl's parent process has already gone, so there is no shell to hang up. The removal is done., and a signal
the OS refused prints the pid and the error beside it.
nohup dl <ws> rme is refused outright, and prints rme: SIGHUP was already ignored when dl started, so the shell stays. The removal is done. A nohup sets
SIGHUP to SIG_IGN and execs in place, so the parent rme would signal is the
terminal nohup was typed to outlive. dl already honours that inherited ignore
for its own signal handling, which is what lets a nohup dl … survive a closed
window at all (see How you exit decides whether it
fires), so sending the signal it refuses to
act on would be dl arguing both sides. The removal still happens.
One thing to know before reaching for it. A shell hung up this way takes its
background jobs with it, because that is what a shell does on SIGHUP. Use rme in
the tab that has nothing left in it, which is the tab it was written for, and rm in
the one you are still working in.
Both moved because --rm changed meaning, and both are still recognised so that a
line recalled from history says what happened instead of quietly doing something else:
$ dl <ws> --autorm
--autorm is now spelled --rm: 'dl <workspace> --rm' opens the workspace and deletes it
when the session ends, the way 'docker run --rm' does. Use 'dl <workspace> rm' to
delete one now.
$ dl <ws> --stop
--stop is no longer a flag: the flag spellings now modify a session (--rm deletes the
workspace once one ends) rather than name a verb. Use 'dl <workspace> stop' to stop a
workspace.
--autorm is a rename and nothing else. The behaviour above is what it always did.
--stop is a genuine withdrawal, and so is the thing --rm used to do. Both were the
suffix form of a verb, appended to a line that already asked for something, and
winning over it, so that aid <ws> 'review this pr' --rm deleted the workspace and
printed --rm overrode the rest of the line. That shape cannot survive --rm meaning
"delete when the session ends": the two spellings look alike, and one cancelling the
line while the other runs it is the one pair a person cannot keep straight.
What replaces it, for "I am done with this workspace":
dl <ws> rm # the workspace named
dl rm # or pick it; TAB marks several, and rm takes each in turnFor a long aid prompt line that is the cheaper edit anyway: dl rm and a pick
beats recalling the line to type at the end of it. What is genuinely gone is deleting
a workspace without naming or picking it, by appending to whatever the last line
happened to be.
dl <ws> prune used to delete one workspace and dl --prune removes clone
directories and no workspace at all. One word, two unrelated commands, told apart
by two dashes. Reach for the wrong one and you either lose a workspace you meant to
keep or get refused for a reason the message could not explain
(--prune takes no workspace: it is not a workspace command.). So the verb spelling
is gone, and typing it says what to use instead:
$ dl <ws> prune
'prune' is no longer a workspace verb. Use 'dl <workspace> rm' to delete a workspace,
or 'dl --prune' to remove the clone directories no workspace opens any more.
dl --prune is unchanged. The word is still recognised rather than forgotten, so it
is never read as a workspace name. dl prune <ws> says what moved instead of
reporting an unknown workspace called prune, and a workspace that really is called
prune is still reachable as dl stop prune. Use dl <ws> rm from now on.
--force is read in one position only: after both the workspace and the verb, which
is where every example on this page writes it. The two slots ahead of it are already
spoken for, so a --force that lands in either is read as the word that belongs
there and refused:
dl <ws> --force rm -> Unknown command '--force'. (exit 1, nothing deleted)
dl --force <ws> rm -> Unknown workspace '--force'. (exit 1, nothing deleted)
Past the verb it is read, but only rm and rme do anything with it. Every
other verb takes the flag and drops it: dl <ws> up --force, dl <ws> stop --force
and dl <ws> recreate --force all run exactly as they would without it, silently.
That is the one place this grammar discards a word rather than refusing it, and it is
worth knowing because the two halves of the rule read as though they were one: a
misplaced --force refuses, a meaningless one does not. kill is the case to
remember, since it is the verb whose whole point is going ahead anyway. It has no
--force to type and ignores one offered.
The selector form cannot be forced at all. dl rm with no workspace opens the
picker, which leaves no slot after the verb, so dl rm --force is the verb-slot
refusal above. There is no spelling of "pick some workspaces and force the removal";
name the workspace, or answer the refusal the guard prints. The diagnostic's
suggestion (dl rm -- --force) is the generic one for an unknown verb word and would
run --force as a shell command, which is not what anybody typing that meant.
A global command has no slots, so placement stops mattering there.
dl --force --prune and dl --prune --force are one line, and the same holds for
every other global. Note this is about the literal word --force: --devcontainer,
--force-worktrees and -y have rules of their own.
Pinned by force_deletes_only_where_it_follows_both_the_name_and_the_verb and
a_globals_force_reads_the_same_wherever_it_sits in rust/dl/src/cli.rs, and by
force_after_the_verb_still_deletes with its two neighbours in
rust/dl/tests/grammar.rs. The two refusals quoted above are a second copy of
strings render.rs and lib.rs own, so
the_force_placement_section_quotes_the_refusals_it_says_it_does reads this
section back and diffs them against what the binary prints.
aid exists to hand a repo to an agent and walk away, so every agent it starts is
started in that agent's full auto mode. There is no flag to type and no flag to
type differently per agent:
| Agent | Full-auto flag |
|---|---|
claude |
--dangerously-skip-permissions, with IS_SANDBOX=1 beside it |
codex |
--dangerously-bypass-approvals-and-sandbox |
gemini |
--yolo |
The flag and not the whole command line, which is longer than one column: a default
claude launch also carries CLAUDE_CODE_DISABLE_TERMINAL_TITLE=1 and a
--remote-control=<workspace>, and gemini takes its initial prompt through
--prompt-interactive.
One rule, three spellings, and the table in rust/aid/src/rewrite.rs is where they
live. every_agent_starts_in_full_auto holds the rule against that table rather
than against one row of it, so an agent added without its flag fails a test instead
of stopping someone's unattended run to ask about its first edit.
Two of those rows have a reason worth reading.
codex gets the bypass, not --full-auto. codex offers both and only one of them
is this, for two reasons of which the first is the one that matters: --full-auto
still escalates to a person. It is an approval policy plus a sandbox rather than an
absence of approvals, so an unattended run stops and asks, which is the whole of
what this rule exists to prevent. Only --dangerously-bypass-approvals-and-sandbox
sets the policy to never ask. The second reason is the sandbox --full-auto keeps:
workspace write with the network off, which would break gh, cargo fetch and
pip install inside a container that has a network and a checkout the agent is
meant to be able to push from. The container is already the sandbox, so a second one
nested inside it subtracts exactly the capabilities dl went to the trouble of
provisioning.
IS_SANDBOX=1 is what makes claude's flag usable. claude refuses
--dangerously-skip-permissions outright under uid 0, exiting 1 with "cannot be
used with root/sudo privileges", and devcontainers that run as root are ordinary.
The variable is claude's own way of being told the refusal is answering for a
machine that is not there. It is scoped to the agent process and is not exported
into your shell.
What this buys and what it costs is the same sentence: the agent will not stop to
ask, so an aid owner/repo fix the bug can run to the end unattended, and it can
also rewrite the checkout it is in without asking. It cannot reach your host. Review
an aid workspace before pushing rather than treating it as something that will
stop the agent for you.
None of this reaches a command you typed yourself. dl <ws> -- claude runs claude,
exactly as written, with no flags added and no variables set.
Every aid launch of claude starts with Claude Code's Remote Control on. There is no
flag to type:
aid blooop/devlaunch@fix/42 fix the flaky testThe session is still the one in your terminal, running in the container on your machine. Remote Control is what also makes it readable and steerable from claude.ai/code and the Claude mobile app, so you can send it the next thing from a phone without the workspace being anywhere but where it was.
The session is named after the workspace you typed, so the list on claude.ai reads
as the workspaces you opened rather than as a row of untitled sessions. aid always
sends a name, because claude --remote-control [name] takes an optional one and a bare
flag would read the first word of your prompt as the name instead.
aid --no-remote-control blooop/devlaunch@fix/42 # this launch only
aid --no-remote blooop/devlaunch@fix/42 # the same, shorter
export DEVLAUNCH_AID_REMOTE_CONTROL=0 # every launch from this shellThe variable takes 1, true, on or yes and 0, false, off or no, and
refuses anything else by name rather than guessing which you meant. A flag on the
command line beats it in both directions, so --remote-control (or --remote) still
turns one launch back on.
Either switch can also be appended to the end of the line, the way --rm can, so a
recalled line can be turned off without retyping the front of it: aid owner/repo fix the bug --no-remote starts a purely local session and the prompt survives. That is
bounded the way --rm is, to the exact word as a whole argument in the run at the very
end, so a prompt that merely mentions a switch is still a prompt. aid owner/repo explain --remote-control please ends on please and asks the agent what you typed.
It is claude's and nothing else's. Remote Control is a Claude Code feature, so
aid --codex and aid --gemini start with no Remote Control and say nothing about
it: a default that refused would refuse every launch of those two. Typing
--remote-control beside either of them is different, because you asked for
something by name, and that is refused by name before anything boots. So is a
--remote-control on a line whose DEVLAUNCH_AID_AGENT names one of them.
It needs a claude.ai login inside the workspace. Remote Control pairs the session
with a Pro, Max or Team account, so a container whose claude is signed in with an API
key, or not signed in at all, cannot start one. That login lives in the container along
with the rest of the agent's state, which is what aid was already relying on.
A drivable session is a drivable agent. aid runs claude with
--dangerously-skip-permissions, so whoever is signed in to that claude.ai account
can send the agent work and it will not stop to ask. DEVLAUNCH_AID_REMOTE_CONTROL=0
is how to turn that off everywhere.
Nothing survives the workspace. The session is a process in the container, so
dl <ws> stop, dl <ws> rm, a --rm firing at the end of the line, or Ctrl-C out of
claude all take it offline immediately. The entry can sit in the claude.ai list for
roughly 4 hours after that before it clears, which is the web side timing out rather
than anything still running on your machine.
dl <ws> stop asks devpod to stop a workspace, and it is the right thing to type
right up to the moment devpod itself is the thing that is stuck. Then you get this,
every five seconds, with no deadline behind it:
info Trying to lock workspace, seems like another process is running that blocks this workspace
That line is not a retry that will eventually give up. devpod takes a blocking
flock on the workspace and logs the same string on a timer while it waits, so it
waits for as long as whatever holds the lock lives. The usual holder is a devpod up that outlived the dl that started it: reparented to init, sleeping, no
children, and nothing on the machine is ever going to reap it.
dl <ws> kill is the way out. The sweep asks devpod nothing, which is the point:
it reads the host's own process table and acts on what is there. It does four
things, and then deletes the workspace:
- Kills the host processes holding the workspace. Only
devpodprocesses, and only ones that name this workspace and whose own parent has died. SIGTERM first, then SIGKILL for whatever sat through it. Any devpod subcommand counts, not justup: they all take the same lock, so an orphaneddevpod deleteordevpod helperblocks the next launch exactly as an orphanedupdoes. Adevpod upwhosedlis still running is somebody's build and is left alone, and the report says it was. - Removes devpod's stale busy marker, but only once nothing is left holding the
workspace. This is the file under devpod's
agentdirectory, not the lock. - Kills any container the workspace's compose project still has running. Often there is none: the container usually dies well before the lock does. Not while a live build is standing, though. Those containers are that build's, and killing what it is in the middle of creating would break it as surely as signalling it would, so they are left alone with it and the report says so.
- Prints every one of them, with the pid and the whole command line, so you can see what went and how hard it had to be pushed. Every holder it left standing is named too, and each says which kind it is: a live build to wait for, a session to ignore, or an orphan to go and look at. The two docker calls carry deadlines for that reason: a daemon that never answers must not swallow the report of a SIGKILL that has already landed.
And then it deletes the workspace. The sweep and the delete were never
independently useful: clearing the lock is precisely what lets a devpod delete
through, and a workspace wedged badly enough to need the hammer is one you are
throwing away. So dl <ws> kill is the whole thing, and the exit code is the
delete's.
Nothing in that delete refuses, and there is no --force to type. It is rm's
delete with the guard turned into a report: work that exists nowhere else is named
and then destroyed. That is not the guard being dropped for convenience. A wedged
workspace has a dirty clone almost by construction, because whatever wedged it
interrupted the work that was going on in it, so a guard here would refuse in
precisely the case the verb exists for. rm is the happy path and keeps its guard
and its --force; reach for it whenever the workspace might still be wanted.
It does stand down for a holder the sweep could not clear, and there are two of
those. A live devpod up is one: the sweep spares somebody's build on purpose,
along with its containers and its busy marker, and deleting the workspace out from
under it would undo all three. A process with nothing waiting on it is the other,
whether it sat through SIGKILL or lost its parent while the sweep was running.
Both hold the flock, so a delete over either would block on it rather than fail.
Neither is a session: an idle devpod ssh takes the lock and gives it straight
back, which is why dl <ws> rm deletes a workspace somebody is sitting in without
noticing them, and it is why one such session used to be enough to make this verb
do nothing at all. The report names every holder it left and which kind it was,
and the closing line sends you back to kill once they are gone.
And nothing in it waits indefinitely. Two things buy that. The call carries
devpod's own --force, so a workspace whose container or machine devpod can no
longer reach is deleted rather than refused, which is the flag dl's error message
has always told people to type by hand. And it carries a deadline, which no other
delete in dl does: rm's is allowed to take as long as it takes, because a
container that is slow to come down is a container that is coming down, while this
one is being run by somebody who has just sat through the five second lock line
above and must not be put back into it by a holder that arrived after the sweep.
If it does run out, dl says the workspace and its clone are still there and that
running kill again picks up where it stopped: devpod is killed a minute into the
job, so it may have got part of the way through.
The lock file itself is never touched. Killing the holder is what releases it,
because the kernel drops an flock when its holder dies. Unlinking the file would
be worse than the hang: the old holder keeps a lock on an inode nobody else can
see, the next caller locks a fresh file, and two processes both believe they have
the workspace. There is nothing here to delete by hand.
Naming the workspace is the one place devpod could come into it, and by
workspace id it does not. Every other lifecycle verb resolves its target through
a devpod status with no deadline behind it, which on the host this verb exists
for is the same wait arriving one call early. dl <ws> kill skips it: a workspace
id is already the id, so there is nothing for a round trip to settle, and a name
devpod has never heard of sweeps nothing and is reported as a workspace nobody is
holding. dl owner/repo kill does have to ask which workspace that resolved to,
and that one call gives devpod five seconds and then falls back to the derived id
rather than refusing.
kill takes several workspaces from the selector, like stop and rm do. A
machine that was suspended, or one whose dl was killed by the OOM killer, wedges
every workspace that was open at the time. Now that the verb ends in a delete,
marking five rows deletes five workspaces, and none of the five stops for unsaved
work: each one names what it held on the way past. Marking five rows for rm
deletes five workspaces too, so this is the verb doing what it says rather than
something the picker adds, but it is worth knowing before the first TAB.
The advice runs the other way too, in two places, because a plain rm can meet the
wedge without knowing it.
A dl <ws> rm that devpod refuses cannot tell a devcontainer.json that moved from
a workspace something on this host is still holding, so it now names dl <ws> kill
alongside the devpod delete --force it always named. A kill whose own delete is
refused does not print that line, since the sweep it would be asking for is already
on screen above it.
A dl <ws> rm that devpod cannot get the lock for is the harder half, because it
never refuses: devpod waits on that lock with no deadline, logging the five second
line at the top of this section for as long as the holder lives, so there is no
exit code for anything downstream to read. dl reads devpod's stderr as it arrives
instead, and answers the first of those lines while the command is still blocked,
naming the dl <ws> kill to run in another terminal. It says it once, however many
times devpod says it.
These are two different failures and they get two different exit codes.
If devpod is not on PATH, every command that needs it prints a single install
hint on stderr and exits 127, the shell's "command not found" code.
dl --help and dl --version keep working without it.
A devpod that is installed but cannot answer is the other case. If devpod list exits non-zero, or prints something that is not a --output json
workspace listing, dl quotes what devpod said on stderr and exits 1 rather
than reporting that you have no workspaces. That is what stops dl --purge
from deleting caches it never checked.
Shell completion is the deliberate exception. dl --install, dl --refresh and
dl --completion-data log the failure and carry on with the repos and branches
they can still discover on local disk, so an unreachable devpod costs you
workspace-name completion and nothing more.
The message is devpod's and the cause is a substring match. devpod decides which
agent binary to inject by globbing uname -a for arm, and uname -a includes
the container's hostname, so a container whose hostname contains arm reads as an
ARM machine on an x86 host. devpod downloads the arm64 agent, the version check
cannot execute it, and 126 is the shell's "not executable". Every workspace whose
branch contains alarm, warm, charm, swarm, harm, farm or armature is
a candidate, and dl's own setup pass puts the workspace id into the hostname, so
dl is one of the ways the name gets there.
# the mechanism, in any running container
docker exec <container> sh -c 'case "$(uname -a)" in *arm*) echo ARM;; *) echo NOT;; esac'
# what got injected
docker exec <container> file /usr/local/bin/devpodTo unblock a container already in this state without rebuilding it, copy the right binary in and reconnect:
docker cp "$(command -v devpod)" <container>:/usr/local/bin/devpodNot restart or recreate: a recreate wipes it. Renaming the branch is the only
fix that lasts until the upstream match is anchored on uname -m.