worktender

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:

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:

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.

It refuses, with exit 3 and nothing created, typed or closed, when:

  1. the issue does not hash to <hex> (checked before the PR is read);
  2. the PR is not open, or comes from a fork;
  3. the PR's branch is another issue's, since the approval covers one issue;
  4. the live worker is working (rework once it is idle);
  5. a workspace holds more than one agent;
  6. for a new worker, the old checkout has uncommitted changes, or the branch holds commits origin/<branch> lacks, or git worktree remove refuses 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:

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

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.