Skip to content

dl carries two tokens into a workspace and nothing else, so no session manager can see the agent it started #548

Closed
0 of 1 issue completed

Description

@blooop

The gap

Every session manager worth using — herdr, Agent View, gh-issues-tui, the
tmux-and-worktree family — is blind to an agent dl started, and all of them
fail the same way for the same reason. This is the priority: dl is the thing
that can fix it, once, for all of them.

A manager learns what an agent is doing by one of two routes:

  1. Process and screen inspection on the host. Under dl the foreground
    process is ssh and the pane is an ssh stream, so the process half finds no
    agent. (The screen half may fare better than expected — see the measurement.)

  2. A hook the agent runs, reporting to the manager. herdr's is
    representative and is the honest general case: a command, with coordinates in
    the environment, talking to a unix socket:

    "$HERDR_BIN_PATH" pane report-agent "$HERDR_PANE_ID" --state working

    with HERDR_BIN_PATH, HERDR_PANE_ID, HERDR_SOCKET_PATH, HERDR_ENV=1.

Route 2 is the one that can work through a container, and dl blocks all three
of its parts: the binary is a host path, the socket is a host socket, and the
coordinates are not forwarded. dl forwards exactly two environment variables,
both hardcoded and both credentials (GH_TOKEN, CLAUDE_CODE_OAUTH_TOKEN), and
forwards no sockets at all.

The failure is silent every time, which is what makes it expensive: with repos
shipping the claude-code feature, ~/.claude/hooks is mounted read-only into
the container, so the manager's hook script is present and does run — and
then finds nothing to talk to.

The mechanism is already here, and it was built anticipating this

Two facts from clients/ssh.rs change the shape of this from "new subsystem" to
"widen an existing one":

The permit list is already a list. send_env: &[String] is threaded through
to the ssh argv as -o SendEnv=<name> per entry, and into control_key, which
length-prefixes every field with a comment written against exactly this change:

a collision here is exactly the silent cross-permit-list reuse this key exists
to prevent. The prefixes are what make that impossible by construction rather
than by an argument about what an alias or an environment variable name is
allowed to contain — which is the kind of argument that stops holding the day
somebody widens one of them.

Today the list holds one name. Widening it is the mechanism working as designed,
and master reuse is already keyed on it, so two different permit lists cannot
share a socket.

The transport is real OpenSSH, so sockets can cross without a mount.
PROGRAM = "ssh", over the alias devpod publishes whose ProxyCommand tunnels
devpod ssh --stdio. That means -R /container/path:/host/path unix-socket
forwarding is available on this path.

That supersedes the bind mount proposed in #547, and is strictly better:
a bind mount lands only at container creation, so every existing workspace would
need dl <ws> recreate. A remote forward is negotiated per connection, so it
works on attach, on a container that has been running for a week, with nothing
to recreate. #547 should become a consumer of this map rather than its own
design.

Proposed shape

Two switches, both taking values, in the style of DEVLAUNCH_AID_AGENT rather
than the boolean ones:

Variable What it does
DEVLAUNCH_FORWARD_ENV=NAME[,NAME...] Add names to the ssh SendEnv permit list for this launch
DEVLAUNCH_FORWARD_SOCKET=/host/path[:/container/path] Forward a unix socket into the workspace with ssh -R

Plus reuse of the existing lend path (the one that streams gh and claude
in as a tar over the channel already held) for the manager's own CLI, so
$..._BIN_PATH resolves inside.

Set those three and any manager's hook protocol works, with no per-manager code
in dl and no cooperation from the manager. herdr is the first consumer; nothing
about the facility knows its name.

Measure first

