The gap
An agent launched by aid is invisible to every host-side session manager, and
the reason is structural rather than a missing feature in any of them.
herdr classifies agent state two ways (docs):
- Screen and process detection — it matches rules against the pane's
terminal output and the foreground process name.
- Lifecycle hooks, which are "authoritative when installed and actively
reporting for the running pane".
Under dl/aid the host pane's foreground process is devpod ssh and its
output is an ssh stream, so (1) has no claude to find. That leaves (2), and
(2) is where this gets interesting: the hook script arrives in the container
and fires, and then has nothing to talk to.
herdr installs ~/.claude/hooks/herdr-agent-state.sh, and the hook reports over
a unix socket (docs):
"$HERDR_BIN_PATH" pane report-agent "$HERDR_PANE_ID" \
--source custom:my-agent --agent my-agent --state working
with HERDR_BIN_PATH, HERDR_PANE_ID, HERDR_SOCKET_PATH and HERDR_ENV=1
set in the pane's environment.
Now cross that with what this repo already does. .devcontainer/claude-code/devcontainer-feature.json:23
mounts ~/.claude/hooks into the container read-only:
source=${localEnv:HOME}/.claude/hooks,target=/home/vscode/.claude/hooks,type=bind,readonly
So for any repo shipping the claude-code feature, herdr-agent-state.sh is
present inside the container and Claude Code runs it. Three of the four things it
needs are absent:
HERDR_BIN_PATH names a host path, so it does not resolve in the container.
HERDR_SOCKET_PATH names a host unix socket, unreachable across the
container's mount namespace.
HERDR_PANE_ID and HERDR_ENV are not forwarded by dl at all.
The failure is silent, which is what makes it expensive: the hook runs, cannot
report, and herdr falls back to screen detection, which is looking at ssh. This
is the same shape as the $ZELLIJ guard in the dotfiles Notification/Stop
hooks — a reporting path that no-ops precisely in the workspaces that are the
whole point of dl.
Why this is worth dl's attention rather than herdr's
Nothing herdr can do fixes it from its side. It cannot reach into a container it
did not create, and the container is created by dl. Meanwhile dl already owns
every mechanism the fix needs, for exactly this class of problem:
So the shape of the change is already established here. What is not established
is whether the transport survives the boundary at all, and that has to be measured
before any of it is built.
Measure first: three unknowns, none of them checked
I could not run these — no herdr, no devpod and no docker in the environment this
was written from. This is the part that wants an agent on the machine, and the
build below should not start until all three are answered.
Prerequisites: herdr installed and running, herdr integration install claude
done, one real workspace, docker available.
-
Is HERDR_SOCKET_PATH stable, or per-run?
herdr ... # inside a herdr pane
echo "$HERDR_SOCKET_PATH" "$HERDR_PANE_ID" "$HERDR_BIN_PATH"
Restart herdr and compare. This decides whether the fix is possible as
stated. A bind mount only lands at container creation
(docs/workspace-tools.md, "Existing workspaces"), so a socket path that
changes per herdr run cannot be served by a mount declared at create time, and
the design has to change to something reachable at attach: a stable
well-known path herdr is pointed at, or a forwarding proxy, or a different
transport entirely.
-
Does the socket accept a peer from another namespace and another uid?
Mount it into a container by hand and report against it:
docker run --rm -it \
-v "$HERDR_SOCKET_PATH:/tmp/herdr.sock" \
-v "$(command -v herdr):/usr/local/bin/herdr:ro" \
ubuntu:24.04 \
env HERDR_SOCKET_PATH=/tmp/herdr.sock \
herdr pane report-agent "<pane id>" --source custom:probe --agent claude --state working
Then check whether that pane's state changed in the herdr UI. The risk worth
naming: if herdr validates the peer with SO_PEERCRED, a container user whose
uid differs from the host user's (vscode is uid 1000, the host user may not
be) is refused. A refusal here is not fatal to the ticket but changes it from
a mount to a proxy.
-
Is reporting against the host pane id the right semantics?
The pane running devpod ssh is where the agent appears to a human, so
reporting the container agent's state against it looks correct, and going to
that row goes to the right place. Confirm herdr does not overwrite it a moment
later from screen detection of the ssh process — i.e. that "authoritative when
installed and actively reporting" beats the screen rules for a pane whose
visible process is not an agent at all. If it does not, HERDR_AGENT=<agent>
(documented for "VMs and wrappers") may be the missing half, and dl would
set it host-side rather than in the container.
Record the answers on this issue before opening a PR. If (1) says per-run, close
this and open a design ticket instead: the rest of the plan is void.
The build, if the measurements allow it
One consent variable in the established style, off by default:
| Variable |
What it does |
DEVLAUNCH_HERDR=1 |
Lend herdr into the workspace, bind-mount the reporting socket, and rewrite HERDR_BIN_PATH/HERDR_SOCKET_PATH to their container paths while forwarding HERDR_PANE_ID and HERDR_ENV |
A consent and not a denial, for the reason DEVLAUNCH_ZELLIJ's doc comment gives:
it costs a mount and a lend, so the default must be that no launch pays for it.
Read it through provisioning_disabled's shared parse so a value spelled the same
way cannot answer differently here than in the other switches.
Two consequences to state in the docs rather than discover:
- This one needs
recreate, not up. The zellij stage lands on a setup pass,
so dl <ws> up is enough for it. A bind mount is not, so an existing workspace
needs dl <ws> recreate — the more expensive verb the zellij page explicitly
says buys nothing there, and the only thing that works here.
- Do not build on
DEVLAUNCH_WORKSPACE_ID. launch.rs:135 is explicit that it
exists for a project's host-side initializeCommand; it is not in the
container's environment. Inside, the workspace id is the hostname
(provision.rs:546).
Scope: herdr only. It is the one manager whose hook protocol is a documented CLI
call over a local socket, so it is the one that can be reached this way at all.
Claude Code's own Agent View is documented as local-only
("Cannot manage remote, cloud, or containerized sessions") and every tmux-and-worktree
manager infers state from host processes, so neither is fixable from this side.
Acceptance
- The three measurements are recorded on this issue, with the commands run and
their output.
- With
DEVLAUNCH_HERDR=1, an aid session in a container shows as working
and then needs input in herdr's agents tab, and selecting its row lands on
the pane holding that workspace.
- With the variable unset, no invocation changes meaning and no launch pays for a
mount or a lend. Pinned by a test, the way the zellij switch is.
- A stale
DEVLAUNCH_HERDR=0 in a profile turns nothing on.
docs/workspace-tools.md gains the capability beside the zellij one, including
the recreate requirement above. Note that flows::provision::lending_contract
reads that file and matches on headings, so a new section moves with its guard.
- The README's environment-variable table names the variable
(test_readme_cli_doc.py), and neither the README nor the new docs prose uses
an em or en dash (test_docs_prose.py).
- No second hand-maintained copy of the variable's name or semantics without a
test beside it diffing the two, per the standing rule in CLAUDE.md.
The gap
An agent launched by
aidis invisible to every host-side session manager, andthe reason is structural rather than a missing feature in any of them.
herdr classifies agent state two ways (docs):
terminal output and the foreground process name.
reporting for the running pane".
Under
dl/aidthe host pane's foreground process isdevpod sshand itsoutput is an ssh stream, so (1) has no
claudeto find. That leaves (2), and(2) is where this gets interesting: the hook script arrives in the container
and fires, and then has nothing to talk to.
herdr installs
~/.claude/hooks/herdr-agent-state.sh, and the hook reports overa unix socket (docs):
with
HERDR_BIN_PATH,HERDR_PANE_ID,HERDR_SOCKET_PATHandHERDR_ENV=1set in the pane's environment.
Now cross that with what this repo already does.
.devcontainer/claude-code/devcontainer-feature.json:23mounts
~/.claude/hooksinto the container read-only:So for any repo shipping the
claude-codefeature,herdr-agent-state.shispresent inside the container and Claude Code runs it. Three of the four things it
needs are absent:
HERDR_BIN_PATHnames a host path, so it does not resolve in the container.HERDR_SOCKET_PATHnames a host unix socket, unreachable across thecontainer's mount namespace.
HERDR_PANE_IDandHERDR_ENVare not forwarded bydlat all.The failure is silent, which is what makes it expensive: the hook runs, cannot
report, and herdr falls back to screen detection, which is looking at ssh. This
is the same shape as the
$ZELLIJguard in the dotfilesNotification/Stophooks — a reporting path that no-ops precisely in the workspaces that are the
whole point of
dl.Why this is worth
dl's attention rather than herdr'sNothing herdr can do fixes it from its side. It cannot reach into a container it
did not create, and the container is created by
dl. Meanwhiledlalready ownsevery mechanism the fix needs, for exactly this class of problem:
ghandclaudeare streamed inas a tar over the
devpod sshchannel already held(
docs/workspace-tools.md, "Tools in every workspace").herdris the samekind of payload.
CLAUDE_CODE_OAUTH_TOKENandGH_TOKENare injected only into the sessionsdlitself opens.DEVLAUNCH_ZELLIJ(Does devlaunch's setup pass earn its per-container zellij install? #391/Gate the zellij stage on DEVLAUNCH_ZELLIJ; retire DEVLAUNCH_NO_ZELLIJ #404) isthe precedent, including the argument for one signal answering both "install
it" and "wire it up".
So the shape of the change is already established here. What is not established
is whether the transport survives the boundary at all, and that has to be measured
before any of it is built.
Measure first: three unknowns, none of them checked
I could not run these — no herdr, no devpod and no docker in the environment this
was written from. This is the part that wants an agent on the machine, and the
build below should not start until all three are answered.
Prerequisites: herdr installed and running,
herdr integration install claudedone, one real workspace, docker available.
Is
HERDR_SOCKET_PATHstable, or per-run?Restart herdr and compare. This decides whether the fix is possible as
stated. A bind mount only lands at container creation
(
docs/workspace-tools.md, "Existing workspaces"), so a socket path thatchanges per herdr run cannot be served by a mount declared at create time, and
the design has to change to something reachable at attach: a stable
well-known path herdr is pointed at, or a forwarding proxy, or a different
transport entirely.
Does the socket accept a peer from another namespace and another uid?
Mount it into a container by hand and report against it:
Then check whether that pane's state changed in the herdr UI. The risk worth
naming: if herdr validates the peer with
SO_PEERCRED, a container user whoseuid differs from the host user's (
vscodeis uid 1000, the host user may notbe) is refused. A refusal here is not fatal to the ticket but changes it from
a mount to a proxy.
Is reporting against the host pane id the right semantics?
The pane running
devpod sshis where the agent appears to a human, soreporting the container agent's state against it looks correct, and going to
that row goes to the right place. Confirm herdr does not overwrite it a moment
later from screen detection of the ssh process — i.e. that "authoritative when
installed and actively reporting" beats the screen rules for a pane whose
visible process is not an agent at all. If it does not,
HERDR_AGENT=<agent>(documented for "VMs and wrappers") may be the missing half, and
dlwouldset it host-side rather than in the container.
Record the answers on this issue before opening a PR. If (1) says per-run, close
this and open a design ticket instead: the rest of the plan is void.
The build, if the measurements allow it
One consent variable in the established style, off by default:
DEVLAUNCH_HERDR=1herdrinto the workspace, bind-mount the reporting socket, and rewriteHERDR_BIN_PATH/HERDR_SOCKET_PATHto their container paths while forwardingHERDR_PANE_IDandHERDR_ENVA consent and not a denial, for the reason
DEVLAUNCH_ZELLIJ's doc comment gives:it costs a mount and a lend, so the default must be that no launch pays for it.
Read it through
provisioning_disabled's shared parse so a value spelled the sameway cannot answer differently here than in the other switches.
Two consequences to state in the docs rather than discover:
recreate, notup. The zellij stage lands on a setup pass,so
dl <ws> upis enough for it. A bind mount is not, so an existing workspaceneeds
dl <ws> recreate— the more expensive verb the zellij page explicitlysays buys nothing there, and the only thing that works here.
DEVLAUNCH_WORKSPACE_ID.launch.rs:135is explicit that itexists for a project's host-side
initializeCommand; it is not in thecontainer's environment. Inside, the workspace id is the hostname
(
provision.rs:546).Scope:
herdronly. It is the one manager whose hook protocol is a documented CLIcall over a local socket, so it is the one that can be reached this way at all.
Claude Code's own Agent View is documented as local-only
("Cannot manage remote, cloud, or containerized sessions") and every tmux-and-worktree
manager infers state from host processes, so neither is fixable from this side.
Acceptance
their output.
DEVLAUNCH_HERDR=1, anaidsession in a container shows as workingand then needs input in herdr's agents tab, and selecting its row lands on
the pane holding that workspace.
mount or a lend. Pinned by a test, the way the zellij switch is.
DEVLAUNCH_HERDR=0in a profile turns nothing on.docs/workspace-tools.mdgains the capability beside the zellij one, includingthe
recreaterequirement above. Note thatflows::provision::lending_contractreads that file and matches on headings, so a new section moves with its guard.
(
test_readme_cli_doc.py), and neither the README nor the new docs prose usesan em or en dash (
test_docs_prose.py).test beside it diffing the two, per the standing rule in
CLAUDE.md.