Write Safe Event Handlers
Herdr fires events in bursts: focus three panes quickly and workspace.focused, tab.focused, and pane.focused can arrive together, each spawning your handler. A safe handler treats every invocation as “reconcile the world to what it should be” instead of “react to one event”. pane-topic-sync is the reference implementation of every pattern on this page.
The pattern: one idempotent reconcile
Give every subscribed event the same entrypoint and let it recompute full desired state:
[[events]]
on = "workspace.focused"
command = ["sh", "-c", "exec \"$HERDR_PLUGIN_ROOT/target/release/tab-topic\" run workspace.focused"]
[[events]]
on = "pane.focused"
command = ["sh", "-c", "exec \"$HERDR_PLUGIN_ROOT/target/release/tab-topic\" run pane.focused"]
[[events]]
on = "pane.agent_status_changed"
command = ["sh", "-c", "exec \"$HERDR_PLUGIN_ROOT/target/release/tab-topic\" run pane.agent_status_changed"]
The handler ignores why it was called. It lists current panes and tabs, computes what each label should be, and applies only the diffs. Run it twice and the second run is a no-op.
fn reconcile() -> anyhow::Result<()> {
let panes = herdr(&["pane", "list"])?;
let tabs = herdr(&["tab", "list"])?;
let mut state = State::load()?;
for pane in agent_panes(&panes) {
let desired = topic_label(pane);
if state.owned(&pane.pane_id, &desired) && pane.label.as_deref() != Some(&desired) {
herdr(&["pane", "rename", &pane.pane_id, &desired])?;
}
state.remember(&pane.pane_id, desired);
}
state.save()?;
Ok(())
}
Idempotency is what makes overlapping runs safe: two concurrent reconciles compute the same target, so whichever lands last writes nothing new.
Gate writes through a state file
Store what you wrote in $HERDR_PLUGIN_STATE_DIR and only call a mutating command when the value actually changed:
use std::path::PathBuf;
fn state_path() -> PathBuf {
let dir = std::env::var("HERDR_PLUGIN_STATE_DIR").expect("herdr sets HERDR_PLUGIN_STATE_DIR");
PathBuf::from(dir).join("tab-topic-state.json")
}
Without gating, every event triggers renames even when nothing changed, and herdr emits a *.renamed event for each write. Your log fills with noise and you are one bug away from a loop.
Write state atomically, merge on save
Overlapping runs each load state, mutate, and save. A naive whole-file overwrite means the slower run clobbers the faster one’s writes. Merge with what is on disk at save time, then swap atomically via temp file + rename:
use std::fs;
use std::io::Write;
fn save_state(path: &std::path::Path, fresh: &State) -> anyhow::Result<()> {
// Re-read whatever reached disk since we loaded, so a slower run
// cannot clobber a faster one's entries.
let on_disk = State::load_from(path).unwrap_or_default();
let merged = fresh.merge(&on_disk);
let tmp = path.with_extension(format!("json.{}", std::process::id()));
let mut f = fs::File::create(&tmp)?;
f.write_all(serde_json::to_string_pretty(&merged)?.as_bytes())?;
f.sync_all()?;
fs::rename(&tmp, path)?; // atomic on the same filesystem
Ok(())
}
pane-topic-sync goes further: it stores a short history of labels per pane (not just the latest) because histories union cleanly under concurrency. See the teardown.
Gotcha:
fs::renameis atomic only within one filesystem. Create the temp file in the same directory as the target (as above), never in/tmp.
Never subscribe to what you emit
If your plugin renames panes, do not subscribe to pane.renamed. Your own writes re-trigger you and you get an infinite loop. pane-topic-sync’s manifest says it outright:
# NOTE: deliberately NOT subscribing to *.renamed - our own renames must not re-trigger us.
[[events]]
on = "pane.agent_status_changed"
command = ["bun", "sync-labels.js"]
The same rule applies to any verb you perform: if you create tabs, skip tab.created; if you move panes, skip pane.moved. State-gating (above) is the second line of defense: even if you later add a reflexive subscription, a rename that changed nothing writes nothing.
Respect manual names
Users rename panes and tabs by hand. Overwriting their names is the fastest way to get uninstalled. pane-topic-sync answers “is this label mine to write?” with three signals:
| Signal | Meaning |
|---|---|
| Virgin default | The pane was never named (label is null) or a tab still carries its switch-position default |
| Ours in history | The live label appears in the plugin’s written-label history |
| Desired | The live label already equals exactly what the plugin would write now |
fn is_owned(live: Option<&str>, entry: &Entry, desired: &str) -> bool {
match live {
None => true, // virgin pane
Some(live) => live == desired || entry.seen(live), // ours, or already correct
}
}
If none hold, a human named it: skip that pane and keep its history entry so the plugin re-adopts it if the user clears the name.
Gotcha: the virgin default differs by kind. Panes start with a null label; tabs start labeled with their 1-based switch position as a string. Comparing a tab against
number(the persistent id) instead makes every untouched tab look manually renamed once positions drift.
Keep handlers cheap
Every handler run spawns a process and several CLI calls. Slow readers trigger events_lost on herdr 0.9.2+. Practical rules:
- Do the minimum: exit early when the event cannot matter (e.g. wrong status).
- Cache CLI reads within a run; never loop-call
workspace getper pane when a format string does not need it. - Never block on network inside an event hook. Hand work to a queue file or do it in a separate sink process (see notify-external-services.md).