None of this is checked; there is no herdr, devpod or docker in the environment
this was written from. An agent on the machine should answer these before any
code
, and the first two can each void the design.

  1. Does the sshd accept arbitrary names? SendEnv is only half a handshake —
    the server must AcceptEnv them or they are dropped silently. GH_TOKEN
    arrives today, so either devpod's sshd sets AcceptEnv * or something
    configures it. Find out which, because "it works for GH_TOKEN" does not imply
    it works for HERDR_PANE_ID. Check the container's /etc/ssh/sshd_config
    and confirm a forwarded novel name actually lands in the remote environment.
  2. Does -R unix forwarding survive the ProxyCommand? Forward any socket
    into a live workspace and connect to it from inside. A ProxyCommand
    transport carries the ssh protocol, so remote forwards should work, but this
    has never been exercised here.
  3. Does the forward interact with ControlMaster? A remote forward belongs
    to the master connection, so a later trip joining an existing master does
    not get its own forward. control_key must take the forward spec as a
    field exactly as it takes send_env, or a second launch silently inherits the
    first's socket — the precise failure the length-prefixing comment describes.
  4. Is screen detection already working? Cheapest possible outcome, and worth
    testing before building anything. dl <ws> -- claude pipes the agent's own
    TUI through the pane, so a manager matching rules against screen content may
    already classify correctly and only the process-name half may be failing. If
    so, herdr's HERDR_AGENT=<agent> (documented for "VMs and wrappers") set
    host-side may be the entire fix, at zero cost to dl.

If (4) answers yes, most of this map is unnecessary. Run it first.

Scope and constraints

  • A credential is not a coordinate. The two existing forwards are secrets
    and go through a private file and a permit list for that reason. These are
    not, but the same care applies in reverse: an arbitrary caller-supplied name
    list must not become a way to sweep the host's environment into a container a
    repo you did not write can read. Decide whether names are allowlisted by
    pattern, and say why in the code.
  • Which transports get it. There are two: real ssh for anything needing a
    pty, and the devpod transport for one-shots. Socket forwarding exists only on
    the first. Say plainly which launches carry the facility.
  • Off by default, no launch pays for an unused forward, and a stale =0 turns
    nothing on. Pinned by a test, as DEVLAUNCH_ZELLIJ is.
  • The README env table and docs/workspace-tools.md gain the variables;
    flows::provision::lending_contract reads that file by heading, and
    test_docs_prose.py bans em and en dashes there.

