Skip to main content
  1. Posts/

The installer stamped an ID it never read. It reinstalled over itself.

··1376 words·7 mins·
Nick Liu
Author
Nick Liu
Building infrastructure for Facebook Feed Ranking at Meta. Previously at Walmart, Twitter, AWS, and eBay. MS in Computer Science at Georgia Tech.
Table of Contents
An installer writes a script. It stamps that script with `HERDR_INTEGRATION_ID=claude`, an identifier that exists for no reason other than to say "I made this". Then, to decide whether it has already run, it ignores that stamp entirely and compares the rendered command string in a config file. Rewrite the command to an equivalent form and the installer no longer recognises its own work, so it installs a second copy alongside the first.

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=claude

That 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
#

BugIt comparedThe owner of the truthResult
Integration installerrendered command stringHERDR_INTEGRATION_ID it wrote itselfduplicate hook, fires twice
Rename checkits own cached namethe tab’s actual labelcan never repair a drifted label
Tab id resolutioninherited env varthe pane the process is really inrenames 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_ID and 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_DIR cost me nothing and meant the duplicate hook landed somewhere disposable.

References
#

Related

Every context ring read 10%. Two bugs, and fixing one hid the other.

··1570 words·8 mins
Each agent key on the Stream Deck draws a ring showing how full that session's context window is. Mine sat at 10% for days. Every session, regardless of activity, regardless of how long the agent had been grinding. I found the bug, fixed it, watched agents start reporting correct percentages every single turn, and the ring still said 10%. That was the good part, because it meant there were two. Deleting ClaudeDeck · Part 3 of 5 1 2 3 4 5 🧪 Tested with Claude Code 2.1.x · macOS Ten percent is a suspicious number. Not zero, which would say “nothing ever arrived”. Not a plausible-looking 37%, which would say “this works and your session is small”. Ten percent is round, and round numbers in a display that should be noisy mean the display is not reading anything.

I deleted six subsystems by swapping one dependency. The protocol billed me.

Six of the ten posts in my "Building ClaudeDeck" series document code that no longer exists. Over four days in August I rebuilt the plugin on herdr's socket API, and the hook dispatcher, the PTY runner, the statusline auto-patcher, the AppleScript focus path, the Claude project watcher and the shell-PID resolver all went in the bin, taking the `.app` bundle, the codesigning step and every TCC prompt with them. Then the substrate sent its invoice. Deleting ClaudeDeck · Part 1 of 5 1 2 3 4 5 🧪 Tested with herdr 0.8.0 · macOS ClaudeDeck was complicated for exactly one reason. Claude Code has no control API, so every fact the plugin needed had to be stolen from somewhere it was not offered.

Running six agents made tab patrol my biggest time sink. So: a herdr trial.

Once I had six Claude Code sessions open at once, the most expensive part of my workflow was not writing code. It was patrol: cycling through tabs to see which agent was still running and which one had been sitting on a question for ten minutes. tmux has no concept of any of this. To tmux, every pane is a rectangle of terminal, equally interesting, equally mute. herdr’s pitch lands exactly on that pain: panes are still real terminals, but a sidebar shows each agent’s live state. So it got a one-month trial, with exit conditions written down before it started. The verdict lands on August 16, and this post is honest about still being inside the window.