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

CommandWhat it gives you
herdr agent listEvery 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
SourceReadsNotes
visibleThe on-screen viewportNo scrolling, ever; the safe default
recentLast N rendered rows (default 80), includes host scrollbackPages alternate-screen agents when idle
recent-unwrappedLike recent but reflows hard-wrapped linesBest for transcript parsing
detectionThe bottom-buffer text detection matches onAlways 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 N that needs alternate-screen history is not a passive read; it drives the agent’s UI. Only visible and detection are 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:

  • rusqlite with the bundled feature 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/message in OpenCode 1, session_v2/session_message in OpenCode 2); an absent table means that layout is unused, not that the store is broken.
  • The bridge to herdr is session identity: agent get gives 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
FlagPurpose
--source IDNames the reporter; re-reporting from the same source updates it
--token NAME=VALUEA custom $name sidebar token
--title / --display-agent / --state-label STATUS=TEXTDisplay-only overrides
--seq NOrdering for out-of-order deliveries
--ttl-ms NExpiry; 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.