Activity

  1. blooop commented on Sep 1, 2026

    @blooop
    OwnerAuthor

    Measured on the machine (#549)

    Run on the host ags-dell, 2026-09-01, against a scratch workspace
    devlaunch-main-3j1t created with XDG_CACHE_HOME=/tmp/dl-measure/cache dl blooop/devlaunch up. herdr 0.8.2 (stable) was fetched to the scratch tree and run
    as herdr server (headless); its socket API is what every state reading below
    comes from, so nothing here rests on me reading a screenshot correctly.

    Released dl throughout: 0.25.0, which matters once, in E4.

    Transport confirmed first, as instructed:

    $ ssh -F ~/.ssh/config devlaunch-main-3j1t.devpod 'echo inside; hostname; id -u; id -un'
    inside
    devlaunch-main-3j1t
    1000
    vscode
    

    E1 — screen detection is already working. Say it loudly.

    Yes. With HERDR_AGENT=claude set host-side, herdr classifies a dl workspace
    agent correctly and continuously, with no change to dl at all.
    Not "partially",
    not "the idle case": idle, working and blocked all land, from Claude's own screen,
    through ssh, through devpod, through the container.

    The two runs differ in one env word.

    Without it. Launch dl <ws> -- claude in a herdr pane and poll for 40s:

    t=5s   agent=[None unknown]  fg=['dl', 'ssh', 'devpod', 'devpod']
    t=40s  agent=[None unknown]  fg=['dl', 'ssh', 'devpod', 'devpod']
    

    The process half fails exactly as #548 predicts. But the pane's screen at that
    moment held the real thing:

     ▐▛███▛█   Claude Code v2.1.257
    ▝▜██████▀  Opus 5 (1M context) · Claude Team
      ▝▝ ▝▝    /workspaces/devlaunch-main-3j1t
    ...
    ❯
    

    That is live_prompt_box's exact shape and herdr still said unknown, because it
    never decided the pane was a claude pane. The rules were never consulted.

    The sharpest version of that control: I drove the same session into an
    unmistakably blocked state and asked herdr what it saw.

     ☐ Colour
    Which colour do you prefer: red or blue?
    ❯ 1. Red
      2. Blue
    Enter to select · ↑/↓ to navigate · Esc to cancel
    
    t=05s None unknown | explain={"error":{"code":"agent_not_found", ...
    t=50s None unknown | explain={"error":{"code":"agent_not_found", ...
    

    A human is being waited on and the manager cannot see it. That is the bug, stated
    as precisely as I can state it.

    With it. env HERDR_AGENT=claude dl <ws> -- claude, same pane, same everything:

    ### after launch: agent= claude status= idle
    

    Transitions, polled every 3s with herdr's own agent explain beside each reading:

    t=03s idle     rule: live_prompt_box (region=prompt_box_body priority=950)
    t=06s working  rule: live_turn_working (region=bottom_non_empty_lines(12) priority=970)
    ...
    t=45s working  rule: live_turn_working (region=bottom_non_empty_lines(12) priority=970)
    

    and into blocked:

    t=04s working  rule: live_turn_working (region=bottom_non_empty_lines(12))
    t=12s blocked  rule: live_blocked_form (region=after_last_horizontal_rule)
    ...
    t=48s blocked  rule: live_blocked_form (region=after_last_horizontal_rule)
    

    aid behaves identically (aid devlaunch-main-3j1t "..." → claude working →
    claude idle), which is expected since it is dl <ws> -- <agent> underneath, but
    worth having measured rather than assumed.

    Two things fell out of this that nobody asked for and both are useful.

    explain names the rule that fired. Every state above is attributable to a
    manifest rule by id, so a future regression is diagnosable rather than a shrug.

    The pane title is already the workspace id. herdr reports
    terminal_title_stripped: devlaunch-main-3j1t on the pane throughout. dl is
    already publishing one coordinate any manager can read; it is the agent identity
    and only the agent identity that is missing.

    What this does to the map

    #548 reduces to documenting one host-side environment variable. E2 to E4 are moot
    for herdr. I ran them anyway because they were written and because two of them
    turned out to carry information that changes the design if it is ever built, but
    nothing below is a reason to build it.

    One caveat worth putting in #548 rather than losing: HERDR_AGENT fixes herdr,
    by name. A manager without an equivalent override is still blind, and the fix is
    per-manager configuration rather than anything dl provides. If that is
    acceptable, the map is done. Note also that aid already knows which agent it is
    starting (--claude/--codex/--gemini), so it could set the variable itself
    and make even the documentation unnecessary. That would put a herdr-specific name
    in dl, which #548 explicitly does not want. I am not deciding that here; I am
    saying the option exists and costs one line.

    E2 — the question does not apply, and the answer is yes anyway

    #548 sentence that is wrong: "so either devpod's sshd sets AcceptEnv * or
    something configures it. ... Check the container's /etc/ssh/sshd_config".

    There is no sshd.

    $ ssh ... 'ls -l /etc/ssh/sshd_config /etc/ssh/sshd_config.d/; command -v sshd'
    ls: cannot access '/etc/ssh/sshd_config': No such file or directory
    ls: cannot access '/etc/ssh/sshd_config.d/': No such file or directory
    bash: line 1: sshd: command not found
    
    $ ssh -v ... true
    debug1: Remote protocol version 2.0, remote software version Go
    

    The far end is devpod's own agent speaking SSH in Go. AcceptEnv is an OpenSSH
    sshd_config keyword and there is no sshd_config to hold it, so the permit-list
    question has no configuration to inspect. What it has instead is behaviour, and
    the behaviour is permissive:

    $ HERDR_PANE_ID=probe-42 ssh -o SendEnv=HERDR_PANE_ID ... 'echo "[${HERDR_PANE_ID:-UNSET}]"'
    [probe-42]
    
    $ ... -o SendEnv=HERDR_PANE_ID -o SendEnv=HERDR_SOCKET_PATH -o SendEnv=HERDR_ENV -o SendEnv=HERDR_BIN_PATH
    HERDR_BIN_PATH=/usr/local/bin/herdr
    HERDR_ENV=1
    HERDR_PANE_ID=probe-42
    HERDR_SOCKET_PATH=/tmp/x.sock
    
    $ FOO_ONE=a FOO_TWO=b ssh -o 'SendEnv=FOO_*' ...
    FOO_ONE=a
    FOO_TWO=b
    

    Wildcards included. Verdict: novel names arrive; #548's env half needs nothing
    from the server.

    Two controls, because "it arrived" is worth nothing without them:

    ### exported on the host, no SendEnv asked for
    [UNSET]
    ### SendEnv asked for, not exported on the host
    [UNSET]
    

    So there is no wholesale host-environment leak through the stdio tunnel, and
    SendEnv is genuinely the thing that carried it. The container session has 23
    environment variables, not the host's.

    And the premise behind the E2 question was right to be suspicious

    #549 warned that "GH_TOKEN arriving today proves nothing about a novel name: it
    may be arriving because something configures that one specifically." Stronger than
    that: GH_TOKEN is not arriving by SendEnv at all. A bare ssh with no
    SendEnv and an empty GH_TOKEN on the host still finds it set inside:

    $ ssh ... 'echo "GH_TOKEN len=${#GH_TOKEN}"'      # host GH_TOKEN is empty
    GH_TOKEN len=40
    

    It lives in a file devpod writes at container creation:

    $ docker exec -u 0 <container> cat /etc/envfile.json
    {"env":{"GH_TOKEN":"<redacted>","PATH":"...","PIXI_CACHE_DIR":"/var/tmp/devlaunch-pixi"}}
    $ ls -l /etc/envfile.json
    -rw-r--r-- 1 root root 176
    

    Three consequences worth carrying into #548:

    I saw SendEnv=GH_TOKEN and nothing else on the wire:

    SSH command: ssh -F /home/ags/.ssh/config -t -o SendEnv=GH_TOKEN devlaunch-main-3j1t.devpod bash -lc 'claude ...'
    

    CLAUDE_CODE_OAUTH_TOKEN was absent because the host had none set; claude inside
    authenticated from the mounted ~/.claude. So #548's "exactly two environment
    variables" is right about the code and, on this machine, one of the two never
    travels.

    E3 — -R unix forwarding works, end to end, first try

    Verdict: pass, and not narrowly. The socket arrives, the herdr CLI running
    inside the container drives the herdr server on the host through it, and the
    host's pane state changes as a result.

    Transport:

    $ ssh -R /tmp/probe.sock:$HERDR_SOCKET_PATH -o StreamLocalBindUnlink=yes ... 'ls -l /tmp/probe.sock'
    srwxrwxr-x 1 vscode vscode 0 /tmp/probe.sock
    

    There is no nc, no socat and no python3 inside the container, so I lent the
    herdr binary in over the same channel (ssh ... 'cat > /tmp/herdr'), which is the
    lend path #548 wants anyway and it works unchanged:

    $ ssh ... '/tmp/herdr --version'
    herdr 0.8.2
    

    Then the protocol, from inside:

    $ ssh -R /tmp/probe.sock:$HERDR_SOCKET_PATH ... 'HERDR_SOCKET_PATH=/tmp/probe.sock /tmp/herdr pane list'
    {"id":"cli:pane:list","result":{"panes":[{"agent_status":"unknown","pane_id":"w8:p1",...}]}}
    rc=0
    

    Then the map's actual acceptance test:

    ### before
    {"agent_status": "unknown", "pane_id": "w8:p1"}
    
    ### from inside the container
    HERDR_SOCKET_PATH=/tmp/probe.sock /tmp/herdr pane report-agent w8:p1 \
      --source custom:probe --agent claude --state working
    rc=0
    
    ### after
    {"agent": "claude", "agent_status": "working", "pane_id": "w8:p1"}
    

    Route 2 works today, over stock OpenSSH, with nothing added to dl.

    Taking the three named failure modes in turn:

    • AllowStreamLocalForwarding is not a factor. Not configurable here and not
      refused; there is no sshd (E2).
    • Peer credentials are not a factor. herdr does not appear to enforce
      SO_PEERCRED: the call succeeds from container uid 1000 and from uid 0
      (sudo -n ... pane list → rc=0), while the host user is uid 1000. So the uid
      mismatch Measure whether a session manager can already see into a workspace, before anything is built for it #549 anticipated would not have bitten even if the uids had differed.
    • Remote path creation is the one that bites, and it bites silently. See below.

    Three hazards the design has to handle

    1. A failed forward is invisible under dl's own alias. Forwarding to a path
    the container user cannot create fails, and devpod's ssh config sets
    LogLevel error, which is below the level the warning is emitted at:

    $ ssh -R /probe-root.sock:... ...           # LogLevel error, as the alias sets
    (nothing)
    $ ssh -o LogLevel=INFO -R /probe-root.sock:... ...
    Warning: remote port forwarding failed for listen path /probe-root.sock
    remote-ran                                   # and the session continues regardless
    

    The fix is one option, and it is clean in both directions:

    $ ssh -o ExitOnForwardFailure=yes -R /probe-root.sock:... ...
    Error: remote port forwarding failed for listen path /probe-root.sock
    ssh-exit=255
    $ ssh -o ExitOnForwardFailure=yes -R /tmp/probe.sock:... ...
    remote-ran
    ssh-exit=0
    

    DEVLAUNCH_FORWARD_SOCKET must set ExitOnForwardFailure=yes or it will fail the
    way #548 says this class of bug always fails.

    2. The remote listen path is destroyed, not refused. devpod's Go server does
    not implement OpenSSH's "refuse to bind over an existing path" rule. Without
    StreamLocalBindUnlink it overwrites whatever is there:

    $ ssh ... 'echo PRECIOUS > /tmp/precious.txt; cat /tmp/precious.txt'
    PRECIOUS
    $ ssh -R /tmp/precious.txt:$HERDR_SOCKET_PATH ... 'ls -l /tmp/precious.txt; cat /tmp/precious.txt'
    srwxrwxr-x 1 vscode vscode 0 /tmp/precious.txt
    cat: /tmp/precious.txt: No such device or address
    

    A user-supplied container path in DEVLAUNCH_FORWARD_SOCKET=/host:/container is
    therefore a file-deleting primitive, and StreamLocalBindUnlink=yes makes it more
    so rather than less. Whatever validation the "a credential is not a coordinate"
    paragraph lands on needs to cover the destination half too, not just the names.

    3. A dead socket is left behind. When the master goes, the socket file stays
    in the container and stops answering:

    $ ssh -O exit ...; ssh ... 'ls -l /tmp/sockA /tmp/sockB; ...'
    srwxrwxr-x 1 vscode vscode 0 /tmp/sockA
    srwxrwxr-x 1 vscode vscode 0 /tmp/sockB
    /tmp/sockA FAIL(rc=1)
    /tmp/sockB FAIL(rc=1)
    

    Self-healing on the next launch that forwards (StreamLocalBindUnlink=yes), but a
    hook firing between launches connects to a corpse. Since ~/.claude/hooks is
    mounted read-only into every workspace and the hook does run, that is a real
    window, not a theoretical one.

    E4 — the prediction is wrong, and the conclusion survives anyway

    #548 sentence that is wrong: "A remote forward belongs to the master
    connection, so a later trip joining an existing master does not get its own
    forward." It does.

    Trip 1 opens the master with a forward to A only. Trip 2 joins it and asks for B:

    ### trip 1 (-R /tmp/sockA), ControlMaster=auto ControlPath=... ControlPersist=60
    ls: cannot access '/tmp/sockB': No such file or directory
    srwxrwxr-x 1 vscode vscode 0 /tmp/sockA
    
    ### trip 2, SAME ControlPath, -R /tmp/sockB
    srwxrwxr-x 1 vscode vscode 0 /tmp/sockA
    srwxrwxr-x 1 vscode vscode 0 /tmp/sockB
    --- which works:
    /tmp/sockA OK
    /tmp/sockB OK
    

    Multiplexing verified, not assumed:

    $ ssh -O check ...
    Master running (pid=18043)
    $ ssh -v ... 'true'
    debug1: auto-mux: Trying existing master at '/tmp/dl-measure/ctl/m1'
    debug1: mux_client_request_session: master session id: 2
    

    B was honoured. The real failure is the other direction, and it is worse.
    Forwards accumulate on the master and outlive the trip that asked for them. Trip
    3, which asks for no forward at all, gets both:

    ### trip 3, same master, no -R whatsoever
    srwxrwxr-x 1 vscode vscode 0 /tmp/sockA
    srwxrwxr-x 1 vscode vscode 0 /tmp/sockB
    --- reachable:
    /tmp/sockA OK
    /tmp/sockB OK
    

    So the concrete dl failure is not "launch 2 silently inherits launch 1's socket
    instead of its own". It is "launch 2, which asked for nothing, silently gets
    launch 1's socket
    ", for up to CONTROL_PERSIST seconds. That breaks the map's
    own "off by default, no launch pays for an unused forward" constraint at the
    granularity of the master's lifetime, and it breaks it invisibly.

    The ticket's instruction stands, in as many words: #548 must take the forward
    spec as a control_key field.
    The reasoning in the length-prefixing comment
    transfers exactly and only needs its noun changed. A master does not filter
    forwards against the joining client's request the way it filters SendEnv; it
    simply keeps every forward any client ever asked it for. Keying the socket on the
    forward spec is what makes a no-forward launch unable to find a master that has
    one, which is the same "unrepresentable rather than documented" move send_env
    already gets.

    One version note that matters for reading this section. Multiplexing landed in
    v0.26.0 (18edab0 feat: one reusable OpenSSH connection per workspace); the
    released dl on this machine is 0.25.0 and prints

    SSH command: ssh -F /home/ags/.ssh/config -t -o SendEnv=GH_TOKEN devlaunch-main-3j1t.devpod bash -lc '...'
    

    with no ControlMaster at all. E4 was therefore measured with the option set
    clients::ssh::command_args builds, by hand, rather than by watching 0.25.0 do
    it. The result applies from 0.26.0 onward, which is where the hazard exists.

    Summary

    Question Verdict
    E1 Is screen detection already working? Yes, with HERDR_AGENT. The map collapses to documenting one variable.
    E2 Does the sshd accept arbitrary names? There is no sshd. Novel names and wildcards arrive regardless. GH_TOKEN never used this route.
    E3 Does -R survive the ProxyCommand? Yes, end to end, including the real report-agent call. Three hazards, all fixable.
    E4 Does a forward collide with ControlMaster? Not as predicted. Forwards accumulate and leak to launches that asked for none. control_key must take the spec.

    Cleanup

    devlaunch-main-3j1t deleted (dl ... rm --force, container 13ba517aed25
    removed), /tmp/dl-measure removed, herdr server stopped. devpod list is back to
    the same eight workspaces it held before this ran:

    devlaunch-kill-4real-558v
    devlaunch-remote-control-i4ln
    devlaunch-table-names-ketirobe
    devlaunch-test1-ucbw
    devlaunch-test2-9xr5
    devlaunch-wayfinder-devlaunch-296-1ifd
    devlaunch-wayfinder-devlaunch-300-k743
    devlaunch-wayfinder-devlaunch-444-7gal
    

    docker ps shows only the pre-existing dagger engine, and ~/.ssh/config holds no
    entry for the scratch workspace. dl --purge was not run at any point.

    Two things this did touch and did not put back, both under ~/.config/herdr and
    ~/.local/state/herdr, which already existed from a previous herdr install:
    starting the server refreshed the agent-detection manifests (claude
    2026.08.21.1 → 2026.08.29.1, plus codex and copilot) and rewrote
    session.json. Both are herdr's own state, not this repo's, and the refresh is
    what any herdr start would have done.

  2. blooop commented on Sep 1, 2026

    @blooop
    OwnerAuthor

    Built, and the decision the measurement left open

    #550. Two things this closes that
    the measurement comment above deliberately left hanging.

    The decision: aid sets the variable, and the name is hardcoded

    The measurement said, and then refused to settle:

    aid already knows which agent it is starting (--claude/--codex/--gemini),
    so it could set the variable itself and make even the documentation
    unnecessary. That would put a herdr-specific name in dl, which #548 explicitly
    does not want. I am not deciding that here.

    It is decided now: aid sets it, unconditionally, and HERDR_AGENT is written
    into rust/aid/src/main.rs as a constant.

    That is a deviation from this issue's own constraint, so it should be on the record
    as one rather than discovered later in the diff. The constraint reads:

    Set those three and any manager's hook protocol works, with no per-manager code
    in dl and no cooperation from the manager. herdr is the first consumer; nothing
    about the facility knows its name.

    That sentence is an argument for the generic forwarding facility: given the
    facility, no manager needs naming. The measurement removed the facility, and with
    it the thing that was buying the anonymity. What is left is one name in one
    constant, or a documented variable every user exports by hand on every machine.
    The trade was made in favour of the name.

    Two smaller choices went the same way and are worth naming, because both invert
    this issue's stated posture of "off by default":

    • Written on every launch, with no check for whether a manager is running.
      Gating on HERDR_ENV=1 is a second thing to be wrong about, and being wrong
      about it fails silently, which is the failure mode this whole issue is about. A
      machine with no herdr pays one setenv for a name nothing reads.
    • Written over a value that is already there. aid is what decides which agent
      starts, so it is the only thing that can be right about it: a HERDR_AGENT=codex
      left in a profile is wrong the moment aid --claude runs, and a manager that
      believed it would match codex's rules against Claude's screen and classify
      nothing.

    Plain dl still sets nothing, because plain dl has no agent to name.

    The evidence for the mechanism, which the report above does not contain

    The measurement above ran env HERDR_AGENT=claude dl <ws> -- claude, where the
    value is in the top process's environment from its own exec. That is the opposite
    of the path that shipped
    , and the difference is load-bearing: aid calls
    dl::run in-process, so it can only setenv, and a setenv after start does not
    rewrite /proc/<pid>/environ, which the kernel fixes at exec. The plausible
    version of this change is a silent no-op.

    So it was measured again, after the change, with nothing in the launching
    environment. Reading HERDR_AGENT out of /proc/<pid>/environ for each of the
    pane's foreground processes:

      aid    (pid 37015): (absent)
      ssh    (pid 37102): HERDR_AGENT=claude
      devpod (pid 37104): HERDR_AGENT=claude
      devpod (pid 37116): HERDR_AGENT=claude
    

    aid's own row carries nothing, exactly as the setenv semantics predict. The
    ssh it spawns carries the name, because that child builds its environment at its
    own exec. herdr walks the pane's foreground processes rather than reading only the
    shell, which is what makes the child enough.

    The behaviour that follows, patched aid, empty environment:

    t=05s agent=None    status=unknown | ['aid', 'devpod']
    t=10s agent=claude  status=working | ['aid', 'ssh', 'devpod', 'devpod']
    t=15s agent=claude  status=idle    | ['aid', 'ssh', 'devpod', 'devpod']
    

    The None at t=05s is the mechanism showing its own seam: before ssh exists
    there is nothing carrying the name. And into a blocking prompt:

    t=04s claude blocked | rule: live_blocked_form (region=after_last_horizontal_rule)
    

    No test in the tree holds any of this, and none can, since it is a fact about a
    running herdr, a pty and a container. The unit tests hold the part that is in the
    process: that every name in aid's table is written verbatim, that a stale value is
    replaced, and that a line starting no agent leaves the environment alone. This
    comment is the record for the rest.

    The scratch workspace was deleted afterwards and devpod list is back to the eight
    it held before.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions