Skip to content

feat: a herdr session sees the agent state of a dl workspace - #367

Merged
blooop merged 8 commits into
mainfrom
feat/herdr-agent-state
Aug 22, 2026
Merged

feat: a herdr session sees the agent state of a dl workspace#367
blooop merged 8 commits into
mainfrom
feat/herdr-agent-state

Conversation

@blooop

@blooop blooop commented Aug 22, 2026

Copy link
Copy Markdown
Owner

The problem, measured

herdr shows, per pane, whether the coding agent in it is working, idle or blocked waiting for a human. Run aid in 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:

Pane Process tree herdr
w1:p1 bash → claude agent: claude, state: idle
w1:p2 bash → script → claude agent_not_found

Their 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 under aid the host tree is aid → dl → ssh with the agent inside the container.

Three fixes were ruled out with evidence before writing any code:

  • A config knobherdr --default-config has no detection or process options.
  • A manifest edit — the detection manifests (~/.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.
  • Registering with --state unknown and letting the manifest drive — the manifest does evaluate correctly on the proxied pane (rule: live_prompt_box with 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_agent authority and keeping it current, which needs something that knows the state.

What this adds

Three things cross the container boundary and nothing else:

The socket the host's herdr socket, bind-mounted at /var/tmp/devlaunch-herdr.sock (herdr::up_args, following SharedPixiCache::up_args)
The pane HERDR_ENV/HERDR_PANE_ID/HERDR_SOCKET_PATH on the agent's own command line, socket path rewritten to the container's
The hook a setup-pass stage installing ~/.devlaunch/herdr-agent-state.py, wired to claude's SessionStart, UserPromptSubmit, Notification, Stop, SessionEnd

Notification is 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 the up that 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 claude exists, and it is the wrong tool here. Its hook sends only pane.report_agent_session — session identity for a pane herdr already believes holds an agent. Called against an unregistered pane it registers nothing (agent_not_found afterwards), 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 python3 invocation 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 with cat > "$hook" <<EOF, and in a container with no cat the redirect created the file, cat failed, 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=1 opts out; DEVLAUNCH_NO_TOOLS=1 covers it too; both read the same truthy values as DEVLAUNCH_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:

  • the mount appears in the docker run line and the socket is srw- inside the container
  • the hook lands at /root/.devlaunch/herdr-agent-state.py with a sys.executable shebang, wired into all five events
  • and driving it from inside the container moved the badge on the host:
event sent herdr agent_status
notification blocked
prompt working
stop idle
session-end agent released
stop with agent_id (subagent) still released — correctly ignored

Tests

19 new tests in flows::herdr, plus stage-gating in flows::provision and command-line tests in aid. The substantive ones run the real generated shell and the real hook:

  • the installer runs with only python3 on PATH, writes the hook, wires every event
  • idempotent across three passes, and a foreign Stop hook plus an untouched PreToolUse hook and a sibling model setting all survive
  • refuses, writing nothing, when the socket or python3 is missing
  • the hook's requests are read off a real unix socket and checked against herdr's own schema (herdr api schema --json): method names, required params, and the idle/working/blocked spellings
  • a subagent's events, an unmapped event, and no-pane-at-all each send nothing

cargo test --workspace, cargo clippy --locked --all-targets -- -D warnings, cargo fmt --check and pixi run ci-no-cover all pass.

Follow-ups not in scope

  • claude only; --codex/--gemini get nothing, since the hook installed is claude's. The Agent.herdr field makes adding one a one-line change.
  • the socket mount lands only at container creation, so pre-existing workspaces need dl <ws> recreate. ssh -R forwarding on the openssh transport would make it per-attach and is worth investigating separately.
  • herdr --remote <id>.devpod is 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:

  • Report Claude agent lifecycle states from containers to the host-side herdr pane, including working, idle, blocked, and session release states.
  • Automatically bind-mount the herdr socket into eligible workspaces and pass the current pane identity to Claude sessions.
  • Add opt-out controls through DEVLAUNCH_NO_HERDR and DEVLAUNCH_NO_TOOLS, while leaving hosts without herdr unchanged.

Bug Fixes:

  • Ensure proxied agents running inside dl workspaces are registered with herdr instead of appearing as agent_not_found.
  • Prevent stale or subagent lifecycle events from incorrectly overriding the main agent's herdr state.

Enhancements:

  • Install and maintain a container-local Claude hook using only python3, preserving existing Claude settings and refusing host-shared configuration paths.
  • Handle missing sockets, Python, configuration, or compatible workspace mounts without failing workspace launches.

Documentation:

  • Document herdr agent-state reporting, its security trade-offs, lifecycle limitations, recreation requirements, and opt-out behavior.

Tests:

  • Add coverage for herdr mounting, session command-line propagation, hook installation and idempotency, settings preservation, refusal cases, request schemas, event-state mapping, and ignored events.

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>

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry @blooop, you have reached your weekly rate limit of 500000 diff characters.

Please try again later or upgrade to continue using Sourcery

@sourcery-ai

sourcery-ai Bot commented Aug 22, 2026

Copy link
Copy Markdown

Reviewer's Guide

Integrates host-side herdr awareness into devlaunch workspaces so claude sessions started via aid can report agent state (idle/working/blocked) back to the originating herdr pane, via a mounted herdr socket, env wiring, and a Python hook installed inside the container, all gated by new herdr-specific switches and opt-out.

Sequence diagram for reporting Claude agent state to herdr

sequenceDiagram
    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()
Loading

Flow diagram for conditional herdr integration

flowchart 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
Loading

File-Level Changes

Change Details Files
Wire herdr pane identity into aid-started agent commands so claude in the container can know which pane to report to, while leaving non-claude agents and non-herdr hosts unchanged.
  • Extend aid argument rewriting to accept an optional herdr session reference and pass it through when building dl arguments and agent command lines.
  • Augment the agent table with an optional herdr label and set it only for the claude agent; codex and gemini explicitly opt out.
  • Add HERDR_ENV, HERDR_PANE_ID, and HERDR_SOCKET_PATH env fragments to the claude command line when a herdr pane is present, and keep commands byte-identical when no pane or no hook exists.
  • Add tests verifying the herdr env injection for claude, absence for other agents, and that command lines remain unchanged without a pane.
rust/aid/src/main.rs
rust/aid/src/rewrite.rs
Introduce a new flows::herdr module that discovers the host herdr socket, represents the current pane session, defines container socket/env conventions, and generates a Python-based installer + hook to translate claude lifecycle events into herdr pane.report_agent / pane.release_agent calls.
  • Define HerdrSwitch and DISABLE_VAR=DEVLAUNCH_NO_HERDR to control whether herdr integration is active, sharing the same truthy/falsey parsing as other provisioning switches.
  • Implement HostSocket discovery via HERDR_SOCKET_PATH or XDG default, validating that the path is an actual Unix socket.
  • Define Session for the current herdr pane based on HERDR_ENV and HERDR_PANE_ID, plus helpers to build the per-session env used on the agent command line.
  • Provide up_args to mount the host herdr socket file into the container at /var/tmp/devlaunch-herdr.sock only when the host has herdr and the switch allows it.
  • Generate a shell wrapper that performs minimal checks (socket present, python3 available) and then runs an embedded Python installer to write the hook script, mark it executable, and merge claude settings.json without clobbering existing hooks.
  • Generate the Python hook that reads claude event JSON from stdin, filters out subagent events, maps a subset of events to idle/working/blocked states, and sends JSON-RPC requests over the Unix socket to herdr with proper method names, parameters, and sequence numbers.
  • Add extensive tests that run the real installer script in a sandboxed env, asserting no reliance on common shell utilities, idempotent merging of hooks, correct schema of emitted herdr requests, ignoring subagent/unmapped events, and inert behaviour when no pane or no socket is present.
rust/devlaunch-core/src/flows/herdr.rs
Plumb herdr awareness through the devlaunch core launch and provision flows so the herdr socket mount and hook-install stage are invoked when appropriate, and add a narrow opt-out independent of general tools provisioning.
  • Extend Host to carry a resolved herdr_socket and herdr switch derived from environment and host socket presence.
  • Introduce HerdrMount to encapsulate herdr-related devpod up flags, and thread it into up_args so the herdr socket bind mount is added only when available and enabled.
  • Add a new HERDR_STAGE in provision that runs the herdr hook installation script during the setup pass, gated by both ToolsSwitch::Install and HerdrSwitch::Report, and with FailureLevel::Info so common failure modes don’t surface as warnings.
  • Extend Switches to include the herdr switch, wire it into setup_stages and setup_pass, and update stage-ordering and outcome tests to include the herdr stage while preserving hostname/zellij behaviour.
  • Add tests confirming that the herdr stage appears only when both tools and herdr switches are on, that it does not affect hostname stages, and that failure level and script embedding are correct.
rust/devlaunch-core/src/flows/launch.rs
rust/devlaunch-core/src/flows/mod.rs
rust/devlaunch-core/src/flows/provision.rs
Expose herdr session reading via the dl crate so aid can depend on it without reaching into devlaunch-core directly, and document the feature and opt-out in the README.
  • Re-export devlaunch_core::flows::herdr from dl so aid can construct Session instances and use session_env via the dl facade.
  • Update README navigation to include an “Agent state in a herdr session” section and describe the feature, its automatic behaviour, prerequisites, opt-outs (DEVLAUNCH_NO_HERDR, DEVLAUNCH_NO_TOOLS), and security trade-offs around mounting the herdr socket.
  • Document that only claude is currently wired, that containers without python3 or with host-shared claude config will skip hook installation, and that pre-existing workspaces require dl <ws> recreate to get the socket mount.
rust/dl/src/lib.rs
README.md

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

blooop and others added 5 commits August 22, 2026 18:40
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

codecov Bot commented Aug 22, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.29032% with 21 lines in your changes missing coverage. Please review.
✅ Project coverage is 95.07%. Comparing base (5b45da5) to head (5e39c45).

Files with missing lines Patch % Lines
rust/devlaunch-core/src/flows/herdr.rs 96.18% 21 Missing ⚠️
Additional details and impacted files
Flag Coverage Δ
python 42.98% <ø> (ø)
rust 95.43% <97.29%> (+0.03%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Components Coverage Δ
shipped code (rust) 95.43% <97.29%> (+0.03%) ⬆️
harness and tooling (python) 42.98% <ø> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

blooop and others added 2 commits August 22, 2026 18:59
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant