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_detected and pane.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 list omits label entirely when unset, hence #[serde(default)] producing None. For tabs, do not compare against tab.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 a tab_format config key read from $HERDR_PLUGIN_CONFIG_DIR/config.toml.
  • Choose the tab source pane by pane layout rect order instead of list order.
  • Ship it: ship-to-marketplace.md.