Browse running Coder agent sessions in herdr, and open each one as its own workspace.
Coder task workspaces have no tmux or screen: the agent runs under
agentapi, which owns the PTY and exposes it
over HTTP on port 3284. agentapi attach renders that screen but has no
viewport, so nothing scrolls. This plugin builds a workspace per session around
agentty, which adds scrolling, mouse wheel, a caret, and
local echo over the same endpoints.
herdr plugin install ubuntudroid/herdr-coder-sessionsNothing to build: two Python files, standard library only. See Requirements for what has to be on the machine, and Configuration for the one setting most people need to change.
Each session gets one workspace, named in the sidebar by tokens:
┌───────────────────────────┬───────────────────┐
│ agentty <session>.coder │ reviewr │
│ the agent's live screen │ the session's │
│ │ changes, mirrored │
└───────────────────────────┴───────────────────┘
The review pane is reviewr, and it is optional —
without it you get the agent pane alone. Need a shell on the box? ssh <session>.coder in any
pane.
Picking a session that is already open focuses its workspace instead of building
a second one; those rows are marked ● in the list.
The list shows every Coder task whose workspace is running, with the agent's
last reported state and message. enter opens or focuses, ctrl-o opens the
highlighted session in the Coder web UI, ctrl-r refreshes,
and the preview pane shows the full report plus the prompt the session started
from.
Bind the action in ~/.config/herdr/config.toml:
[[keys.command]]
key = "prefix+shift+c"
type = "plugin_action"
command = "ubuntudroid.coder-sessions.pick"
description = "Coder agent sessions"
[[keys.command]]
key = "prefix+ctrl+m"
type = "plugin_action"
command = "ubuntudroid.coder-sessions.refresh"
description = "refresh this Coder mirror"
[[keys.command]]
key = "prefix+ctrl+w"
type = "plugin_action"
command = "ubuntudroid.coder-sessions.web"
description = "open this Coder session in the web UI"
[[keys.command]]
key = "prefix+ctrl+t"
type = "plugin_action"
command = "ubuntudroid.coder-sessions.takeover"
description = "take this Coder session over locally"The second key refreshes the focused session's mirror, and in a workspace that has none yet it moves the session into one — see Mirroring. The third opens the focused session's page in the Coder web UI — see The web UI. The fourth ends the remote flow and continues it locally — see Take over locally.
A plugin action runs with no terminal, so anything it prints goes nowhere anyone sees.
The take-over key therefore re-opens itself in a popup: what it prints is on screen,
a failure waits for a key before the popup closes, and a session without a branch
gets asked which one to create — see Take over locally. The
refresh key stays a bare action and says what it is doing in a herdr notification
instead: one when the ssh work starts, because it spends seconds on the network before
there is anything to look at, and one when it finishes. A failure from a keypress
notifies as well, and every failure lands in coder-sessions.log under
HERDR_PLUGIN_STATE_DIR. The idle hook's own refresh runs through --mirror and says
nothing at all, failures included: a mirror that cannot be built yet would otherwise
notify after every agent turn. One notification per keypress, never one per turn.
Or, without installing the plugin, point a popup straight at the script:
[[keys.command]]
key = "prefix+shift+c"
type = "popup"
command = "python3 /path/to/herdr-coder-sessions/coder-sessions.py"
description = "Coder agent sessions"
width = "90%"
height = "80%"The script also works standalone:
./coder-sessions.py # picker
./coder-sessions.py --list # rows only
./coder-sessions.py --open <name> # open or focus one session
./coder-sessions.py --web [<name>] # open a session in the Coder web UI
./coder-sessions.py --takeover [<name>] # hand it to a local agent, stop mirroring
./coder-sessions.py --restamp # re-publish the sidebar tokens on open workspaces
./coder-sessions.py --selftest # check the naming helpersThe plugin never touches the workspace label. That is one slot every plugin
writes to, so they fight over it. It publishes metadata tokens instead, which
ui.sidebar.spaces.rows places and styles per plugin:
| token | example | what it is |
|---|---|---|
$coder_icon |
C■ |
marks the workspace as mirroring a Coder one |
$coder_ticket |
PROJ-1234 |
the ticket the branch names, else the one the task names, else the display name with Slack markup stripped, truncated. Cleared when there is nothing to say beyond the session name |
$coder_name |
example-task-4f21 |
the Coder session name — also the plugin's own handle on the workspace |
A plugin cannot add these to your config. Put them in ~/.config/herdr/config.toml
yourself, wherever you want them, and run herdr server reload-config:
[ui.sidebar.spaces]
rows = [
["state_icon", "workspace"],
["branch", "git_status"],
[{ token = "$coder_icon" }, { token = "$coder_ticket", bold = true }],
["$coder_name"],
]Two rows, not one: on a single line the session name crowds the ticket out and it
gets truncated. fg takes a strict #RGB/#RRGGBB — there are no theme colour
names, and anything else is rejected with invalid ui config (herdr keeps the
previous UI settings, so a bad value costs a reload, not your sidebar). Omitting
fg uses the default foreground, which is the only thing that follows the theme.
Without a label of its own, a session's workspace falls back to what herdr derives:
the mirror's branch name — coder-<session> while the session has no branch of its
own — or ~ when there is no mirror. The session name appears nowhere but
$coder_name.
Token names are a global namespace — --source scopes the sequence counter,
not the name. Two plugins publishing $coder_name would overwrite each other, and
either one clearing it would take the other's value with it. So every token this
plugin publishes is prefixed coder_. Change the prefix in the plugin's own
config.json (token_prefix) if something else already claims those names, and
mirror it in rows; set it to "" for bare icon / ticket / name.
Tokens are display-only: a herdr restart drops them, and it kills agentty too, so a
session's workspace comes back as empty shells. The plugin's startup hook runs
--restamp once herdr has restored the session: a workspace still running agentty is
recognised by the session name herdr's agent detection reads off that pane; a mirror
workspace that lost it is recognised by the session name the mirror records in its
marker, gets agentty restarted in its first pane, and is stamped again. Paused sessions
are re-attached too — agentty's ssh starts their workspace. The picker re-stamps as
well whenever it runs, and --restamp is the same thing on demand, idempotent.
Two things, deliberately in one repo:
| file | what it is |
|---|---|
coder-sessions.py |
the herdr plugin: session list, workspace builder |
agentty |
a scrollable replacement for agentapi attach, useful on its own |
The plugin runs the agentty sitting next to it, not one from PATH, so
installing the plugin is all you need and the two can never drift apart. See
agentty for using it standalone.
A session's work lives on the remote workspace, and usually there is no PR yet — so the plugin reproduces the session locally and reviews that. On open it:
- asks the session for its repo root, branch and
originURL over ssh; - finds the matching local clone (
clone_root/<owner>/<repo>, the same mapping gh-dash'srepoPathsuses) — if there is none, it falls back to a plain workspace and skips the rest; - refreshes the one branch reviewr will diff against — the repository's base pick if you set
one, otherwise whatever
origin/HEADnames — because a clone whose copy of that branch predates what the session branched from puts every commit merged upstream since inside the diff, as files the branch never touched; - fetches
+HEAD:<branch>straight from the workspace over ssh, so commits the agent has not pushed are included, onto a local branch with the session's own name; - creates a herdr worktree from that clone, which makes the workspace worktree-backed and groups it under the repo's own workspace in the sidebar — on the session's branch when it has one, detached at the same commit while it shares the branch your clone has checked out;
- applies the session's uncommitted work:
git diff HEADfor tracked files, plus a tar ofgit ls-files -o --exclude-standardfor untracked ones.
You end up with agentty over ssh on the left and reviewr
on the right, with no wiring. A brand-new mirror gets reviewr from reviewr's own
worktree.created subscription; every open after that reuses the checkout, which fires
worktree.opened instead — an event reviewr does not watch
(#82) — so on exactly those openings the
plugin asks for the pane itself, honouring reviewr's own placement and auto_open settings. If
reviewr is not installed, or you turned its auto-open off, nothing is opened and the mirror is
still there for whatever tool you prefer.
- After every agent turn. The plugin launches agentty with
AGENTTY_ON_IDLE="<script> --mirror <session>", and agentty runs that hook when the agent's status goesrunning → stableand the screen moved in between. It already watches/eventsfor the dot, so nothing polls, and the refresh lands exactly when new work exists and nothing is mid-write. The screen half of that test is what keeps a turn a turn: agentapi calls any write to the agent's ptyrunning, and the claude it runs repaints to the identical screen every 30 minutes, which without it would refresh a mirror with nothing to mirror and leave a bluedonedot on a session nobody touched.AGENTTY_ON_IDLEis generic — agentty knows nothing about this plugin. - On demand, with
prefix+ctrl+m(therefreshaction), which refreshes the focused workspace's mirror — or, in a workspace that has none yet, moves the session into one. It reads the session name from the$coder_nametoken, so it needs no argument, and refuses cleanly in a workspace that is not a session's. It resets the mirror hard, so it will not guess: the tokens have to be there, which after a herdr restart the startup hook takes care of. - On first open, as part of building the workspace.
Re-picking an already-open session only focuses it; it does not refresh.
The mirror is derived, never authored in: --mirror <name> refreshes it by resetting hard
to the session's current state. A marker in the worktree's git dir (not the working tree, so it
never shows in git status) records that the worktree is a mirror; without it the refresh
refuses, so a worktree you made yourself is never reset.
A session that has not branched yet sits on main, and so does your clone. Git refuses the
same branch in two worktrees, and --force grants it only by sharing the ref — every refresh
resets the mirror hard, so that would reset your own branch out from under your checkout. The
mirror is detached at the session's commit instead: the same files, sharing no ref,
resetting nothing but its own HEAD. It lives at mirror_root/<repo>/coder-<session> — there
is no branch to name the checkout after — and that basename is the label herdr gives the
workspace.
The turn the agent branches, the idle hook moves that same worktree onto the real branch with
git checkout -B and renames the workspace to it. No second workspace, no pane to move, and
reviewr resolves the PR from the branch name from then on. The checkout keeps the path it was
created under: that names the session, which does not change.
Every later branch the agent moves to is the same move again. A mirror is found by the session it belongs to, not by the branch it happens to sit on, so an agent that abandons one branch for another takes its one workspace with it — agentty, reviewr and all. Only the workspace label follows the branch.
A mirror that could not be built at all — no local clone, or no commit shared with the session
yet — leaves the session with the agent pane alone. Both can stop being true between turns, so
the idle hook offers again: a pane splits in below the agent and asks whether to move the
session into a mirror. y builds the worktree workspace, opens reviewr, and closes the agent's
old pane — herdr collapses whatever that empties, so a session that had a workspace to itself
takes it along, and one parked in a tab of yours loses only that tab. The single pane it will
not close is the last one in a workspace this plugin did not open, because that would close
your workspace too; agentty is left running there instead, and says so. n closes the offer,
and nothing asks again until the branch changes.
prefix+ctrl+m makes the same move whenever you want it.
Two deliberate choices: the source refspec is HEAD, not the branch name, because a workspace
can hold several session branches and HEAD is unambiguous; and git add -N is not used to
capture untracked files, because it writes to the live agent's index and would show up in its
own git status.
prefix+ctrl+t (the takeover action) ends the remote flow for the focused
session and continues it here. It refreshes the mirror one last time, renders the
remote agent's conversation into .coder-takeover.md at the worktree root, drops
the mirror marker so nothing can reset the worktree again, closes the agentty
pane, and starts a local agent on the same branch.
The handover is markdown, not the agent's own session format. That is deliberate: resuming a real session natively cost 1,032,229 tokens on a 3.3 MB codex rollout and compacted itself mid-run, because 97% of that file is tool output, reasoning traces and world state replayed in full. The conversation alone is ~14,000 tokens. Rendering is ~70× cheaper, works between agents — a codex session can be picked up by Claude Code — and does not break when either CLI changes its on-disk format. What it drops is the tool output; the commands are still listed, and the worktree already holds everything they produced.
takeover_agent decides who picks it up: "match" (the default) uses whichever
agent agentapi ran on the workspace, and a name always uses that one.
A session with no branch of its own has a detached mirror, and a local agent's commits there would land on no branch at all. So the popup asks first:
asked-in-allengineering-e780 (CON2-150) has no branch of its own yet: its mirror is detached.
Take it over on a new branch, pushed to origin, and tell the remote agent.
branch [sven/con2-150-batch-supply-the-engines-client-error-type-so-a-gated-ai]:
enter = use this type a name = use yours ctrl-c = cancel
The suggestion is Linear's own branch name for the ticket the session names, read
through the linear CLI when it is installed
and logged in. Without one — or without a ticket — it is branch_prefix plus the
ticket plus a slug of the task's title. Enter takes it, anything typed replaces it
whole, ctrl-c cancels with nothing changed. The branch is created at the mirror's
commit once the mirror is demoted, so the session's uncommitted work comes along,
then pushed with its upstream set, and the remote agent is told in one line typed
into its composer — queued if it is mid-turn — so any further work over there starts
from the same branch. A push that fails is reported and not fatal: the branch exists
locally, and the agent is not told about a branch origin does not have.
The Coder workspace is not paused. A paused task is a stopped workspace, so
pausing would remove the ssh <session>.coder the local agent is told to fall back
on when it needs something that genuinely cannot be reproduced here. Nothing is
lost by leaving it: a Coder agent only acts when it is sent input, and ssh restarts
a stopped workspace on demand in about 30 seconds.
Taking over is one-way. The worktree is yours afterwards, and prefix+ctrl+m on
it says so rather than refusing generically: it recognises the taken-over icon and
answers that there is no mirror left to refresh. It never reaches
mirror_session's not-a-mirror guard, because agentty went with the takeover.
prefix+ctrl+w opens the focused session's page in the Coder web UI, and ctrl-o
in the picker does the same for the highlighted row —
<deployment>/tasks/<owner>/<task id>, the same link the UI's own task list
builds — in the machine's default browser. That is the task page, not the
workspace page: it has the agent's conversation, the apps, and the controls for
pausing or restarting the session.
The deployment comes from $CODER_URL, else the url file in the coder CLI's
config dir, so it costs no network call. Like the refresh key it needs no
argument: with no name it reads the session off the focused workspace, and refuses
cleanly in one that is not a session's. Stopped sessions still open — their page
is where you start them again.
Needed:
- herdr 0.8.0+ on macOS or Linux. It uses popup plugin panes, workspace
metadata tokens and the worktree CLI; there is no Windows support, because
agenttydrives a Unix terminal directly. - Python 3.9+, standard library only. No build step, nothing to compile.
Whatever
python3resolves to on yourPATHis what runs. coderCLI, logged in, new enough to havecoder task. The list iscoder task list -o json.- ssh access to each session, as
<session-name><host_suffix>. Coder's generated ssh config gives<name>.coder; sethost_suffixif yours differs.
Optional, each with a working fallback:
fzffor the picker. Without it,--listand--open <name>still work.linearCLI, logged in, for the branch name a take-over suggests. Without it the suggestion is built frombranch_prefix, the ticket and the task title.- A local clone of the session's repo, at
clone_root/<owner>/<repo>. This is what mirroring needs; without a match the session opens as a plain workspace.clone_rootdefaults to~/projects/github— the setting most people have to change. - reviewr 0.30.0+ for the
review pane. Not installed means the agent pane alone; the mirror worktree is a
normal worktree, so any review tool can be pointed at it. The version matters
only for step 3 above: 0.30.0 is where reviewr's base became the
--baseflag, then a per-repository pick, thenorigin/HEAD, and where thebase_branchesconfig key was removed. Older reviewrs have no pick ref, so the refresh always lands onorigin/HEADwhile reviewr may still resolve abase_branchesentry nothing fetched.
herdr 0.8.2 on macOS with fzf and reviewr 0.34.0 installed, coder logged
in with its ssh config generated, and every repo you review cloned under one
root as <owner>/<repo> (the same layout gh-dash's repoPaths uses).
Nothing here fails with a traceback; each degrades to the next-best thing:
| missing | what happens |
|---|---|
coder, git, ssh |
one line naming the binary, then it stops |
an old coder without task |
one line saying so, with the CLI's own answer |
fzf |
the picker refuses and points at --list / --open |
| ssh to the session | the session opens without a mirror |
| no local clone | the session opens without a mirror, naming the path it looked under and the config file to change |
| a session still on the branch your clone has checked out | the mirror is detached at the same commit, and moves onto the session's branch the turn the agent creates one |
| a shallow session clone | the mirror comes from origin instead, with the agent's commits folded into one diff |
| reviewr | the agent pane opens alone |
| reviewr below 0.30.0 | the base refresh can only guess origin/HEAD, so a base_branches base stays stale |
| Python below 3.9 | one line naming the interpreter and its version |
| an unwritable state dir | previews go empty and the mirror offer repeats each turn; the picker still works |
an agentty that lost its executable bit |
it runs through the interpreter instead |
| a coder CLI that was never logged in | the web-UI key says so and names CODER_URL; everything else still works |
agentty <workspace-ssh-host> attaches to an agent running under
agentapi on another machine, opening its own
ssh tunnel. It exists because agentapi attach renders the whole remote screen
each frame with no viewport, so nothing scrolls.
It adds PgUp/PgDn/Home/End and mouse-wheel scrolling, a caret at the composer,
local echo (the round trip to a remote workspace is ~600 ms), suppression of the
agent's empty-composer hint, and folding for panes narrower than the agent.
Shift+Enter inserts a newline: herdr sends it as xterm modifyOtherKeys, which
the agent ignores, so it is rewritten to the ESC+CR the agent reads as one.
Ctrl+] quits; agentty --selftest checks the pure helpers and
agentty --wiringtest drives the two reporting threads against a fake agentapi
(a port and ~8s, which is why it is its own flag). Inside a herdr pane it also
reports the remote agent's state, so the session gets a real agent dot —
withholding a running the screen never backed up, since agentapi reports one
for any write to the agent's pty, repaints included.
Standalone install — it is one stdlib-only file, so copying it is the whole procedure:
curl -fsSL https://raw.githubusercontent.com/ubuntudroid/herdr-coder-sessions/main/agentty \
-o ~/.local/bin/agentty && chmod +x ~/.local/bin/agenttyPlugin users need none of that.
Optional, in $HERDR_PLUGIN_CONFIG_DIR/config.json:
| key | default | meaning |
|---|---|---|
host_suffix |
".coder" |
appended to the session name to form the ssh host |
clone_root |
"~/projects/github" |
where <owner>/<repo> clones live |
mirror |
true |
mirror the session into a local worktree on open |
mirror_root |
"~/.herdr/worktrees" |
where a mirror lives while the session has no branch of its own, as <root>/<repo>/coder-<session>. herdr's own default, so both kinds of mirror land together; point it elsewhere if you moved worktrees.directory |
takeover_agent |
"match" |
which agent takes a session over locally: "match" uses whichever agent ran on the workspace, "claude" or "codex" always uses that one |
branch_prefix |
"" |
prefix for the branch a take-over suggests when Linear cannot name the ticket, e.g. "sven/". Linear's own names already carry one |
token_prefix |
"coder_" |
namespace for the sidebar tokens: this prefix plus icon / ticket / name. Change it if another plugin already claims those names, and mirror it in rows |
- The agent renders at whatever width its launcher chose (
--term-widthin the Coder template's generatedstart.sh, commonly narrower than your pane). There is no resize endpoint, so the content width is fixed for the life of a session;agenttyfolds rather than truncates it. coder task listtakes roughly 700ms, too slow for an fzf preview per keystroke, so the list writes a cache that the preview reads.
MIT. See LICENSE.