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::rename is 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:

SignalMeaning
Virgin defaultThe pane was never named (label is null) or a tab still carries its switch-position default
Ours in historyThe live label appears in the plugin’s written-label history
DesiredThe 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 get per 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).