feat: a herdr session sees the agent state of a dl workspace - #367
Merged
Conversation
herdr shows, per pane, whether the agent in it is working, idle or blocked waiting for a human. Under `aid` it showed none of that: herdr identifies a pane's agent from the pane's foreground process, and the host's tree is `aid -> dl -> ssh` with the agent inside the container. Measured rather than assumed. Two panes, same container, same claude: one running it directly, one behind `script -qc claude /dev/null` (the same pty-proxy shape as the ssh hop). Their detection snapshots are equivalent -- same rendered UI, both carrying the OSC title `* Claude Code` -- and herdr registers an agent for the first and `agent_not_found` for the second. Screen content is not the missing signal; process identity is. herdr takes an answer for that over its socket, so devlaunch supplies it and herdr's own rules do the rest. Three things cross the boundary and nothing else: the socket, bind mounted at /var/tmp/devlaunch-herdr.sock; the pane's identity, on the agent's own command line rather than in workspace environment, because attaching to a running workspace skips the `up` that would refresh it; and a hook wired to claude's SessionStart/UserPromptSubmit/Notification/ Stop/SessionEnd, because a held report nothing updates is a badge that lies. Not herdr's own `integration install claude`: that hook sends only `pane.report_agent_session`, which registers nothing by itself -- proved by calling it against an unregistered pane and still getting `agent_not_found`. Forwarding it would carry session metadata for an agent herdr does not believe exists. The installer is one python3 invocation and shells out to nothing. The version that used `cat > "$hook" <<EOF` installed an *empty* hook in a container without `cat` -- the redirect created the file, cat failed, and the stage reported success -- and then claude ran it on every event. Every command that shape needed is one more thing a container can lack in a way that ends as a wired-up hook doing nothing. On a host not running herdr nothing happens at all: no mount, no stage, no notice, and every command line byte-identical. Decided from whether a socket exists rather than from whether the feature is on, so it is true of the payload rather than only of its effects. `DEVLAUNCH_NO_HERDR=1` opts out; `DEVLAUNCH_NO_TOOLS=1` covers it too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reviewer's GuideIntegrates host-side herdr awareness into devlaunch workspaces so claude sessions started via Sequence diagram for reporting Claude agent state to herdrsequenceDiagram
participant User
participant Aid as aid
participant Container
participant Claude as Claude hooks
participant Herdr
User->>Aid: start Claude session
Aid->>Container: launch agent with HERDR_PANE_ID and container socket path
Claude->>Herdr: pane.report_agent(state: idle)
User->>Claude: submit prompt
Claude->>Herdr: pane.report_agent(state: working)
Claude->>Herdr: pane.report_agent(state: blocked)
Claude->>Herdr: pane.report_agent(state: idle)
Claude->>Herdr: pane.release_agent()
Flow diagram for conditional herdr integrationflowchart TD
Start[Launch workspace] --> Socket{Herdr socket exists?}
Socket -->|No| Unchanged[No mount, hook, or command-line changes]
Socket -->|Yes| OptOut{Herdr opt-out enabled?}
OptOut -->|Yes| Unchanged
OptOut -->|No| Mount[Mount socket into container]
Mount --> Tools{Tool provisioning enabled?}
Tools -->|No| Ready[Workspace launches without herdr hook]
Tools -->|Yes| Install[Setup stage runs Python installer]
Install --> Hook[Install and wire Claude state hook]
Hook --> Ready
File-Level Changes
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
A mount specification is comma-delimited `key=value` pairs, so a socket path holding a comma -- a HOME with one in it is enough -- did not make a wrong mount. It made `docker run` reject its own command line, which fails `devpod up` and with it the launch. The mount is the one part of this feature that a stage's "can never fail a launch" contract does not cover, so the refusal belongs where the socket is resolved: no socket is a state the module already handles everywhere, and it costs a badge rather than a workspace. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…settings
The guard asked whether the claude configuration directory was itself a
mount point. The shape that actually occurs is a mounted *parent*: this
repo's own devcontainer binds ~/.claude/hooks and ~/.claude/agents while
~/.claude stays container-local, and a container that binds ~/.config
with CLAUDE_CONFIG_DIR inside it shares settings.json without ever making
it a mount point. In that case the stage merged hooks into the
developer's real settings, from inside every container, on every launch.
Now the most specific mount covering each path devlaunch writes decides,
and mountinfo's root field says whether that mount is a bind of somewhere
else ("/" for a filesystem mounted whole -- the container's own root, a
tmpfs on /tmp) or a host path. Both written paths are checked, not just
the directory.
Testable because the mount table is now read through
DEVLAUNCH_MOUNTINFO: a test cannot mount anything, and a guard nothing
exercises is a guard that is wrong the first time it matters. Confirmed
red against the exact-match version before the fix landed.
Also renames the README heading that collided with the one
lending_contract.rs pins as unique, which the suite caught.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It said a missing bind source makes the runtime create a directory at the source. That is `-v`. `--mount`, which is what devlaunch emits, refuses with `bind source path does not exist` and fails `devpod up` with it -- so the consequence of skipping the check is a lost launch rather than a mount that carries nothing, and the comment understated its own reason for existing. No test: the defect is a sentence. Verified against docker directly, both spellings, before rewriting it. The note now also names the race the check narrows rather than closes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Verified against a real claude session rather than assumed: a hook entry with no "matcher" key does fire (both spellings were tested side by side), and print mode fires SessionStart, UserPromptSubmit, Stop and SessionEnd -- the reports arrive in that order and the release lands. What the same run turned up is worth documenting. In this repo's own devcontainer, ~/.claude/settings.json is bind-mounted from the host by .devcontainer/claude-code, so the guard refuses and nothing is installed. dl itself never mounts ~/.claude, so an arbitrary repo -- which is what dl launches -- has a container-local one and gets the badge. Confirmed on a nested python:3.12-slim workspace. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The branch was cut from a stale external_integration at 0.6.0. Three files conflicted and two of them mattered. `aid` now starts claude with CLAUDE_CODE_DISABLE_TERMINAL_TITLE=1 so the workspace id dl writes as the terminal title is the name that stands (3703cf7). Both sides are kept: the variable, and the herdr label beside it. The expectation in the new pane test grows the variable too -- the pane's own variables stay a suffix after the agent's, which leaves every command line Python ever produced byte-identical. That change also falsified something this branch claimed twice: that the OSC title crosses the ssh hop and is available to herdr's rules. Under `aid` claude emits no title at all, deliberately. Both the module doc and the README now say so, and say why it costs nothing here -- the title names the workspace, the hook reports the state, and the two never contend for one signal. `Host` gains both new fields; the README keeps both new sections. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Codecov Report❌ Patch coverage is
Additional details and impacted files
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
public-api: the snapshot the repo carries is the reviewed record of devlaunch-core's public surface, and this branch added a module to it without regenerating it. The entries are written by hand in the exact shape cargo-public-api renders -- the container has no nightly to generate them with -- so CI's diff is what confirms them. codespell: a test's fake mountinfo hardcoded a real host username to stand in for a bind source. A stated path serves the test better anyway and says what it is: /home/on-the-host. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…d wrong Two renderings this branch guessed at, both now taken from CI's own diff: a tuple struct with a private field is `HostSocket(_)`, not `HostSocket`, and struct fields are listed alphabetically rather than in declaration order -- which `TableRow` proves independently (id, last_used, size, source against a declaration order of id, source, size, last_used). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This was referenced Aug 22, 2026
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The problem, measured
herdr shows, per pane, whether the coding agent in it is working, idle or blocked waiting for a human. Run
aidin a herdr pane and it shows none of that.The cause is structural, and it was isolated rather than guessed at. Two panes, same container, same claude 2.1.240 — one running it directly, one behind
script -qc claude /dev/null, which is the same pty-proxy shape as dl's ssh hop:w1:p1bash → claudeagent: claude,state: idlew1:p2bash → script → claudeagent_not_foundTheir detection snapshots (
herdr pane read --source detection) are equivalent — same rendered Claude UI — and both panes carry the OSC title✳ Claude Code. So herdr had every screen signal it needed and still registered nothing. Screen content is not the missing signal; process identity is, and underaidthe host tree isaid → dl → sshwith the agent inside the container.Three fixes were ruled out with evidence before writing any code:
herdr --default-confighas no detection or process options.~/.local/state/herdr/agent-detection/remote/claude.toml) contain only state rules keyed on screen regions (osc_title,prompt_box_body, …). There is no process-matching key, so identification is compiled in.--state unknownand letting the manifest drive — the manifest does evaluate correctly on the proxied pane (rule: live_prompt_boxwith real evidence), but a reported state overrides it in the surfaced status and does not decay (flat across 45s of polling). A one-shot kick pins the badge permanently, which is worse than no badge.So the only mechanism is holding
pane.report_agentauthority and keeping it current, which needs something that knows the state.What this adds
Three things cross the container boundary and nothing else:
/var/tmp/devlaunch-herdr.sock(herdr::up_args, followingSharedPixiCache::up_args)HERDR_ENV/HERDR_PANE_ID/HERDR_SOCKET_PATHon the agent's own command line, socket path rewritten to the container's~/.devlaunch/herdr-agent-state.py, wired to claude'sSessionStart,UserPromptSubmit,Notification,Stop,SessionEndNotificationis the event that earns the feature: it is what claude fires when it is waiting for a human.The pane identity goes on the command line, not through
--workspace-env: a pane id is a fact about the session, and attaching to a running workspace skips theupthat would refresh workspace environment — so the container would report this session's state into whatever pane was current when it was last built.Not herdr's own integration
herdr integration install claudeexists, and it is the wrong tool here. Its hook sends onlypane.report_agent_session— session identity for a pane herdr already believes holds an agent. Called against an unregistered pane it registers nothing (agent_not_foundafterwards), so forwarding it would carry session metadata for an agent herdr does not know exists. The reports here are the ones that make the pane appear.The installer shells out to nothing
One
python3invocation does all of it: resolve the config dir, refuse a host-shared one, write the hook, chmod, merge settings. That is the lesson of the version that used shell — it wrote the hook withcat > "$hook" <<EOF, and in a container with nocatthe redirect created the file,catfailed, and the stage reported success having installed an empty hook that claude then ran on every event. Every command that shape needed (cat,mkdir,dirname,chmod,grep) is one more thing a container can lack in a way that ends as a wired-up hook doing nothing. A test asserts the installer names none of them.Nothing changes on a host without herdr
No mount, no stage, no notice, every command line byte-identical. That is decided from whether a herdr socket exists, not from whether the feature is enabled, so it holds of the payload rather than only of its effects — which matters because the stage reports when it cannot work, and a machine that never heard of herdr would otherwise be told about a stage exiting 1 on every launch.
DEVLAUNCH_NO_HERDR=1opts out;DEVLAUNCH_NO_TOOLS=1covers it too; both read the same truthy values asDEVLAUNCH_NO_ZELLIJ.The trade, stated plainly in the README: mounting the socket gives the container control of your herdr session — the same socket its CLI drives. On by default for the reason the forwarded GitHub token is, with an opt-out.
Verified end to end
Against a real nested devpod workspace (
python:3.12-slim) launched as if from a herdr pane:docker runline and the socket issrw-inside the container/root/.devlaunch/herdr-agent-state.pywith asys.executableshebang, wired into all five eventsagent_statusnotificationblockedpromptworkingstopidlesession-endstopwithagent_id(subagent)Tests
19 new tests in
flows::herdr, plus stage-gating inflows::provisionand command-line tests inaid. The substantive ones run the real generated shell and the real hook:python3on PATH, writes the hook, wires every eventStophook plus an untouchedPreToolUsehook and a siblingmodelsetting all survivepython3is missingherdr api schema --json): method names, required params, and theidle/working/blockedspellingscargo test --workspace,cargo clippy --locked --all-targets -- -D warnings,cargo fmt --checkandpixi run ci-no-coverall pass.Follow-ups not in scope
--codex/--geminiget nothing, since the hook installed is claude's. TheAgent.herdrfield makes adding one a one-line change.dl <ws> recreate.ssh -Rforwarding on the openssh transport would make it per-attach and is worth investigating separately.herdr --remote <id>.devpodis the complementary route and needs no devlaunch code at all — documented in the README, since it is the better answer when per-workspace views are acceptable.🤖 Generated with Claude Code
Summary by Sourcery
Enable Claude sessions launched through aid and dl to report their live agent state to the surrounding herdr session.
New Features:
Bug Fixes:
Enhancements:
Documentation:
Tests: