Core Runtime Internals

How herdr loads, validates, dispatches, and tracks plugins. Everything here is from the source at _repos/herdrdev-herdr/src/app/api/plugins/: manifest.rs (validation), context.rs (context building), mod.rs (registries and dispatch), panes.rs (pane entrypoints). Knowing this changes how you debug: every symptom below maps to a specific module.

The lifecycle

  • Validation failures refuse the install or link; they never appear at dispatch time (except the version check, which is re-run on every dispatch).
  • Registration is a plain map insert keyed by plugin_id.

manifest.rs: what gets rejected

The validator (about 600 lines) normalizes and checks:

CheckError code
Id matches the charset (ASCII letters, digits, dot, colon, underscore, hyphen)invalid_plugin_id
Non-empty name, versioninvalid_plugin_name, version required
min_herdr_version parses as semver and is not above the running herdrinvalid_plugin_min_herdr_version, plugin_requires_newer_herdr
Action, pane, and link-handler ids are unique and dot-freeduplicate_plugin_action_id, duplicate_plugin_pane_id, duplicate_plugin_link_handler_id
Every link handler points at an existing action idlink handler error

Two details with practical consequences:

  • Duplicate ids are rejected within one plugin even across platform-gated entries. file-viewer’s Windows launchers need -windows-suffixed action ids precisely because platforms gating does not relax the uniqueness rule.
  • Link handlers must reference an existing action. The manifest links URLs to actions by id; a typo surfaces at install, not at first click.

The version check is the one that recurs: required > current fails with plugin_requires_newer_herdr and, per the dispatch path, is re-checked on every invocation, not just at load. This is why version-compat-doctrine.md says keep the floor low.

context.rs: PluginInvocationContext

For each trigger, herdr builds a context struct and hands it to your command as HERDR_PLUGIN_CONTEXT_JSON:

Field groupFields
Workspaceworkspace_id, workspace_label, workspace_cwd
Worktreeworktree
Tabtab_id, tab_label
Focused panefocused_pane_id, focused_pane_cwd, focused_pane_agent, focused_pane_agent_status
Metainvocation_source, correlation_id, clicked_url, link_handler_id
Selectionselected_text

Each event type maps to the richest context available with graceful partial fallback: a tab.closed for a tab whose workspace is already gone still carries the ids it can fill. Write handlers that treat missing fields as normal.

selected_text is always None from the API path. The source comment says why: “Selection is client presentation state.” Selection lives in the UI client, not the server, so plugins invoked server-side cannot see it. This is the confirmed root cause behind issue #3380; design actions that do not need the selection.

mod.rs: registries and dispatch

mod.rs (about 4,000 lines) owns the plugin registries and invocation:

// installed plugins are a map keyed by plugin_id
plugins.insert(plugin.plugin_id, plugin);

That one line settles the collision question: installing a plugin whose id already exists replaces the previous entry. Two same-id plugins cannot coexist on one machine. Uninstall removes by id and clears the plugin’s agent-view source (plugin:<id>), so a stale sidebar view never outlives its plugin.

The same module rejects duplicate_plugin_action_id when two installed plugins would collide on a globally qualified action id (its tests cover this at link time).

Dispatch is: resolve trigger → build context → spawn the argv command with the HERDR_* environment → record completion.

panes.rs: pane entrypoints

panes.rs implements the [[panes]] placements:

  • popup → spawn_popup_argv_command with a PopupGeometry (width/height from the manifest, clamped to the terminal). The popup is a singleton in app state (state.popup_pane); if one already exists, mod.rs returns ui_busy (“a popup pane is already open”).
  • split → splits beside the caller (direction defaults to right; tab create + pane run paths for tab placement).
  • overlay/zoomed → focus-taking placements; zoomed maximizes and focuses.
  • A tab.created event is emitted when a pane entrypoint opens its own tab, so other plugins can react.

Opened panes become ordinary entries in state.terminals: movable, resizable, closable, with lifecycle events.

The command log path

src/events.rs defines the internal event PluginCommandFinished { log_id, exit_code, stdout, stderr }. Every plugin invocation publishes one; herdr plugin log list reads them back. That is the entire logging mechanism: herdr captures stdout/stderr and exit codes, and you debug from the log. There is no plugin stderr stream to a terminal.