Reference
Exit codes, errors, keybindings, and the behaviours that do not belong anywhere else.
Finding out what is wrong
$ worktender doctor
version 0.5.0 @f074c65 warn origin/main is at @a1b2c3d; run `worktender update`
herdr 0.7.5 ok
gh authenticated ok
worker-gh your token warn workers' gh uses your token, so one can approve and merge; set WORKTENDER_WORKER_GH_TOKEN to narrow it
approvers unset warn any account with write access can approve, a worker's own account among them; set WORKTENDER_APPROVERS
events unset off
roles none off no /home/you/.config/worktender/config.json
repos
worktender 3 worktrees 1 working
house 7 worktrees 1 idle, 1 working
Four of this plugin's failures are environmental, silent, and shaped exactly
like ordinary operation, so doctor exists to name them without being asked the
right question first:
ghmissing or unauthenticated collapses to "this branch has no pull request", so prune keeps almost everything while every reason it prints reads as reasonable. Reported aswarnrather thanfail— a repository that does not use pull requests is entitled to noghat all.- An unrecognised
WORKTENDER_EVENTSleaves events off, and the notice saying so is printed by a hook that will not fire.doctorreports the value as the gate parses it, not as it is spelled. - A herdr that cannot be reached, which makes every other answer here meaningless — so it is said once and the command stops.
- An install left behind, which is the one that hides longest. herdr has no
plugin update, so an install pins a commit and stays on it — one sat on8ef0de9across four releases. Theversionline names what is installed and says when origin has moved past it. It is the one check that asks the network —git ls-remote, bounded at ten seconds, writing nothing into the checkout — and an origin that cannot be reached leaves drift reported as unknown rather than as fine.
It also warns while workers share your gh token; see
Giving workers their own GitHub token.
It warns while WORKTENDER_APPROVERS is unset, or names nobody; see
Approving the raw bytes.
Neither warning changes the exit code.
It also reports the role configuration start and dispatch --role read: the
file, whether it parses, and any agent or directory a role names that does not
exist. See Roles.
It is read-only, takes no lock, and works from outside a repository: someone who cannot tell what is wrong often cannot tell where they are either. The repository list is herdr's open worktree workspaces rather than wherever the caller is standing.
doctor is a command rather than an action, and deliberately: an action's
output lands in the plugin log, which is exactly the indirection a diagnostic
should not have.
Giving workers their own GitHub token
export WORKTENDER_WORKER_GH_TOKEN=github_pat_...
Unset, a worker's gh runs on your login, and a prompt-injected worker can post
a valid worktender approve comment or run gh pr merge. Set, every agent this
plugin starts gets it as GH_TOKEN instead — from start, dispatch, sync,
the event hooks and review alike, so a worker a reconcile restarts after herdr
does is not back on your token. Like WORKTENDER_EVENTS, the hooks read it from
herdr's own environment, so export it before starting herdr.
A fine-grained token covers one GitHub owner. To give each owner its own, set
WORKTENDER_WORKER_GH_TOKEN_<OWNER>, with the owner upper-cased and - turned
into _:
export WORKTENDER_WORKER_GH_TOKEN_LAMPSCOM=github_pat_...
The owner comes from the worktree's origin remote, over https://github.com/
or git@github.com:. The per-owner variable wins when set; an owner without one
gets WORKTENDER_WORKER_GH_TOKEN. While any per-owner variable is set, a worktree
whose owner cannot be read fails to staff, naming its path, rather than falling
back. Each owner's token is kept in its own worker-settings-<owner>.json.
This narrows what a worker does by default; it does not isolate one. The
worker runs as your OS user, so env -u GH_TOKEN gh …, gh auth token and
git credential fill all still reach your own credentials. It stops a worker
using your login by accident or in passing, not one set on using it. The control
is on GitHub's side: branch protection requiring a review from someone other
than the worker's account. Make the token as narrow as you can as well: it needs
to push branches and open pull requests, and approve counts a comment from any
account with write access, so it should not be able to comment on issues as one.
Setting WORKTENDER_APPROVERS to your own login stops it counting even if it
can.
The token never goes on a command line, which ps and the pane would show. It
is written to worker-settings.json, mode 0600, in the plugin state directory —
HERDR_PLUGIN_STATE_DIR, else ~/.local/state/herdr/plugins/steig.worktender
— and handed to claude as --settings, whose env reaches every command the
agent runs. The file persists: through the worker finishing and through
prune, and it is rewritten with the current token at each launch. Unsetting
the variable does not remove it — a launch without the variable deleting a file
another was just handed would start that agent on your login — so remove it
yourself; doctor warns while it is there with the variable unset. A state directory that cannot be
written fails the launch rather than starting an agent on your token, and so
does a role whose agent arguments already carry a --settings of their own. doctor
never prints the value.
Reading GitHub with a separate token
When worktender runs as a user with no gh login, set
WORKTENDER_READ_GH_TOKEN_<OWNER> (owner upper-cased, - turned into _, as
for the worker token). worktender's own reads of a repository under that
owner — start, approve's issue lookup and permission check, gate,
verify, review's pull request lookups and stats — then run gh with it as
GH_TOKEN. The owner comes from the origin remote; stats uses the origin
recorded in the ledger.
export WORKTENDER_READ_GH_TOKEN_LAMPSCOM=github_pat_...
It is for reads only. approve's comment and label, draft's issue edit and
review --comment keep the gh login they ran under. With no such variable set
nothing changes. Once any is set there is no fallback: a read for an owner
without one, or whose owner cannot be read, fails naming the variable. It does
not reach factory, prfeedback, refocus, doctor or the reconciler's
reads yet.
The token goes in the gh process's environment, never an argument. A worker
process can read it, from the environment of the worktender that launched it
or of its parent, so give it a token that can read and nothing else.
Approving without a comment
A worker on a host whose tokens act as you can post a
worktender-approved: sha256= comment as you, so start has two ways to ask
for an approval that does not read one:
worktender start <issue> --sha256 <hex>takes the payload hash you approved (64 lowercase hex characters, asapprove --sha256does).startfetches the issue once, builds the payloadapproveprints, and refuses with exit 3 unless it hashes to<hex>. The label is not consulted, no comment is read, andWORKTENDER_APPROVERSdoes not apply. The pinned copy says the payload was approved by the hashstartwas given.WORKTENDER_REQUIRE_APPROVAL, set to anything but empty or0, makes astartwithout--sha256check the approval comment on every issue, labelledfactory:readyor not, and refuse if there is none. Unset, empty or0,startchecks only labelled issues.
Giving workers an MCP server
Workers start with no MCP: start and dispatch launch them with
--strict-mcp-config --disallowedTools mcp__*, and the claude.ai connectors are
off (ENABLE_CLAUDEAI_MCP_SERVERS=false). WORKTENDER_WORKER_MCP_CONFIG, set
to the absolute path of a JSON file with an mcpServers object, launches them
with --strict-mcp-config --mcp-config <path> instead and drops the
mcp__* deny; only the servers in that file load, and the claude.ai connectors
stay off. A value that is relative, names a file that cannot be read, or holds
no mcpServers object makes start, dispatch and rework exit 1 before they
create a worktree or launch an agent; they never fall back to no MCP. review
and draft ignore the variable.
Sending a pull request back
worktender rework <issue> --sha256 <hex> --pr <n> --note <text> --reason <key>
sends an open pull request back to its worker. It exists because typing into a
pane is what the factory's herdr wrapper refuses its callers: the typing happens
here, behind the same hash check as start --sha256. The flags may sit on either
side of the issue number, and --repo, --role, --model, --permission-mode,
--focus and --json are start's. There is no --base, --branch-prefix,
--brief or --scout: the PR fixes the branch, and the text is worktender's own.
The note is pinned, never typed. It is written as one |-quoted line, under a
fixed header that calls it untrusted data, to worktender/rework-note.md in the
worktree's git dir (so it is never committed), and the worker is typed a fixed
frame pointing at that file. --reason is a key matching ^[a-z][a-z_]{0,31}$;
worktender checks the shape only. The note must be one line of at most 500
characters with no control or invisible characters.
- Live worker. If the issue's worktree is in a herdr workspace holding one agent, the frame is typed into that agent's pane and the issue and note are re-pinned in its checkout. Nothing is fetched, closed or re-created.
- No live worker. Otherwise worktender fetches
origin/<branch>, closes the old checkout's workspace, removes the old checkout, deletes the local branch, re-creates the worktree fromorigin/<branch>and starts a new agent with a brief that names the PR, the pinned issue and the pinned note. Ignored files in the old checkout, such asnode_modules, are lost.
It refuses, with exit 3 and nothing created, typed or closed, when:
- the issue does not hash to
<hex>(checked before the PR is read); - the PR is not open, or comes from a fork;
- the PR's branch is another issue's, since the approval covers one issue;
- the live worker is
working(rework once it is idle); - a workspace holds more than one agent;
- for a new worker, the old checkout has uncommitted changes, or the branch
holds commits
origin/<branch>lacks, orgit worktree removerefuses untracked files. The message names the checkout, which is the caller's to keep or remove.
A herdr call that fails is a refusal too, never a fallback to a new worker. Usage
errors (exit 1) come before any gh or herdr call.
Staying current
$ worktender update
install: /Users/you/.config/herdr/plugins/github/steig.worktender-3ebd1704d63b
origin/main is at @a1b2c3d; fetching
0.5.0 @f074c65 -> 0.6.0 @a1b2c3d
herdr still records @f074c65 for this plugin, so `herdr plugin list` will keep
naming a commit that is no longer installed.
nothing here can correct that record; a reinstall re-clones and re-records it:
herdr plugin install steig/worktender
herdr has no plugin update — its plugin subcommands are install,
uninstall, link, unlink, enable, disable, list, config-dir,
action, log and pane — so this command exists because nothing else can
move an install forward.
Three things worth knowing:
- An install is a shallow, detached clone with no local branch, so
git pullcannot work in one.updatefetches the origin default branch one commit deep and resets ontoFETCH_HEAD, which is the same shape herdr's installer left. - The rebuild never writes over the live binary. An update is normally run by the binary it replaces, and herdr may be running an action through the same file, so the build is staged beside it and renamed into place. Anything already running keeps the old image until it exits.
The staging is a request, though: update runs the build script from the
checkout it just fetched, and every release before this one writes
bin/worktender regardless. That cannot be prevented from here, so the live
binary is compared before and after and the failure says which happened —
replaced in place, or never built at all. The two look identical from the
staged file alone, and calling the first the second would assert a state
nobody checked.
- herdr plugin list will report the pre-update commit afterwards. herdr
records the commit at install time and never re-reads the checkout — the
manifest version it re-reads, so the two disagree. Nothing in this plugin can
correct that record; update says so, and doctor repeats it on every run.
A standalone install upgrades differently
worktender update is a command about a plugin checkout: it fetches, resets
and rebuilds. A binary installed by scripts/install.sh has none of those — no
clone, no manifest, no build script — so there is nothing for it to move, and it
refuses by naming the one step that does:
$ worktender update
worktender: this is a standalone install of 0.11.2, not a plugin checkout, so
there is nothing here to fetch and rebuild.
re-run the installer to move it forward:
curl -fsSL https://raw.githubusercontent.com/steig/worktender/main/scripts/install.sh | sh
It does not run that for you. Piping a script from the network into a shell is a decision someone makes once, on purpose, having read the URL; a binary doing it on their behalf from inside an unrelated command is the same act with the decision removed.
doctor's version line names the release a standalone install is, and says
that nothing compared it against the newest — there is no checkout to compare,
and a read-only command has no business making a second network call whose only
actionable answer is "re-run the installer" anyway.
A binary that is neither — somebody's own go build from a working tree — is
told what was looked for instead, because sending it to an installer would be
telling its owner to throw their own build away.
It refuses two checkouts: one on a branch — that is what herdr plugin link
leaves, and it is yours to move with git — and one with uncommitted changes,
which a hard reset would destroy.
Like doctor, it is a command rather than an action. An action's output lands in
the plugin log, and herdr running it as an action would be herdr executing the
binary the rebuild replaces.
Getting onto it the first time
update arrived in 0.6.0, so no install that predates it can reach it by
running it — which is every install that existed when it shipped:
$ worktender update
worktender: unknown command "update"; usage: worktender <ls|doctor|sync|dispatch|prune|prune-apply|report|gate|on-event|startup>
Reinstall instead:
herdr plugin install steig/worktender
That is the one to reach for first, and not only because the alternative is
longer: it re-clones and re-records, so it is also the only thing that corrects
the commit herdr holds. The hand path — fetch one commit deep, reset onto
FETCH_HEAD, rebuild — is exactly what update performs, and it leaves herdr
plugin list naming the commit the install started on; one checkout still read
@8ef0de9 after two updates by hand, which doctor now reports as a warn.
This is once per install rather than a standing tax. Any install carrying
update moves forward with update.
Exit codes and errors
Every failure prints worktender: <error> on stderr and exits non-zero. The
code says what to do next, not what went wrong — a coordinating agent has
four responses available, and one code per response is what lets it pick without
matching on the message.
| Code | Meaning | What the caller does |
|---|---|---|
| 0 | It happened. | Proceed. |
| 1 | Usage: an unknown flag, a missing argument, a repository you declined to name, a target that cannot report. | Fix the invocation. Retrying unchanged fails identically. |
| 2 | Environment: herdr's socket unreachable, git failing, gh unauthenticated. Also every unclassified failure. |
Fix the machine, then retry. |
| 3 | Needs a human: a worker reported blocked, or start briefed an agent that never took it up, or found Claude Code's folder-trust, hooks-review or managed-settings approval screen where the brief was due, or was given a factory:ready issue whose payload nobody with write access approved, or that changed since, or without acceptance checks it can parse; or draft kept no proposed check, or found the issue edited while its drafter ran; or verify found a check failing on head, or none that fails on base and passes on head. |
Escalate. No retry clears it. |
| 4 | No answer arrived: a gate timed out, a pane died before reporting, draft's drafter did not answer. |
The work may simply be unfinished; redispatching is reasonable. |
2 is the catch-all on purpose. The alternatives are worse: defaulting to 1 tells a coordinator to rewrite a correct invocation, and defaulting to 3 wakes somebody for a bug. An unrecognised failure routed to 2 costs at most a retry.
The distinction 3 and 4 draw is the one this replaced two codes for. A gate returning 1 used to mean the worker is blocked and needs a person or that herdr's socket had died, and those demand opposite actions.
Everything fails loudly on purpose. herdr records a plugin action that exits 0
as "succeeded", so a command that reports a problem and exits 0 is a silent
failure — which is why sync and prune exit non-zero with %d of %d
action(s) failed rather than printing a warning and returning success.
Errors you are most likely to meet:
| Message | Means |
|---|---|
refusing to guess which repository to change |
You ran a changing command outside herdr. ls and prune allow it; sync, prune-apply and the event paths do not. |
--repo: not inside a git repository: <path> |
prune/prune-apply were given a path that is not one. Never a fallback — naming a repository exists to stop the resolution wandering. |
another worktender reconcile has held X for more than 30s |
A concurrent pass. Retry. |
WORKTENDER_EVENTS="ture" is not a value this gate recognises |
Events stay off. Fix the value yourself; nothing here rewrites it. |
MUSTER_EVENTS is set, but it was renamed |
A superseded opt-in enabling nothing. So is HERDR_WT_EVENTS. |
--note is N characters; the limit is 200 |
Refused, never truncated. Shorten and report again. |
this checkout is on branch X rather than the detached HEAD herdr installs |
update was pointed at a linked development checkout. Move that one with git. |
no herdr-plugin.toml beside X; update only works from an installed plugin binary |
The binary is not sitting in an install's bin/. |
the worker reported blocked after Ns |
The gate failed fast rather than waiting out its clock. With --any it names which worker, and the others are left running. |
no new report reached status done within Ns |
Timed out. The message accounts for every worker waited on, quoting what its pane already held when the gate opened, which it ignored as a previous task's answer. |
b is the same worker (pane w2:p1); name it once |
A gate was given an agent's name and its pane id, which herdr resolves to one worker. Watching it twice would let one report look like two. |
Smaller things worth knowing
--jsonreplaces the table onls,doctor,sync,pruneandprune-apply, and never joins it — see Machine-readable output. Human asides that would otherwise sit beside the table, like a lock that would not release, go to stderr instead.--release-agentsonpruneandprune-applyremoves a finished worktree whose agent has stopped, closing the workspace — which is the only way herdr lets go of an agent. Pass it to both halves or the dry run describes a plan the apply will not carry out. It never reaches an agent that is working, and there is no action for it: see How it decides what to remove.worktender versionprints the installed version, the one inherdr-plugin.toml, andworktender --versiondoes the same. The manifest is compiled into the binary, so a plaingo buildandscripts/build.shreport the same thing.baseisorigin/HEAD, notmain. It falls back tomainonly when origin cannot be asked, so a repository defaulting tomasterordevelopis handled without configuration.startprints the commit it forked from, not just the ref. A ref moves; a squash merge of the base leaves none of its commits on the trunk, and the fork point is whatgit rebase --ontoneeds to replay a stacked branch. An unresolvable ref prints no fork point rather than a guess — the worktree create reports that failure better than this line could. See Stacking on a branch that is still in review.worktender draft <issue>proposes acceptance checks; it does not approve them. A read-only agent drafts the section, every line is held to the grammar and an allow-list of the repository's own runners, and what fails is printed and dropped. The drafter reads an export of the tracked files, not the checkout.--writeprints the rest, asks foryes, and puts it on the issue (--replaceto overwrite an existing section);--yesskips the question, and so the human check before publishing. It never labels or approves. Exits 1 when the answer is not yes, 3 when no proposed check survives or the issue was edited while it ran, 4 when the drafter does not answer in ten minutes. See Drafting checks.worktender verify <issue>runs the approved checks on base and head. Each runs in an export of the merge-base and of the head (--pr Nfor a PR's, this checkout'sHEADotherwise). Exits 0 only when nothing fails on head, at least one check fails on base and passes on head, and a changed Go test runs and fails with the changed Go source,go.modandgo.sumput back as on base; 3 otherwise, a revert that passes (hollow), does not build, runs no test, times out or finds no test included. See Verifying that the checks prove the change.listis an alias forls.pr-feedbackandrefocusare the watch mod's. It runs them for you, and either runs by hand:pr-feedbackin a repository,refocusinside a worker's worktree. See Routing PR feedback to workers and Refocusing a worker after compaction.- Agent names come from the checkout's directory basename, lowercased to
[a-z0-9-], prefixedwt-if the result does not start with a letter, and suffixed with a six-character digest of the repository root and the full basename —42-fix-the-thingin~/code/thingbecomeswt-42-fix-the-thing-016aab. The whole thing is 32 characters or fewer, because that is what herdr accepts.
The digest is not decoration. herdr's agent namespace is global and it
refuses a duplicate outright (agent_name_taken), so two repositories with a
worktree called api — or an issue #12 each — would ask for one name and
whichever was staffed second would get no agent. It also separates two long
branches the 32-character limit would otherwise truncate onto each other. So
do not retype the name from the branch: start prints the exact gate
line for what it started, and sync prints the name it staffed under.
- Adoption does not focus the workspace, so adopting a batch does not drag
you through every one of them.
- The repository lock is fail-open. It lives under
HERDR_PLUGIN_STATE_DIR, and if that is absent or unwritable it degrades to a
lock that excludes nothing, silently. Any lock held longer than five minutes is
taken. It stops two reconciles duplicating work; it is not a safety
control — the guards that are re-checked immediately before removal are.
Binding a key to an action
herdr has no plugin_action keybinding type — its key commands are command,
pane, popup and split. So a binding runs the invoke CLI like any other
command, in your own config.toml:
[[keys.command]]
key = "prefix+alt+s"
type = "popup"
command = "herdr plugin action invoke sync --plugin steig.worktender"
width = "70%"
height = "50%"
Bind sync and prune, never prune-apply. A key that can reach a removal
is exactly what splitting those two actions was meant to prevent.
This plugin deliberately ships no [[keys.command]] entries of its own. An
install that silently claims prefix+alt+s is the same class of surprise as one
that edits your agent's configuration — and you may already have that key.
That is the whole of the herdr action surface, and it is a subset of the
binary's: every action runs ./bin/worktender <id>, so ls, sync, prune
and prune-apply are subcommands you can call directly — and should, since the
action path returns an invocation record rather than the output. report and
gate have no action equivalent at all: they take arguments and gate blocks,
neither of which an action can do.