Claude Code (https://code.claude.com/docs) as a member of this collection's ecosystem: installs the sha256-verified native release binary at /usr/local/bin/claude (no Node.js, version pinnable) unless a claude is already in the image - e.g. from the official ghcr.io/anthropics/devcontainer-features/claude-code, which is then augmented instead of replaced - and adds agent-adapter-claude (Claude Code's adapter for the agents Feature: task agents -- finds a session by its name and folder and acts on it by id), the deprecated claude-host launcher, claude-fleet command and claude-host-pin hook (still working, each printing one deprecation line; claude-code 2.0 removes them), a named volume mounted at CLAUDE_CONFIG_DIR (/var/lib/devcontainer-features/claude-code) so login and sessions survive rebuilds, a post-create hook that makes that directory usable by the remote user, and the Feature Taskfile. The in-app auto-updater is off; a rebuild updates.
"features": {
"ghcr.io/nabil-becker/devcontainer-features/claude-code:1": {}
}| Options Id | Description | Type | Default Value |
|---|---|---|---|
| installTaskfile | Also install this Feature's Taskfile into /usr/local/share/go-task/includes.d so its tasks appear under the : namespace of a root Taskfile that includes /usr/local/share/go-task/features.yml (see the go-task Feature). | boolean | true |
| install | How the claude binary gets there. 'auto': keep a claude already in the image (official Feature, base image) and only add the ecosystem pieces, else install the native binary. 'native': always install the verified native binary at /usr/local/bin/claude (wins over an npm install on PATH). 'none': never install the binary, only the pieces. | string | auto |
| additionalDirectories | Comma-separated folders every Claude session in the container may read and edit besides the one it starts in (Claude Code's permissions.additionalDirectories), written at build time to the managed-settings drop-in /etc/claude-code/managed-settings.d/50-devcontainer-features-claude-code.json, so no wrapper adding --add-dir is needed and the user's own settings are not touched. Absolute paths, or ~/... for the remote user's home (expanded at build time); a relative path fails the build. Empty: nothing is written. File access only: unlike --add-dir, a folder listed here does not load its CLAUDE.md, skills or other .claude/ configuration. | string | - |
| remoteControl | Start every Claude agent the agents Feature runs with Remote Control on (--remote-control), so agents.yml need not repeat it in each agent's args. Applied by agent-adapter-claude to the sessions it starts or wakes, unless an agent's args already have --remote-control or --no-remote-control (the adapter's own per-agent opt-out). Per run: AGENT_ADAPTER_CLAUDE_REMOTE_CONTROL=0 or 1. | boolean | false |
| version | Native binary version to install (e.g. '2.1.286'), or a release channel: 'stable' or 'latest'. Ignored when an existing claude is kept (install=auto) or install=none. | string | stable |
anthropic.claude-code
- The host returns as itself after a restart (
host-restart-copy). Reported by a consumer on claude-code 1.9.4 with Claude Code 2.1.314, after one container restart: three residents woke under new ids with the old ones superseded, but the host came back as a copy, a job started as--fork-session --resume <old id>whose saved options held only--model(its--name,--agentand--settingsgone), spawned by Claude Code as(shell)rather than claimed like the others, and nothing recorded it or marked the old session superseded. The host's old session was the only one leftblocked. Which path ran could not be read from that stack; the CLI adapter now closes every path that gives that result:- A listed pid is checked.
listread any session with a pid as running. A pid listed for a session the restart ended (gone, or taken by another program) made it running, soagents upnever woke it andagents start/task devcontainer:agentattached to it, which let Claude Code restart it on its own. A pid counts now only when$AGENT_ADAPTER_CLAUDE_PROC(default/proc) has it and its command line, when readable, names claude; otherwise the session isstopped(orfailed), whatever state it was left in. attachof an ended session wakes it first. It ranclaude attach <id>of the ended session, which lets Claude Code restart it however it does (a copy under a new id that nothing records). It now wakes it aswakedoes (bare, confirmed, a new id recorded with the old one superseded, a gone session started fresh) and attaches to the session that then runs.- A fork is recognised by its job. A new session continues the old one also when its job's
state.jsonnames the old id anywhere (a forked respawn's--fork-session --resume <old id>), not only asresumeSessionId; it is recorded whatever its name. A new running session by the agent's name that is not known to continue it (the 1.9.1 fallback) is recorded too, now with the woken session superseded, and one line says so. - What a new id lacks is said. When the session that came back under a new id lacks the agent's options, one
line names them (
lack or differ in: --name host; --settings ...; --agent host); it runs without them until the agent's next fresh start (task agents -- <name> --new, a new conversation). A wake is still always bare. - A quiet trace.
$CLAUDE_CONFIG_DIR/agent-adapter-claude/trace.log(besideagents.tsv, in the Feature's volume, mode 600, the last 500 to 1000 lines) gets one tab-separated line each time an agent is started, woken or attached: time (UTC), the verbagentscalled (start-bg,wake,attach), agent, folder, old id, new id, and the outcome:same,continues(a new id continuing it, the old one superseded),new-id(a new id not known to continue it, the old one superseded),fresh: <why>,started,said-woken,unconfirmed(not recorded) orattached, with; options lack: ...when the session lacks the agent's options. Nothing is printed. After the next restart,grep host "$CLAUDE_CONFIG_DIR/agent-adapter-claude/trace.log"says which verb brought the host back and as what. - Not changed:
agents(2.1.1) itself; a session with a live Claude Code process is still running,blockedtoo.
- A listed pid is checked.
agents start <name> --resumereaches the agent's own conversation.claude agents --json --alllists an interactive session only while it runs: one ended with/exitis gone from it, though its transcript stays at$CLAUDE_CONFIG_DIR/projects/<folder slug>/<session id>.jsonland names it (agentNameandcustomTitlelines).resumetook the newest listed session of the agent's name and folder, so once the host ended, a stray background session of the same name (leftblockedby an earlier resident run) was resumed: the wrong conversation.resumenow weighs, by the identity rule (name and folder, the recorded session first): the agent's recorded session (the onenewwrote before it started), listed or ended with a transcript; its listed interactive sessions; and the ended sessions found only on disk in the folder's transcript folder, made in that folder (cwd), whose last title is a name the agent's session has had. A background session (listed so, or one a job in$CLAUDE_CONFIG_DIR/jobsholds) counts only when it is the recorded one. A running one is refused, as before (a second process on one transcript); else the newest by its transcript's time is resumed by its id. When the list cannot be read, Claude Code's own picker opens, as before.
- A crashed wake never loops. Reported by a consumer on claude-code 1.9.2 with Claude Code 2.1.314: a resident
whose session had never been prompted was woken, the wake crashed (
bg settled <short> (crashed): source session <id> not foundin$CLAUDE_CONFIG_DIR/daemon.log), the CLI adapter recorded the crashed attempt as the agent's session (it was briefly listed running under a new id), and everyagents upwoke that attempt and crashed again. The workaround wasagents start <name> --new --yes. Now:- A session with no transcript (
$CLAUDE_CONFIG_DIR/projects/<folder>/<session id>.jsonl, looked for in every folder) is never woken: there is no conversation to keep, so the agent starts fresh, as for a gone session, and one line says so. When there is noprojectsfolder at all the wake goes ahead as before. - A session whose job failed with
source session <id> not found(itsstate.json), or that any job'sstate.jsonnames as not found, is gone and never woken again; the agent starts fresh. - Every wake is confirmed for
AGENT_ADAPTER_SETTLEseconds (default 5): when the wake output, the woken job'sstate.json, any job'sstate.jsonor the linesdaemon.loggained since the wake saysource session <id> not found, the agent starts fresh and the crashed attempt is never recorded. A woken session listedfailedat the end of that time for another reason fails the wake (exit 1), unrecorded, its conversation kept.
- A session with no transcript (
- "Same conversation, new id." The same report: residents that resumed fine came back under new ids (Claude
Code 2.1.314 appears to resume into a new id), so "the id after a restart is the id before" no longer holds; a
consumer's check should compare conversations, not ids. A wake that comes back under another id is the agent's
session when that session continues the old one: its job's
resumeSessionIdis the old id, or Claude Code said it woke the old session and named the new one (woke session <old>withbackgrounded · <new>), or the new transcript names the old id. The new id is recorded, the old one kept in the record, and one line names both (woken; Claude Code continues its conversation under a new id, <new> (was <old>)). A copy Claude Code says it started (started a copy as <id>) never counts. On 2.1.308 (checked again for this release) a bare wake keeps the id, including that of a session never prompted, which also has no transcript there; 1.9.1's "the same id" holds on 2.1.308 only. - The old session is never left as a second copy. After a wake under a new id, the old session is stopped by
its id if it still has a process (an interactive one is left, with a line). Without one it is left (never
removed) and marked superseded in the record (a seventh field, the sessions the agent left, also filled when it
starts fresh in place of a gone or crashed session).
listprints a superseded session without a process as<name> (superseded), which is no name of the agent's, soagentsnever counts it as another copy, and the CLI adapter never wakes or attaches it as the agent again. - Facts checked on Claude Code 2.1.308 (a throwaway
factcheck-ntsession, haiku, never prompted, in a trusted folder, stopped by its id): such a session has no transcript; its job'sstate.jsonhassessionIdandresumeSessionId(equal);daemon.logsits at$CLAUDE_CONFIG_DIR/daemon.logand logs a crashed wake asbg settled <short> (crashed): source session <id> not found; a bare wake of the never-prompted session continued the same id. The 2.1.314 crash and the new id on resume are taken from the consumer's report and modelled in the unit tests' fakeclaude; they could not be reproduced here.agents2.1.1 words its line about a new id neutrally (it said the old session "was not woken").
- Extra saved options never turn a wake into a copy. Reported by a consumer after 1.9.1: residents whose entries
in
agents.ymlhadargs: [--remote-control]still came back as copies. Their sessions' saved options were--name mhs --remote-control --model sonnet: the--model sonnetwas never passed by the agents Feature or the CLI adapter, Claude Code saved it from the room's.claude/settings.json("model": "sonnet"). The bare-wake check required the saved options to equal--name <name>plus the agent's args, saw an extra--model, woke with flags, and Claude Code started a copy (the flags you passed started a copy as ...) that lost the conversation. - A wake of an existing session is always bare. The CLI adapter compares the saved options with what the
agent wants (
--nameas any name the agent's session has had, 1.9.0, and its args,--remote-controlleft out as before) and treats a wanted option that is present with the same value as met, in any order; saved options beyond those are no difference. A wanted option that is missing, or saved with another value, is still woken bare, with one line on stderr naming it (for examplelack or differ in: --model opus).wakeandattachof an ended session both follow this, and neither ever passes flags to--bg --resumeany more (that path is removed). When the saved options cannot be read the session is woken bare too, with a line saying so. - The trade. A changed
argsentry inagents.ymlno longer reaches a session that already exists: the session keeps the options it was started with, and the changed ones apply at the agent's next fresh start (agents reset, or the session gone). Losing a resident's conversation is the failure to avoid, so the options give way, not the conversation. A session found gone is still started fresh with the agent's flags, as a new session.
- A finished resident with a live process is running, not stopped. Reported against 1.9.1: every
agents upstopped and re-woke a resident that had finished its turn. Claude Code keeps such a session (state: done) as a process, waiting for a prompt, until it is stopped; 1.8.7 listed itstoppedso that a wake would stop it first, and the agents Feature, seeingstopped, woke it every time. The CLI adapter now lists any session with a pidrunning(its activityidle, sosession-busysays it is not mid-turn), soagents upjoins it: no stop, no resume, no start.stop(andsession-stop) end it by its id like any running session.wakeof it is refused (nothing to wake;attachgoes to the live process), so no--bg --resumeis ever run on a live process (that starts a copy, 1.8.7). The stop-then-wake path of 1.8.7 (stop_done) is removed: a session reachingwakehas no process. - A
blockedsession with no process is stopped. It was listedrunningalthough nothing ran, so nothing woke it. It is nowstopped, and a wake resumes it bare under its own id (1.9.1).blockedwith a pid is still running. - Corrections to earlier notes (the history below is kept, each marked): saved options keep the order they were
given (1.8.7's "fixed order" was wrong; corrected in 1.9.1), and the
"state"in Claude Code'sstate.jsonis not authoritative (1.8.3's check on it was wrong; 1.9.1 stopped consulting it).
- Residents wake into their own session after a container restart. Reported by a consumer: after each of two
restarts, 8 of 9 residents came back as new sessions. The cause was the adapter's own guard, not Claude Code.
Facts checked on Claude Code 2.1.308 against a background service of its own, killed the way a restart does
(processes gone, job files kept):
- A session started with
--bgand no prompt keeps"state": "working"(tempoblocked, detail(idle — send a prompt to start)) in itsstate.jsonwhile it waits. Once its process and the background service are gone,claude agents --json --alllists itfailed, but the file keeps sayingworking. The service writesfailed(same detail) only when it starts again, and the nextclaude --bgstarts it. So the consumer'sfailed+(idle — send a prompt to start)is Claude Code's own record, written by the wake. - A bare
claude --bg --resume <id>of that session continues the same id with its saved options (note: woke session <id> with its saved options), even though the file still saidworking. It is the adapter that used to seeworking, decide that a bare wake would start a copy (1.8.3), and wake it with--nameand the args. Claude Code then started a copy under a new id (the flags you passed started a copy as <id>). The adapter no longer consultsstate.json's state at all. - A finished session (
done) whose process is gone needs no stop: 1.8.7's stop-then-resume applies only when the list shows a pid. A bare wake continues it under its own id, and it comes back idle with a pid and its state stilldone. The adapter listed itstopped(1.8.7), so the wake waited for "running" untilAGENT_ADAPTER_WAITran out (not recorded) and printed no id, and the agents Feature carried its lease assession:unknown. The woken session now counts once it has a pid, whatever its state. When Claude Code says it woke that very session (woke session <short id>,backgrounded · <short id>) and the list does not show it yet, the adapter records it and prints its id. In neither case is there a fresh start. - A fresh start still happens only when Claude Code says the session is gone (
source session <id> not found, 1.8.6).
- A session started with
--remote-controlis saved. Claude Code keeps it inrespawnFlagswhere it was given, for example--name n --remote-control --model m. The bare-wake comparison still leaves it out on both sides, so turningremoteControlon does not make the wake of a session started without it a copy (1.8.8).- Saved options keep the order they were given.
--model haiku --remote-control --agent a --settings s --name nwas saved in exactly that order, so 1.8.7's "fixed order" was wrong. Saved and wanted options are now both put in one order before they are compared (--name,--settings,--agent,--model, then the rest as given, with the last value of a repeated option winning), so the order never decides between a bare wake and a copy.
- Session names:
AGENT_SESSION_NAME. The CLI adapter (agent-adapter-claude) reads the environment variableAGENT_SESSION_NAME, the name the caller (theagentsFeature, from its template) wants the session to show. When it is not empty it is the session's--nameon every start (start-bg,new, a wake that has to start a copy, and the fresh start after a session is gone); when it is empty or unset the handle is the name, as before. The adapter checks nothing about the template (the caller renders and checks it); it only refuses a value with a line break or tab (the handle is used, and it says so). The argument<name>stays the agent's handle: it keys the record, the lease and every verb. A CLI adapter that cannot name sessions ignores the variable (the NFR-12 fallback), so there is nosupportsline for it. - Fact it rests on. A name given with
--nameis pinned (nameSource: userin the job'sstate.json): Claude Code does not retitle such a session, through its first turn, a stop and a bare wake (checked on Claude Code 2.1.308; the session-names proposal, "Facts checked"). - A changed name never makes a second session. The record (
agents.tsv) keeps, besides the ids, the name the session was started with and up to five names before it (two more columns; an older record without them still works). The agent's session is found by its record, and otherwise by any name it has had in its folder: the session name now, the handle, the recorded name and the recorded earlier names (host (2)too). The bare-wake comparison accepts a saved--nameequal to any of those, so a template that changed between starts wakes the same session bare, with its conversation, under the name it has. A bare wake also records the name it keeps.recordwith no running session clears the ids and keeps the names. - A changed name applies at the next fresh start, not to a running or ended session: the adapter does not
rename (Claude Code has
/renameinteractively and nothing from outside; NFR-13). A call withoutAGENT_SESSION_NAME(a nested call) wakes the same session bare and renames nothing. A session found by neither record nor any of those names (a lost record, started under a name the template no longer gives) is not found.
remoteControloption: Remote Control on for every agent."remoteControl": truemakes the CLI adapter (agent-adapter-claude) add--remote-controlto every session it starts (start-bg,new) and to a wake that starts it again, soagents.ymlneed not repeatargs: [--remote-control]per agent. Defaultfalse: nothing changes. The option is baked into/usr/local/share/claude-code/remote-control;AGENT_ADAPTER_CLAUDE_REMOTE_CONTROL=0or1overrides it for one run (AGENT_ADAPTER_CLAUDE_REMOTE_CONTROL_FILEmoves the file, for tests).- Per agent. An agent whose args already have
--remote-controlis not given a second one. Claude Code has no off flag, so--no-remote-controlin an agent'sargsis the CLI adapter's own opt-out: the adapter removes it and adds nothing (it is never passed toclaude). - Wake. Claude Code's saved options are documented only as
--name,--settings,--agent,--model; whether it saves--remote-controlwas not checked in a real session. The bare-wake comparison therefore ignores--remote-controlon both sides, so turning the option on does not make every wake a copy. If Claude Code does not save it, a bare wake brings the session back without Remote Control: to be checked on the workstation.
- The downloads retry every error. The channel file, the manifest and the binary go through
dcf_curl, the collection's download helper (vendored fromcommon):--retry 3, and on curl 7.71+--retry-all-errors, so a TLS reset (curl exit 35) or a connection cut mid-transfer (exit 56) no longer fails a no-cache rebuild. The checksum check is unchanged. No option or behavior change.
- The example pins Node. "Augment, don't duplicate" now shows the node Feature with
"version": "24"instead of its movingltsdefault, which broke builds when Node 26.13.0 became LTS. Documentation only; the Feature itself is unchanged.
- A wake of a finished session whose process is still alive stops it first. (Superseded in 1.9.2: such a session
is now listed running and joined, never woken; the stop-then-wake path is gone.) Claude Code leaves a session
whose state is
donerunning as a process (a pid inclaude agents) until it is stopped, andclaude --bg --resume <id>of it starts a copy under a new id with empty saved options ("already running"). The adapter now lists such a session asstopped(its pid kept), andwake(andattach) runclaude stop <short id>for that one session, wait (AGENT_ADAPTER_WAIT, default 10 s) until it has no pid, then resume it. No other session is ever stopped. - The bare-wake check compares the options in Claude Code's order. (Corrected in 1.9.1: the order is the one
given, not a fixed one; both sides are now put in one order before they are compared.) Claude Code saves a session's options in a
fixed order (
--name,--settings <path>,--agent <name>,--model <m>, only those present), not the order of the args inagents.yml; the adapter compared them as given, so a different order looked like a change and woke the session with flags, which starts a copy. Both sides now use Claude Code's order (a repeated flag keeps the last value). Any other arg is assumed saved after those four, in the order given. Checked on Claude Code 2.1.308. A wake given a prompt with--resumedoes not start a copy; wake does not use that.
- A wake whose session is gone starts the agent fresh. When
claude --bg --resume <session id>fails because the session no longer exists (the job'sstate.jsonsaysdetail: source session <id> not found, or claude printed it),wake(andattachof an ended session) starts the agent asstart-bgdoes, with--name <name>and its args, records it and prints its id; stderr sayssession <id> is gone; started fresh.Any other wake failure still fails. Reported by a consumer whose resident stayed failed after a restart.
- A woken agent runs with its options from
agents.yml, and keeps its name. Claude Code puts a background session's saved options (the flags it was started with) back when it is woken with a bareclaude --bg --resume <session id>orclaude attach <id>, and starts a copy under a new id when any flag is given with--resume(Claude Code 2.1.308).wake, andattachof an ended session, now compare those saved options with whatstart-bgpasses (--name <name>and the agent's args): the same, the session is woken bare (same id, same conversation); different, or not readable, it is woken with--name <name>and the args, so it never runs without its--agent,--settingsor name (a copy, said on stderr, recorded as the agent's). The same when the session's own state in Claude Code's job file still says running although the list shows it ended (after a rebuild), where a bare wake would start a copy without any options. (Corrected in 1.9.1: that file's state is not authoritative; a bare wake of such a session continues it, so the adapter no longer consults it.)attachof an ended session wakes it that way first, then attaches. Reported after a rebuild, where three residents came back without their agent definition and register identity, listed by id rather than name.
additionalDirectories(new option): folders every Claude session may read and edit, without a wrapper adding--add-dir(below).agent-adapter-claudereads the session list sturdily: the JSON array is taken even when other lines come before or after it; a read that times out or prints nothing is tried once more with a longer time-out; when it fails, the message says why (the exit code or the time-out, and Claude Code's own first line), and names a wrapper in front ofclaudewhen Claude Code answersunknown option.resumeon an unreadable list opens Claude Code's own picker, never a new session; a newhintverb tells theagentsFeature how to resume by hand.- The adapter keeps its own record of each agent's session, so a session Claude Code retitles after a prompt is
still the agent's (1.7.0 found it by name only, so a retitled resident looked stopped and was started again), and
adds the verbs
record,session-busy,session-stopandsession-removefortask agents:reset.
The agents Feature replaces them: a repository lists its agents in
.devcontainer/agents.yml (name, CLI, kind, folder, args) and starts any of them with
task agents -- <name> inside, task devcontainer:agent -- <name> from the host. No session id
is pinned anywhere, so HOST_SESSION, the claude-host-pin hook and SESSIONS go. This
release keeps the three scripts working; each prints one line on stderr saying it is deprecated
and that claude-code 2.0 removes it (claude-fleet says it once, not again for the
claude-host it calls). task claude-code:host and task claude-code:fleet say so in their
descriptions. The migration for a consuming repository is in
docs/migrations/agents.md.
Claude Code's adapter for the agents Feature, on PATH. The agents command calls it; nothing
else should. The contract (verbs, exit codes) is in the agents Feature's notes.
| Verb | What it runs |
|---|---|
start-bg |
cd <folder> && claude --bg --name <name> <args>; waits until the session is listed and prints its id |
wake |
the agent's newest stopped or failed session, by its full id: cd <folder> && claude --bg --resume <session id> (its conversation, under the same id on Claude Code 2.1.308, which says woke session ... with its saved options (--name, ...); 2.1.314 may answer under a new id, which is recorded when it continues the conversation, 1.9.4), always bare (1.9.3); a line says when its saved options lack or differ from --name <name> <args>; extra saved options are no difference. If the session is gone (source session <id> not found, 1.8.6), has no transcript, or the wake crashes with "not found" within AGENT_ADAPTER_SETTLE seconds (1.9.4), the agent is started fresh as start-bg does, and stderr says so |
attach |
claude attach <id> of its newest running session; an interactive one is refused with the pid and tty of the terminal that holds it. With none running, its newest ended one is woken first as wake does (bare, a new id recorded, the old one superseded) and the session that then runs is attached (1.9.6; it was claude attach <id> of the ended one, which let Claude Code restart it as an unrecorded copy) |
resume |
cd <folder> && claude --resume <session id> <args> of its newest own conversation (1.9.5): its recorded session, its listed interactive sessions and the ended ones found only by their transcript in the folder (an interactive session leaves the list when it ends), newest by transcript; never a background session that is not its recorded one. Refused while one of them runs (a second process on one transcript). When the list cannot be read: claude --resume <args>, Claude Code's own picker (1.8.0) |
new |
cd <folder> && claude --name <name> <args> |
list |
claude agents --json --all: name, session id, state (a pid is running when that process is Claude Code's and runs here (1.9.6: a pid left by a container restart is not), a finished done session with a live process included (1.9.2); blocked without a live pid is stopped; failed; anything else stopped), folder, newest first |
hint |
how to find and resume the agent by hand (claude agents; cd <folder> && claude --resume, pick it), for when the list cannot be read (1.8.0) |
stop |
claude stop <id> of its newest running session; --extras stops every other running one instead. Never claude rm. |
logs |
claude logs <id> |
Identity by name and folder, action by id. A session is the agent's when its cwd is the
agent's folder and its name is the agent's name, or that name with Claude Code's collision
number (host (2)). attach, stop and logs take the session's short id from the list (the
full sessionId when there is none), never the name: claude attach host also matches part of a
name (host-review) and works only while the session runs, and claude stop takes an id only.
A stopped session is woken by its full id with --bg --resume; --bg -c and --bg --resume <name> start copies, so the adapter never uses them. Saved options (1.8.3). Claude Code keeps the flags a background
session was started with (respawnFlags in $CLAUDE_CONFIG_DIR/jobs/<id>/state.json) and puts them back on a bare
wake; a flag given with --bg --resume starts a copy under a new id instead (the flags you passed started a copy as <id>). So 1.8.3 woke bare only when the saved options were exactly --name <name> and the agent's args
(and its own "state" in that file an ended one), and otherwise passed them, accepting a copy rather than an agent
without its options. Corrected in 1.9.3: a wake is always bare and extra saved options are no difference (see
Changes in 1.9.3). Corrected in 1.9.1: the state in that file is not
authoritative (after a restart it still says working for a session that was idle, and a bare wake continues it), so
it is no longer consulted; only the saved options are compared, and they keep the order they were given. attach of an
ended session follows the same rule before it runs claude attach <id>. That file is Claude Code's
own, not a documented interface: when it cannot be read the adapter wakes bare and says so (1.9.3). The name is set with --name at start and
survives /clear, so nothing has to be written back. The JSON is read with sed (no jq or python
in the Feature), as claude-host does. Needs Claude Code 2.1.290 or later (session names).
Records (1.8.0). Claude Code can retitle a background session after a prompt, so the name given with --name
does not always stay (a resident started as framework was listed as "d47 d50 personalization medallion"). No setting
turns that off, and there is no command to rename a session from outside (Claude Code's docs: -n/--name, /rename
and Ctrl+R in agent view; a name set with /rename is kept). So the adapter records, when it starts, wakes or opens
an agent's session, the agent's name and folder with the session's short job id and full session id, in
$CLAUDE_CONFIG_DIR/agent-adapter-claude/agents.tsv (the Feature's volume, so it survives a rebuild; folder 700,
file 600; AGENT_ADAPTER_CLAUDE_STATE moves it; never in the repository, and not a pin: nothing has to be kept by
hand). new chooses the session id itself (--session-id) and records it before the session starts. A recorded
session still in claude agents --json --all is the agent's whatever its name now is, and list prints it under the
agent's name and folder, before the agent's other sessions:
- found by its job id with a new session id (a
/clearstarts a new conversation in the same job): followed, the record updated, and said on stderr; - found by its session id under a new job id (a wake that started a copy): followed, updated, said;
- not listed any more (removed): the name and folder find the agent, as before, and that is said.
Matching by name, a running session (a pid, or a background state of working, blocked, busy, waiting or idle) always
wins over ended ones (done, stopped, failed); an ended one is woken only when none runs. start-bg refuses when the
agent's recorded session runs (never a second one).
| Verb | What it runs |
|---|---|
record |
records the agent's running session (the one the lookup picks), or clears its record when none runs |
session-busy <id> |
exit 0 when that session is mid-turn: status busy (a live process) or background state working; exit 1 when it is idle, waiting for input, blocked on a decision or ended; exit 3 when a running session says neither |
session-stop <id> |
claude stop of the session with that id (from list); an interactive one is refused |
session-remove <id> |
claude rm of that ended session (Claude Code keeps the transcript and a worktree with unpushed work); a running one is refused |
Reading the list (1.8.0). claude agents --json --all runs within
AGENT_ADAPTER_CLAUDE_TIMEOUT seconds (default 30), its output to files (a process that keeps a pipe
open cannot hang it); plain --json is asked only when Claude Code refuses --all as unknown (an
older release), since a list without the stopped sessions would wake nothing. The array is taken
from the first line starting with [ through its end, so a notice before or after it does no harm.
A read that timed out or printed nothing is tried once more after AGENT_ADAPTER_CLAUDE_PAUSE
seconds (default 3) with three times the time-out; a quick error with a message is not, since it
would not change. A failure says the call, its exit code or time-out and its first line (cut to 160
characters), for example
agent-adapter-claude: the session list could not be read: 'claude agents --json --all' exited 1: error: unknown option '--json'. A wrapper or alias in front of claude may be rewriting its arguments: claude here is /usr/local/bin/claude.
That one is what a wrapper putting --add-dir <dir> before every argument causes: --add-dir
takes any number of folders, swallows agents, and plain claude refuses --json. Drop the
wrapper and use the additionalDirectories option instead.
Not verified against a live claude --bg in CI: the unit tests (task test:claude-unit) use a
fake claude; the calls rest on the facts checked live on Claude Code 2.1.308 (the agents
proposal, section 7). A background session in a folder Claude Code has never trusted fails to
start (Workspace not trusted): run claude there once and accept.
"additionalDirectories": "/workspaces/shared, ~/notes" gives every Claude session in the container
those folders as working directories (read and edit them as it does the folder it starts in), the
persistent form of --add-dir. The Feature writes them at build time as Claude Code's
permissions.additionalDirectories to a managed-settings drop-in the image owns,
/etc/claude-code/managed-settings.d/50-devcontainer-features-claude-code.json:
{"permissions":{"additionalDirectories":["/workspaces/shared","/home/vscode/notes"]}}- Why there: the user's own
settings.json(in the Feature's volume) is never rewritten, and a rebuild writes the file afresh, so a folder taken out of the option is gone again. Claude Code combines lists across settings files, so folders from the user's, the project's or local settings still apply beside these; managed settings need no workspace trust (a project's.claude/settings.jsonlist does). - Paths: comma-separated; absolute, or
~/~/..., which the Feature expands at build time to the remote user's home (the file holds absolute paths only). Spaces inside a path are kept, spaces around a comma dropped. A relative path or~userfails the build with the reason. A folder need not exist at build time (a mount appears later). Empty (the default): nothing is written. - File access only: unlike
--add-dir, a folder listed in settings does not load itsCLAUDE.md, skills, subagents or other.claude/configuration (Claude Code's permissions docs, "Additional directories grant file access, not configuration"). A repository that relied on that keeps--add-dirfor that one session, or moves the configuration into the repository. - Limit: managed settings rank by source, and by default (
managedSourcesBehaviorfirst-wins) Claude Code uses only the highest source that holds a policy. When your organization delivers server-managed settings (the claude.ai admin console) or an MDM policy, this file is skipped and the folders do not apply;/statusshows theSetting sourcesit used. That is not verified here; tell us if it happens.
Migrating from a wrapper: a repository that wraps claude (for example a /usr/local/bin/claude
that runs the real one with --add-dir <dir> first) drops the wrapper and sets this option to the
same folders. Such a wrapper also breaks every claude <command> (--add-dir swallows the
command's name; claude agents --json then fails with unknown option '--json'), which is what
task agents:list showed as error.
With the default install: auto this Feature keeps a claude that is
already in the image and adds only the ecosystem pieces around it, so it
composes with the official Feature:
"features": {
"ghcr.io/devcontainers/features/node:1": { "version": "24" },
"ghcr.io/anthropics/devcontainer-features/claude-code:1": {},
"ghcr.io/nabil-becker/devcontainer-features/claude-code:1": {}
}Pin Node to a major rather than the node Feature's default lts: when a new LTS lands, lts
moves with it on the day, and Node 26.13.0 (2026-10-28) broke builds that way (it needs
libatomic1, which Debian bookworm images lack, and its checksum was briefly unreadable).
installsAfter orders this one after the official Feature (and after the
node Feature it needs). On its own, or with install: native, it installs
the native release binary instead: sha256-verified against the release
manifest, pinnable with version, no Node.js. The official Feature pins no
version and verifies nothing, which is why this installer still exists.
What the Feature adds in both cases:
-
agent-adapter-claudeon PATH (1.7.0, above). -
With
additionalDirectoriesset, the managed-settings drop-in under/etc/claude-code/managed-settings.d(1.8.0, above). -
claude-hoston PATH (deprecated in 1.7.0, see the top): start or resume one session by its UUID. WithHOST_SESSION=<uuid>set it runsclaude --resume <uuid>when that session's transcript exists for the current directory, elseclaude --session-id <uuid>to create it; unset, a plainclaudewith a hint. Names given with/renameare labels; the UUID (shown by/status) is the handle.task claude-code:hostinside,task devcontainer:claudefrom the host machine (thebootstrap/devcontainer.ymlinclude, which readsHOST_SESSIONfrom.devcontainer/.env). WithCLAUDE_HOST_PRINT_FLAG=1it instead prints that decision,--resume <uuid>or--session-id <uuid>, on stdout and exits 0 without startingclaude, so a launcher of your own (detachedclaude remote-controlsessions, say) reuses it:claude remote-control $(CLAUDE_HOST_PRINT_FLAG=1 claude-host). WithHOST_SESSIONunset it prints nothing and exits 0; an invalid UUID is still exit 2. Messages go to stderr. -
A running session is never started a second time (claude-host, 1.5.0).
claude --resume <id>on a session that is still running opens a second process on the same transcript, and/cleargives a session a new id, so the pinned id (HOST_SESSION...in.devcontainer/.env) may not even be the running one. Before starting,claude-hostreadsclaude agents --json(fieldspid,sessionId,cwd, as seen in Claude Code 2.1.308) and looks for live sessions whosecwdis the current folder. Live means the entry has apid: the list also holds ended sessions, without one (or withnullor0), which never count.Live in this folder claude-host does nothing as before: --resume <pinned>when its transcript exists, else--session-id <pinned>one or more (the pinned id included) starts nothing, prints each running id and pid and how to reach it (a background session: claude attach <id>; an interactive one, whichclaude attachcannot reach: the terminal that holds it, by pid and tty, to switch to, or Remote Control), exits 1; withCLAUDE_HOST_PRINT_FLAGit prints nothing and exits 1, so a wrapper does not startany, CLAUDE_HOST_ALLOW_SECOND=1starts by the rule of the first row claude agents --jsonmissing or failing (older Claude Code)one-line note, then the first row When the pinned id is not one of the running ids, the refusal also says the pin is stale.
CLAUDE_HOST_UPDATE_PIN=1(manual path) then rewrites it when exactly one session runs:claude-host-pin --replace <old> <new>changes the oneHOST_SESSION...line holding the old id in the first of the root.envand.devcontainer/.envthat holds it (the lookup below; orCLAUDE_HOST_ENV_FILE;CLAUDE_HOST_PIN_VAR=<name>restricts the variable), quotes kept, no other line touched, refusing unless exactly one line holds it.CLAUDE_HOST_LIVE_CHECK=0skips the check. The JSON is read withsed, not jq or python, which the Feature does not install. Deliberately not done: falling back to the newest transcript of the folder when the pin is stale. It is too implicit; the hook below makes it unnecessary. -
claude-host-pin: keep the pin equal to the running id (opt-in SessionStart hook, 1.5.0; deprecated in 1.7.0). The Feature manages no Claude Code settings, so a repository registers the hook in its.claude/settings.json:{"hooks": {"SessionStart": [ {"matcher": "startup|clear", "hooks": [{"type": "command", "command": "claude-host-pin"}]} ]}}Claude Code gives a SessionStart hook its input as JSON on stdin (
session_id,cwd,source:startup,resume,clearorcompact, among others). The hook finds the repository's.envfiles upwards fromcwd(orCLAUDE_HOST_ENV_FILE), in the order Task'sdotenv: ['.env', '.devcontainer/.env']reads them (the root, the folder holding.devcontainer: its.env, then.devcontainer/.env; the first file that defines a variable wins, so the hook writes the line in the file that holds it, andclaude-hostandclaude-fleetread the same way; 1.6.1, before that only.devcontainer/.envwas looked at and a pin kept in the root.envwent stale after a/clear), picks the variable the waydevcontainer:claudedoes (the folder holding.devcontaineris the root:HOST_SESSION; a sub-folder:HOST_SESSION_<its name, upper-cased, other characters _>,rooms/solisgivesHOST_SESSION_SOLIS) and writessession_idinto that line, only when it differs, keepingexport, quotes, comments and every other byte of the file. A UUID is required. A variable without a line is left alone (never adds one) unlessCLAUDE_HOST_PIN_CREATE=1appendsVAR=<id>to.devcontainer/.env(no file holds the variable). It always exits 0, notes go to stderr, so a session never fails because of it. The consumer's own write-back and second-copy check can go. -
claude-fleet: several sessions in one terminal, never a second copy (1.5.0; 1.6.0 below; deprecated in 1.7.0). Bash likeclaude-host, for the same reason (no jq, no python; the container always has bash), installed beside it, run whereclaude agentsruns.SESSIONS(environment, orSESSIONS_B64base64-encoded) holds one line per session,name|session-variable|dir|extra claude args(blank lines and#lines skipped; the extra args are split on whitespace, no quoting). Each variable's value (the session's UUID) comes from the.envfiles first (root.env, then.devcontainer/.env; the first file with a line for the variable wins, as in Task), and from the environment only when none has a line for that variable (1.6.0 reverses the earlier order: inside a session the environment keeps the id the session started with, stale after a/clearor a fork, while theclaude-host-pinhook keeps.envcurrent). For each line, againstclaude agents --json: variable unset: said, skipped; its id live, or any live session working in its folder (a/cleared session has a new id there): running, not started (two or more live in one folder are reported as duplicates, below); otherwisecd <dir> && HOST_SESSION=<id> claude-host --bg <args>(claude-host's own live check is off for these starts, the fleet has made it; a live session is one with apid, ended entries never count). Thenexec claude agents.-- --planprints what it would do and starts nothing;-- --no-viewstarts the missing ones and does not open the view. When the list cannot be read it starts nothing and exits 1.1.6.0, from a consuming repository's lessons running four agents in one container:
- It waits for restored sessions. After a rebuild the Claude daemon restores background
sessions from this Feature's volume; starting sessions in that window races the restore and
makes duplicates. Before reconciling,
claude-fleetprintswaiting for restored sessions…and readsclaude agents --jsonuntil two readsFLEET_SETTLE_INTERVALseconds apart (default 3) give the same set of live ids, for at mostFLEET_SETTLE_TIMEOUTseconds (default 30; then it reconciles anyway with a one-line note).-- --no-waitskips it. - Duplicates are reported, and stopped only on request. When more than one live session
matches an agent (by its id or by its folder),
-- --planand a normal run list each one (id, pid, age, whether a transcript exists) and mark the kept one (the id in.envif live, else the newest bystartedAt) and the extras, then print the--stop-duplicateshint; nothing is stopped.-- --stop-duplicatesstops the extras withclaude stop <id>, which keeps the conversation (claude attach <id>resumes it), re-reads the list to confirm they are gone, and says so. A stopped extra is never restarted: the agent still has its kept session.claude-fleetnever deletes a session (claude rmcan delete its worktree, which is not Claude's to delete in a fleet); a test guards that. claude-host's refusal reads each live session'skindfromclaude agents --json: for a background session it namesclaude attach <id>; for an interactive one (claude attachfails there with "No job matching") it says a terminal holds it, naming the pid and the tty (ps -o tty=), and to switch to that terminal or use Remote Control (1.6.1). It also warns when theHOST_SESSIONin the environment is in no line of the.envfiles (it still uses it).task claude-code:fleetruns it inside (SESSIONS in the environment); from the host,task devcontainer:claude:fleet(see the README), withSESSIONSset where the Taskfile includesbootstrap/devcontainer.yml:
includes: devcontainer: taskfile: ./.devcontainer/cli_bootstrap.ignore/devcontainer.yml vars: SESSIONS: | solis|HOST_SESSION_SOLIS|rooms/solis|--model sonnet atlas|HOST_SESSION_ATLAS|rooms/atlas|
A repository that scripted this itself (a
scripts/agents.pythat readsclaude agents --jsonand starts the missing sessions) can drop the script and keep only itsSESSIONSlist. Not verified against a realclaude --bghere: the tests use a fakeclaude;claude agents --jsonoutput was read from Claude Code 2.1.308. - It waits for restored sessions. After a rebuild the Claude daemon restores background
sessions from this Feature's volume; starting sessions in that window races the restore and
makes duplicates. Before reconciling,
-
CLAUDE_CONFIG_DIR=/var/lib/devcontainer-features/claude-codeincontainerEnvand a named volume mounted there, so login and sessions survive rebuilds without any configuration (see below). -
A
postCreateCommand(claude-code-setup, alsotask claude-code:setup) that createsCLAUDE_CONFIG_DIRand chowns it to the remote user, since bind mounts and named volumes arrive root-owned, and reports what backs it (volume, bind or image), writability and whether a login is present. -
DISABLE_AUTOUPDATER=1: the binary is root-owned in every install mode, so a user session could not replace it anyway; bumpversion(or leave it onstable) and rebuild to update.
Claude Code keeps credentials, settings and session history under ~/.claude
and the OAuth account / per-project trust in ~/.claude.json. Setting
CLAUDE_CONFIG_DIR moves both into one directory, so persisting that one
directory is enough.
By default nothing needs configuring: the Feature sets
CLAUDE_CONFIG_DIR=/var/lib/devcontainer-features/claude-code and mounts the
named volume claude-code-config-${devcontainerId} there (the pattern the
official docker-in-docker Feature uses for its storage). The volume survives
rebuilds and image updates. It does not survive a container-engine storage
reset or a move to another engine, and ${devcontainerId} changes when the
checkout moves to another path, which leaves the old volume behind (docker volume ls shows it; remove it by hand).
For the host disk (survives all of that; stays out of the checkout, so a
synced checkout such as OneDrive never carries transcripts), the repo
bind-mounts a host folder on the same target - a repo's mounts merge after
the Features' and the last source on a target wins - and creates the folder
with initializeCommand before the first start, since a bind mount with a
missing source fails (array form: a string goes through cmd /c on Windows,
which mangles quotes and %VARS%):
"initializeCommand": ["task", "devcontainer:homes", "NAME=myrepo", "--", "claude"],
"mounts": [
"source=${localEnv:USERPROFILE}/.devcontainer-homes/myrepo/claude,target=/var/lib/devcontainer-features/claude-code,type=bind"
]CLAUDE_CONFIG_DIR stays as the Feature sets it; the hook reports
claude-code: /var/lib/devcontainer-features/claude-code (bind, writable)
instead of (volume, writable) in the post-create log.
Two containers on the same config dir at once (one from VS Code, one from
the CLI) contend for .claude.json.lock; run one.
Note: This file was auto-generated from the devcontainer-feature.json. Add additional notes to a NOTES.md.