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:

  1. For each pane with an agent, normalize terminal_title_stripped (strip status glyphs, control chars, markup; collapse whitespace).
  2. Group panes by tab; choose the tab’s source pane (tab_source = "first" picks the visually top-left agent pane by sorting pane layout rects by y, then x).
  3. Apply the format string ({topic}, {agent}, {n} switch number, {workspace}) and truncate.
  4. 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.focused together) 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);
}
SignalPaneTab
Virgin defaultlabel is null (never named)label equals its 1-based switch position string
Ours in historylive label appears in seensame
Desiredlive equals exactly what we would write nowsame

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.label is non-nullable (herdr seeds it with the switch position) while TabInfo.number is a persistent id that drifts from the position as tabs open and close. Comparing against number made 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 mergeserde_json + temp-file fs::rename
seen: [labels] historyVec<String> capped with truncate(5)
isOwned(live, entry, virgin, desired)same function, Option<&str> for live
renameSafely catch-and-continuematch on Result, log to stderr, keep going

The full port is walkthroughs/build-tab-topic.md.