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:
| Check | Error code |
|---|---|
| Id matches the charset (ASCII letters, digits, dot, colon, underscore, hyphen) | invalid_plugin_id |
Non-empty name, version | invalid_plugin_name, version required |
min_herdr_version parses as semver and is not above the running herdr | invalid_plugin_min_herdr_version, plugin_requires_newer_herdr |
| Action, pane, and link-handler ids are unique and dot-free | duplicate_plugin_action_id, duplicate_plugin_pane_id, duplicate_plugin_link_handler_id |
| Every link handler points at an existing action id | link 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 becauseplatformsgating 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 group | Fields |
|---|---|
| Workspace | workspace_id, workspace_label, workspace_cwd |
| Worktree | worktree |
| Tab | tab_id, tab_label |
| Focused pane | focused_pane_id, focused_pane_cwd, focused_pane_agent, focused_pane_agent_status |
| Meta | invocation_source, correlation_id, clicked_url, link_handler_id |
| Selection | selected_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_commandwith aPopupGeometry(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.rsreturnsui_busy(“a popup pane is already open”).split→ splits beside the caller (direction defaults to right;tab create+pane runpaths for tab placement).overlay/zoomed→ focus-taking placements;zoomedmaximizes and focuses.- A
tab.createdevent 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.