I hit three bugs in three days. They looked unrelated until I wrote them down next to each other: one in someone else’s tool, two in a plugin of mine. They share a shape, and the shape is worth more than any of the three fixes.
State inferred from a derived value rather than read from the component that owns it.
Case one: the installer that did not recognise its own work#
herdr integration install claude wires herdr into Claude Code by adding a hook to settings.json. To be re-runnable, it has to answer one question first: have I already done this?
It answers it by rendering the command string it would write, and checking whether that exact string is present in settings.json.
That works for exactly as long as nobody touches the file. Rewrite the command to an equivalent portable form, say because you want it to work on a machine where the binary lives somewhere else, and the string no longer matches. The installer concludes it has never run, and appends a duplicate hook. Now every event fires the integration twice.
The frustrating part is that the correct answer was already in the file. The installer generates a script and stamps it:
HERDR_INTEGRATION_ID=claudeThat is an unambiguous identity, authored by the installer, for the installer, surviving any amount of reformatting of the command that invokes it. And the check does not consult it.
I reproduced this first-hand in a sandboxed CLAUDE_CONFIG_DIR rather than against my real config, which I recommend for anything that mutates a settings file you care about, and reported it upstream.
Case two: the rename check that could never repair anything#
I wrote a herdr plugin during my trial of it called tab-smart-rename, which renames each tab after whatever the agent in it is currently working on.
To avoid renaming a tab that is already correctly named, it compared the new name against a cached session name it had stored the last time it ran.
Consider what happens the moment anything else changes the tab label. Another plugin, a manual rename, herdr itself. The cache still holds the last name this plugin set. The new name matches the cache. So the plugin concludes nothing has changed and does nothing, and the label stays wrong forever.
The check is not comparing “what the tab says” to “what the tab should say”. It is comparing “what I last decided” to “what I would decide now”, which are two views of the same opinion. From its own point of view nothing had changed, and it was right. It just was not looking at the tab.
Case three: the tab id that came from the environment#
Same plugin. To know which tab to rename, it read a tab id from an inherited environment variable.
Environment variables are inherited by children, which is the entire point of them, and it makes them a terrible source of truth for “where am I right now”. Launch a session from one pane while working in another and the new process carries the launching pane’s id. The plugin then confidently renames a tab that has nothing to do with the agent it is watching.
This is a bug class I have hit before and written about, in a different costume. The CLAUDECODE=1 leak had the same root: an environment flag treated as a statement about the current process when it is really a statement about some ancestor. The reliable answer there was to walk the process tree and let ancestry decide, and the reliable answer here is to ask the multiplexer which pane this is.
The shape#
| Bug | It compared | The owner of the truth | Result |
|---|---|---|---|
| Integration installer | rendered command string | HERDR_INTEGRATION_ID it wrote itself | duplicate hook, fires twice |
| Rename check | its own cached name | the tab’s actual label | can never repair a drifted label |
| Tab id resolution | inherited env var | the pane the process is really in | renames a tab it does not own |
In each case the derivative drifts from the thing it stands for, and nothing notices, because the code is comparing the derivative to itself.
Why this class is so quiet#
The tell is that the failure is silent and self-consistent. Every component reports success.
The installer succeeded. It wrote a valid hook to a valid file and exited zero. The rename check succeeded, and it was correct given its premises. The tab id resolution succeeded, and it renamed a tab that genuinely exists.
There is no exception, no error path, no log line, and nothing to alert on, because from the inside nothing went wrong. You find these by noticing an outcome that is wrong in the world, not by noticing a failure in the system, and that is a much slower loop. It is the same reason a shell function that swallows commands with exit 0 is so much worse than one that crashes.
There is a second-order effect that makes it worse still. A derived value that drifts tends to drift further over time, because the mechanism that would correct it is the mechanism that is broken. The rename check is the clearest version: the longer the label is wrong, the more certain the plugin is that everything is fine.
What to do instead#
The fix in all three cases is the same sentence: ask the component that owns the state.
- The installer should read its own stamp. It went to the trouble of writing an identity; identity is what identity is for.
- The rename check should read the tab’s current label, which is one call away, rather than what it remembers setting.
- The tab id should come from the multiplexer’s notion of the current pane, not from an environment variable that any parent may have set.
The general test is a question you can ask about any comparison in a codebase: if the world changed underneath this check, would it notice? If both sides of the comparison come from the same place, the answer is no, and you have written a check that can only ever agree with itself.
A related smell, and the one that caught the installer for me: if a component authors an identifier and then does not read it, either the identifier is dead code or the reader is looking in the wrong place. It is almost never the former.
Lessons#
- Read state from the component that owns it. A derived value is a copy, and copies drift.
- If a check compares two things that both come from you, it can only agree with itself. Ask whether the check would notice a change made by anything else.
- An identity you write and never read is a bug waiting to be filed. The installer stamped
HERDR_INTEGRATION_IDand then decided idempotency by string-matching a command it also wrote. - Idempotency means recognising your own work after someone reformats it. Matching on rendered output makes any equivalent rewrite look like a fresh install.
- Environment variables describe an ancestor, not the current process. For “where am I now”, ask the thing that knows.
- This class fails silently and self-consistently, so every component reports success. You will find it by noticing a wrong outcome in the world, never by noticing a failure in the system.
- Reproduce config-mutating bugs in a sandboxed config directory.
CLAUDE_CONFIG_DIRcost me nothing and meant the duplicate hook landed somewhere disposable.
References#
- herdr: https://herdr.dev (source: https://github.com/herdrdev/herdr)
- Claude Code settings and hooks reference: https://code.claude.com/docs/en/hooks
- The trial the two plugin bugs came out of: Running six agents made tab patrol my biggest time sink. So: a herdr trial.
- The same silence, different costume: My commands vanished with exit 0. The culprit was a file named env.
- The project that surfaced the installer bug: I deleted six subsystems by swapping one dependency.
