Walkthrough: Build a Tab Topic Plugin
Build peteretelej.tab-topic: rename each agent pane to its live topic and name each tab after its first agent pane. It is a port of the patterns from pane-topic-sync into the lab’s Rust workspace conventions. At the end you have a working plugin you can extend.
What it does
- Subscribes to
pane.agent_detectedandpane.agent_status_changed(the two events that mark a topic change or a new agent). - On every invocation, runs the same idempotent reconcile: for each agent pane, read
terminal_title_stripped, rename the pane if it changed and is ours; name each tab after its first agent pane. - Gates all writes through a state file, respects manual names, and never subscribes to
*.renamed.
Project layout
herdr-plugins/
├── Cargo.toml # workspace root
└── plugins/tab-topic/
├── herdr-plugin.toml
├── Cargo.toml
└── src/main.rs
# herdr-plugins/Cargo.toml
[workspace]
members = ["plugins/*"]
resolver = "2"
The manifest
# plugins/tab-topic/herdr-plugin.toml
id = "peteretelej.tab-topic"
name = "Tab Topic"
version = "0.1.0"
min_herdr_version = "0.7.0"
description = "Rename agent panes to their live topic and name tabs after their first agent pane."
platforms = ["macos", "linux"]
[[build]]
command = ["cargo", "build", "--release"]
[[actions]]
id = "sync"
title = "Sync topics now"
contexts = ["workspace", "tab", "pane"]
command = ["sh", "-c", "exec \"$HERDR_PLUGIN_ROOT/target/release/tab-topic\" sync"]
# Deliberately NOT subscribing to *.renamed: our own renames must not re-trigger us.
[[events]]
on = "pane.agent_detected"
command = ["sh", "-c", "exec \"$HERDR_PLUGIN_ROOT/target/release/tab-topic\" run pane.agent_detected"]
[[events]]
on = "pane.agent_status_changed"
command = ["sh", "-c", "exec \"$HERDR_PLUGIN_ROOT/target/release/tab-topic\" run pane.agent_status_changed"]
Cargo and CLI plumbing
# plugins/tab-topic/Cargo.toml
[package]
name = "tab-topic"
version = "0.1.0"
edition = "2021"
[dependencies]
anyhow = "1"
clap = { version = "4", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
// plugins/tab-topic/src/main.rs
use anyhow::{bail, Context, Result};
use clap::{Parser, Subcommand};
use serde::Deserialize;
use std::process::Command;
#[derive(Parser)]
#[command(name = "tab-topic", about = "Sync pane/tab labels to agent topics")]
struct Cli {
#[command(subcommand)]
cmd: Cmd,
}
#[derive(Subcommand)]
enum Cmd {
/// Manifest event entrypoint. The event name is informational:
/// every invocation runs the same idempotent reconcile.
Run { event: String },
/// Action: run a full reconcile now.
Sync,
/// Action: forget ownership (stop renaming until panes reset).
Release,
}
fn herdr() -> Command {
let bin = std::env::var("HERDR_BIN_PATH").unwrap_or_else(|_| "herdr".into());
let mut c = Command::new(bin);
c.stdin(std::process::Stdio::null());
c
}
fn herdr_json<T: for<'de> Deserialize<'de>>(args: &[&str]) -> Result<T> {
let out = herdr().args(args).output().context("spawning herdr")?;
if !out.status.success() {
bail!("herdr {} failed: {}", args.join(" "), String::from_utf8_lossy(&out.stderr));
}
Ok(serde_json::from_slice(&out.stdout)?)
}
fn herdr_ok(args: &[&str]) -> Result<()> {
let out = herdr().args(args).output()?;
if !out.status.success() {
bail!("herdr {} failed: {}", args.join(" "), String::from_utf8_lossy(&out.stderr));
}
Ok(())
}
fn main() {
let cli = Cli::parse();
if let Err(err) = match cli.cmd {
Cmd::Run { .. } | Cmd::Sync => reconcile(),
Cmd::Release => release(),
} {
eprintln!("tab-topic: {err:#}");
std::process::exit(1);
}
}
Reading panes and tabs
pane list returns { "result": { "panes": [...] } } with PaneInfo entries (JSON is the default output; there is no --json flag). We only need a few fields; serde ignores the rest:
#[derive(Deserialize)]
struct PanesOut {
#[serde(default)]
result: PanesInner,
}
#[derive(Deserialize, Default)]
struct PanesInner {
#[serde(default)]
panes: Vec<PaneInfo>,
}
#[derive(Deserialize)]
struct PaneInfo {
pane_id: String,
tab_id: String,
#[serde(default)]
agent: Option<String>,
#[serde(default)]
terminal_title_stripped: Option<String>,
#[serde(default)]
label: Option<String>, // null until first rename
}
#[derive(Deserialize)]
struct TabsOut {
#[serde(default)]
result: TabsInner,
}
#[derive(Deserialize, Default)]
struct TabsInner {
#[serde(default)]
tabs: Vec<TabInfo>,
}
#[derive(Deserialize)]
struct TabInfo {
tab_id: String,
workspace_id: String,
#[serde(default)]
label: String, // virgin default: 1-based switch position, as a string
}
Normalize the topic the way pane-topic-sync does (full glyph stripping is a TODO for you):
fn normalize(raw: &str) -> Option<String> {
let collapsed = raw
.chars()
.map(|c| if c.is_control() { ' ' } else { c })
.collect::<String>();
let collapsed = collapsed.split_whitespace().collect::<Vec<_>>().join(" ");
let trimmed = collapsed.trim();
if trimmed.is_empty() { None } else { Some(trimmed.to_string()) }
}
Gotcha:
pane listomitslabelentirely when unset, hence#[serde(default)]producingNone. For tabs, do not compare againsttab.number: it is a persistent id that drifts from the on-screen switch position. The virgin tab label is the position, computed below.
State and ownership
use std::collections::BTreeMap;
use std::fs;
use std::io::Write;
use std::path::PathBuf;
const HISTORY_LIMIT: usize = 5;
#[derive(Default, serde::Serialize, serde::Deserialize)]
struct Entry {
seen: Vec<String>, // labels we wrote, newest first
}
#[derive(Default, serde::Serialize, serde::Deserialize)]
struct State {
panes: BTreeMap<String, Entry>,
tabs: BTreeMap<String, Entry>,
}
impl State {
fn path() -> Result<PathBuf> {
let dir = std::env::var("HERDR_PLUGIN_STATE_DIR")
.context("HERDR_PLUGIN_STATE_DIR not set; run via herdr")?;
Ok(PathBuf::from(dir).join("tab-topic-state.json"))
}
fn load() -> Result<Self> {
match fs::read_to_string(Self::path()?) {
Ok(raw) => Ok(serde_json::from_str(&raw).unwrap_or_default()),
Err(_) => Ok(Self::default()),
}
}
/// A label is ours to write if the pane is virgin, already correct,
/// or carries a label we wrote before. Anything else is a human's name.
/// `virgin` is "" for panes (their default label is null, seen as `None`)
/// and the switch-position string for tabs.
fn owned(live: Option<&str>, virgin: &str, entry: &Entry, desired: &str) -> bool {
match live {
None => virgin.is_empty(),
Some(live) => {
live == virgin
|| live == desired
|| entry.seen.iter().any(|s| s == live)
}
}
}
fn remember(map: &mut BTreeMap<String, Entry>, key: &str, label: &str) {
let entry = map.entry(key.to_string()).or_default();
entry.seen.retain(|s| s != label);
entry.seen.insert(0, label.to_string());
entry.seen.truncate(HISTORY_LIMIT);
}
/// Merge with whatever reached disk since we loaded (overlapping runs),
/// then swap atomically via temp file + rename.
fn save(&self) -> Result<()> {
let path = Self::path()?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let on_disk: State = fs::read_to_string(&path)
.ok()
.and_then(|raw| serde_json::from_str(&raw).ok())
.unwrap_or_default();
let merged = State {
panes: merge_maps(&self.panes, &on_disk.panes),
tabs: merge_maps(&self.tabs, &on_disk.tabs),
};
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())?;
fs::rename(&tmp, &path)?;
Ok(())
}
}
fn merge_maps(
ours: &BTreeMap<String, Entry>,
on_disk: &BTreeMap<String, Entry>,
) -> BTreeMap<String, Entry> {
let mut out = ours.clone();
for (k, v) in on_disk {
let entry = out.entry(k.clone()).or_default();
for label in &v.seen {
if !entry.seen.contains(label) {
entry.seen.push(label.clone());
}
}
entry.seen.truncate(HISTORY_LIMIT);
}
out
}
The reconcile
fn reconcile() -> Result<()> {
let panes: PanesOut = herdr_json(&["pane", "list"])?;
let tabs: TabsOut = herdr_json(&["tab", "list"])?;
let mut state = State::load()?;
// pane -> topic for agent panes that have a real topic.
let topics: Vec<(&PaneInfo, String)> = panes.result.panes.iter()
.filter_map(|p| {
if p.agent.is_none() {
return None; // plain shell panes never name a tab
}
let topic = normalize(p.terminal_title_stripped.as_deref()?)?;
Some((p, topic))
})
.collect();
// 1) Panes: rename to the live topic.
for (pane, topic) in &topics {
let entry = state.panes.get(&pane.pane_id).cloned().unwrap_or_default();
if !State::owned(pane.label.as_deref(), "", &entry, topic) {
continue; // manually renamed; leave it alone
}
if pane.label.as_deref() != Some(topic.as_str()) {
// One dead pane id must not cost the rest of the run.
if herdr_ok(&["pane", "rename", &pane.pane_id, topic]).is_err() {
eprintln!("pane rename failed: {}", pane.pane_id);
continue; // never claim a label that did not land
}
}
State::remember(&mut state.panes, &pane.pane_id, topic);
}
// 2) Tabs: first agent pane's topic per tab, in list order.
// Refinement: pick the visually top-left agent pane via `pane layout`
// rects sorted by (y, x), like pane-topic-sync does.
let mut counters: BTreeMap<&str, u32> = BTreeMap::new();
let mut position_of: BTreeMap<&str, String> = BTreeMap::new();
for t in &tabs.result.tabs {
let n = counters.entry(t.workspace_id.as_str()).or_insert(0);
*n += 1;
position_of.insert(t.tab_id.as_str(), n.to_string());
}
for tab in &tabs.result.tabs {
let Some((_, topic)) = topics.iter().find(|(p, _)| p.tab_id == tab.tab_id) else {
continue;
};
// The virgin tab label is its 1-based switch position as a string.
let virgin = position_of.get(tab.tab_id.as_str()).cloned().unwrap_or_default();
let entry = state.tabs.get(&tab.tab_id).cloned().unwrap_or_default();
if !State::owned(Some(&tab.label), &virgin, &entry, topic) {
continue;
}
if tab.label != *topic {
if herdr_ok(&["tab", "rename", &tab.tab_id, topic]).is_err() {
eprintln!("tab rename failed: {}", tab.tab_id);
continue;
}
}
State::remember(&mut state.tabs, &tab.tab_id, topic);
}
state.save()
}
fn release() -> Result<()> {
let path = State::path()?;
match fs::remove_file(&path) {
Ok(()) => println!("ownership released; deleted {}", path.display()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => println!("nothing to release"),
Err(e) => Err(e.into()),
}
}
One load, one save, every run. The history merge inside save is what makes overlapping runs safe; see safe-event-handlers.md for the reasoning.
Dev loop
cd herdr-plugins
cargo build --release -p tab-topic
# link (skips [[build]]); build yourself as you iterate
herdr plugin link plugins/tab-topic
# trigger without waiting for an event
herdr plugin action invoke peteretelej.tab-topic.sync
# watch what happened
herdr plugin log list --plugin peteretelej.tab-topic
Then flip an agent’s state (start a task in Claude Code or OpenCode) and watch pane list for terminal_title_stripped to change and the rename to follow.
Extensions
- Strip leading spinner/status glyphs in
normalize(the regex is in pane-topic-sync’s source). - Format tokens:
{topic},{agent},{n}with atab_formatconfig key read from$HERDR_PLUGIN_CONFIG_DIR/config.toml. - Choose the tab source pane by
pane layoutrect order instead of list order. - Ship it: ship-to-marketplace.md.