Teardown: pane-topic-sync
danbuhler/herdr-pane-topic-sync renames every agent pane to its live topic (terminal_title_stripped) and names each tab after its first agent pane. One 460-line bun script plus a manifest. It is the best event-hygiene reference in the ecosystem: everything in safe-event-handlers.md comes from here. Source mirror: _repos/danbuhler-herdr-pane-topic-sync/.
Event set: everything except what it emits
id = "dan.pane-topic-sync"
[[events]]
on = "workspace.focused"
command = ["bun", "sync-labels.js"]
# ... tab.focused, pane.focused, pane.created, pane.closed, pane.moved,
# pane.agent_detected, pane.agent_status_changed
The comment in the manifest says it: deliberately NOT subscribing to *.renamed - our own renames must not re-trigger us. Every event that could change a topic is subscribed; the one verb the plugin performs is not.
The reconcile loop
Each run walks the full pane and tab lists and computes desired labels:
- For each pane with an agent, normalize
terminal_title_stripped(strip status glyphs, control chars, markup; collapse whitespace). - Group panes by tab; choose the tab’s source pane (
tab_source = "first"picks the visually top-left agent pane by sortingpane layoutrects by y, then x). - Apply the format string (
{topic},{agent},{n}switch number,{workspace}) and truncate. - Rename only what differs and is owned.
Nothing is event-specific. Any event lands in the same full reconcile, which is what makes overlapping runs harmless.
State: histories, not latest values
State lives at $HERDR_PLUGIN_STATE_DIR/pane-topic-sync-state.json, keyed by pane/tab id, storing up to 5 recently written labels per id, newest first:
{
"version": 2,
"panes": { "pane-abc": { "seen": ["fix auth flow", "review PR", "old topic"] } },
"tabs": { "tab-xyz": { "seen": ["fix auth flow"] } }
}
Why a history rather than one string? Because a single stored label becomes wrong in ordinary ways:
- Concurrent runs (herdr fires
workspace.focused,tab.focused,pane.focusedtogether) persist snapshots taken at different times. - A pane temporarily stops being an agent pane; its label cannot be recomputed that run.
- A herdr call fails mid-run before
saveState.
With one string each of those froze the pane: its live label was no longer recognized as “ours”, so it filed as a manual rename and the pane stuck at a stale topic. Histories are additive, so concurrent runs merge instead of clobber, and a label from three topics ago still proves ownership.
Ownership inference: the three signals
function isOwned(live, entry, virgin, desired) {
return live === virgin || live === desired || (entry?.seen?.includes(live) ?? false);
}
| Signal | Pane | Tab |
|---|---|---|
| Virgin default | label is null (never named) | label equals its 1-based switch position string |
| Ours in history | live label appears in seen | same |
| Desired | live equals exactly what we would write now | same |
Signal 3 makes it self-healing: lose the state file (reinstall, new machine) and the plugin re-adopts every pane still carrying a label it would produce, instead of freezing.
Gotcha: tab and pane virgin defaults differ.
TabInfo.labelis non-nullable (herdr seeds it with the switch position) whileTabInfo.numberis a persistent id that drifts from the position as tabs open and close. Comparing againstnumbermade every untouched tab look manually renamed and silently killed tab sync.
Atomic, merging writes
function saveState(state) {
const onDisk = loadState(); // whatever reached disk since we loaded
const merged = mergeSection(state.panes, onDisk.panes); // union of histories
const tmp = `${statePath}.${process.pid}.tmp`;
writeFileSync(tmp, JSON.stringify(merged));
renameSync(tmp, statePath); // atomic swap
}
No lock is needed: histories union, so the result does not depend on which run finishes last.
Error etiquette
Renames are wrapped so one dead pane id cannot abort the run or cost later panes their update. Panes are pruned from state only when absent from the live list; panes merely skipped this run keep their history.
What to copy into Rust
| pane-topic-sync (JS) | Rust equivalent |
|---|---|
loadState/saveState with merge | serde_json + temp-file fs::rename |
seen: [labels] history | Vec<String> capped with truncate(5) |
isOwned(live, entry, virgin, desired) | same function, Option<&str> for live |
renameSafely catch-and-continue | match on Result, log to stderr, keep going |
The full port is walkthroughs/build-tab-topic.md.