Read Agent State
Everything a plugin needs to know about agents is reachable through the herdr CLI (or the socket it exposes). This page covers the read commands, their sources and limits, the OpenCode SQLite backdoor, and the sidebar metadata surface for displaying per-pane data.
The one-shot snapshot
herdr api snapshot
Returns the full session state as JSON: workspaces, tabs, panes, agent assignments, statuses. This is your reconcile input. It never marks tabs seen, so supervision tooling can read freely.
For a specific slice, call the command family directly - the read commands
print JSON by default (no --json flag exists; it is rejected with a usage
error):
herdr pane list
The agent command family
| Command | What it gives you |
|---|---|
herdr agent list | Every agent pane with status (JSON by default) |
herdr agent get <target> | One agent’s record |
herdr agent read <target> [--source S] [--lines N] | Terminal text |
herdr agent explain <target> | Why the agent is in its current state: matched rule, manifest, skip reason |
herdr agent wait <target> | Blocks until idle, done, or blocked (default settled states) |
agent explain is the debugging tool. When a status looks wrong, it tells you which rule produced it or why detection was skipped (e.g. an integration owns the pane).
Read sources
herdr agent read reviewer --source recent-unwrapped --lines 120
| Source | Reads | Notes |
|---|---|---|
visible | The on-screen viewport | No scrolling, ever; the safe default |
recent | Last N rendered rows (default 80), includes host scrollback | Pages alternate-screen agents when idle |
recent-unwrapped | Like recent but reflows hard-wrapped lines | Best for transcript parsing |
detection | The bottom-buffer text detection matches on | Always plain text; what herdr itself uses |
Alt-screen limits (agent_not_idle)
Full-screen agents (Claude Code, OpenCode) render history in the terminal’s alternate screen, not host scrollback. For an idle, recognized agent, recent/recent-unwrapped reads page through the agent’s own mouse-scroll UI automatically. While the agent is working, blocked, or unknown, that paging is refused:
error: agent_not_idle
Fall back to --source visible, wait for idle, or have the agent write a file instead. Herdr never moves the viewport for visible or detection reads, manually scrolled agents, or agents that do not report mouse-wheel input.
Gotcha: an
agent read --lines Nthat needs alternate-screen history is not a passive read; it drives the agent’s UI. Onlyvisibleanddetectionare guaranteed side-effect-free.
The OpenCode SQLite pattern
When you need data herdr does not expose (token counts, costs, model, exact session ids), read the agent’s local store directly. OpenCode keeps sessions in SQLite, and herdr-agent-usage shows the shape:
use rusqlite::{Connection, OpenFlags};
fn find_session(db: &str, session_id: &str) -> anyhow::Result<bool> {
let conn = Connection::open_with_flags(
db,
OpenFlags::SQLITE_OPEN_READ_ONLY, // never write another app's db
)?;
// OpenCode 2 writes new sessions to session_v2; check both layouts.
for table in ["session", "session_v2"] {
let present: bool = conn.query_row(
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1 LIMIT 1",
[table],
|_| Ok(true),
).unwrap_or(false);
if !present { continue; }
let hit: bool = conn.query_row(
"SELECT 1 FROM session WHERE id = ?1 LIMIT 1",
[session_id], |_| Ok(true),
).unwrap_or(false);
if hit { return Ok(true); }
}
Ok(false)
}
Rules the reference implementation follows:
rusqlitewith thebundledfeature so no system SQLite is needed.- Read-only flags, always. You are a guest in another app’s data.
- Bounded point queries (
WHERE id = ? LIMIT 1), never full scans. - Probe for table-layout variants (
session/messagein OpenCode 1,session_v2/session_messagein OpenCode 2); an absent table means that layout is unused, not that the store is broken. - The bridge to herdr is session identity:
agent getgives you the pane’s native agent session id, and you match it against the db.
opencode api reaches the running OpenCode server for live data; the db is the offline fallback.
Showing data in the sidebar: report-metadata
To display values (not just log them), report pane metadata and reference it from the user’s sidebar config:
herdr pane report-metadata <pane_id> \
--source my-plugin \
--token model=opus \
--token summary="reviewing authentication" \
--ttl-ms 60000
| Flag | Purpose |
|---|---|
--source ID | Names the reporter; re-reporting from the same source updates it |
--token NAME=VALUE | A custom $name sidebar token |
--title / --display-agent / --state-label STATUS=TEXT | Display-only overrides |
--seq N | Ordering for out-of-order deliveries |
--ttl-ms N | Expiry; unreported/expired tokens simply disappear |
Metadata is presentation-only. Semantic state (idle/working/blocked) comes only from pane report-agent, which lifecycle integrations use; plugins should not fake it.
The sidebar consumes tokens from ui.sidebar.agents.rows:
[ui.sidebar.agents]
rows = [
["state_icon", "agent", "$model"],
["$summary"],
]
Built-in tokens: state_icon, state_text, machine, workspace, tab, pane, agent, terminal_title, terminal_title_stripped. $name resolves against reported metadata. Rules (gt, equals, …) can style or hide tokens by value, and rows_by_agent overrides the whole layout per agent. Layouts cap at 16 rows of 16 tokens.
herdr workspace report-metadata does the same for Space rows.
Tip: report metadata from your event handler on the events that change the value, with a TTL as the safety net. Unreported tokens vanish rather than showing stale data, so an expired token is self-cleaning.