Documentation
¶
Overview ¶
Package herdr is a thin, mechanical adapter over the herdr CLI (https://herdr.dev), the terminal workspace manager that hosts the founder's live agent panes. It answers two questions any later caller needs — "am I running inside herdr, and under which harness session?" and "what does the herdr socket API say about panes and agents?" — and does nothing else.
This package carries no delivery policy: no decision about who may be woken, no message templates, no authority rules. See spec/ideas/daemon-as-coordinator.md for why those questions exist and how later work answers them; this package is only the foundation they build on.
Identity ¶
ReadIdentity reads the herdr and harness environment variables a process inherits when it runs inside a herdr-managed pane, through an injected EnvLookup so tests never depend on the real environment. Identity.InHerdr reports whether the process is inside herdr at all.
Client ¶
NewClient resolves the herdr binary — HERDR_BIN_PATH first, then PATH — and returns a Client that shells out to it for every call, always as argv (never through a shell), always under a bounded per-call timeout. Every Client method takes a context.Context in addition to that timeout, so a caller can cancel a call early.
Client.AgentPrompt, Client.AgentSendKeys and Client.PaneSendText are the commands that can affect a live session (see "the newline is the authority boundary" in the idea above); this package validates their arguments defensively but makes no decision about when it is safe to call them. Client.AgentRead and Client.PaneCurrent/Client.PaneList/ Client.PaneGet/Client.AgentList/Client.AgentGet/Client.AgentWait only ever read.
herdr's own agent and pane IDs are scoped to one server (herdr --skill: "IDs and live agent names are scoped to one server"). A caller that must reach a specific session's herdr — a daemon coordinating several, for instance — configures that explicitly with WithSocketPath and/or WithSessionName rather than relying on whichever HERDR_SOCKET_PATH the calling process's own ambient environment happens to carry.
Errors ¶
Failures are reported as one of the sentinel errors declared in errors.go (ErrBinaryNotFound, ErrServerUnreachable, ErrUnknownTarget, ErrUnsupportedVersion, ErrUnparseableOutput), wrapped with context via fmt.Errorf's %w so callers can match with errors.Is. Malformed or unexpected herdr output is always reported as ErrUnparseableOutput; this package never panics on it.
Index ¶
- Constants
- Variables
- func OSLookupEnv(key string) (string, bool)
- func ResolveBinary(lookup EnvLookup) (string, error)
- func ValidateKeyName(key string) error
- func ValidatePromptText(text string) error
- func ValidateTarget(kind, target string) error
- type Agent
- type AgentSession
- type AgentStatus
- type Client
- func (c *Client) AgentGet(ctx context.Context, target string) (Agent, error)
- func (c *Client) AgentList(ctx context.Context) ([]Agent, error)
- func (c *Client) AgentPrompt(ctx context.Context, target, text string) error
- func (c *Client) AgentRead(ctx context.Context, target string, source ReadSource) (ScreenText, error)
- func (c *Client) AgentSendKeys(ctx context.Context, target string, keys ...string) error
- func (c *Client) AgentWait(ctx context.Context, target string, until ...AgentStatus) (Agent, error)
- func (c *Client) Binary() string
- func (c *Client) EnsureMinimumVersion(ctx context.Context) error
- func (c *Client) PaneCurrent(ctx context.Context) (Pane, error)
- func (c *Client) PaneGet(ctx context.Context, paneID string) (Pane, error)
- func (c *Client) PaneList(ctx context.Context, workspaceID string) ([]Pane, error)
- func (c *Client) PaneSendText(ctx context.Context, paneID, text string) error
- func (c *Client) SessionName() string
- func (c *Client) SocketPath() string
- func (c *Client) Version(ctx context.Context) (Version, error)
- type ClientOption
- type EnvLookup
- type Identity
- type Pane
- type ReadFormat
- type ReadSource
- type Runner
- type ScreenText
- type Version
Constants ¶
const DefaultTimeout = 10 * time.Second
DefaultTimeout bounds every Client call that does not override it with WithTimeout. herdr's own socket calls are local and fast; ten seconds is generous headroom for load, not an expected steady-state latency.
const ( // HarnessClaudeCode is the [Identity.Harness] value reported when // envClaudeCodeSessionID is set. HarnessClaudeCode = "claude-code" )
herdrEnvVar names the herdr-set environment variables ReadIdentity reads. Keeping them as a table (rather than inline lookup calls) makes it obvious, at the definition, that only these five are read — and keeps identity_test.go's coverage of "every field" mechanically checkable against this same list.
const MinimumVersion = "0.9.1"
MinimumVersion is the oldest herdr release this package's CLI surface was learned against and is declared to support. Update it, deliberately, when a later task adopts a herdr feature unavailable before some newer release.
Variables ¶
var ( // ErrBinaryNotFound means the herdr binary could not be resolved, from // either HERDR_BIN_PATH or PATH. ErrBinaryNotFound = errors.New("herdr: binary not found") // ErrServerUnreachable means the herdr socket could not be reached, or // no herdr server is running behind it. ErrServerUnreachable = errors.New("herdr: socket unreachable or server not running") // ErrUnknownTarget means either herdr rejected a pane, agent, workspace // or tab target because it does not (or no longer) exists, or this // package refused to ask herdr at all because the target/pane id // argument was empty or began with "-" (see [ValidateTarget]) — a // value herdr's CLI parser could parse as a flag rather than // positional text. ErrUnknownTarget = errors.New("herdr: unknown target") // ErrUnsupportedVersion means the resolved herdr binary reports a // version older than [MinimumVersion]. ErrUnsupportedVersion = errors.New("herdr: unsupported version") // ErrUnparseableOutput means herdr's output did not match the shape // this package expects. It is returned instead of panicking whenever // parsing fails. ErrUnparseableOutput = errors.New("herdr: unparseable output") // ErrCommandFailed means herdr reported a structured error this // package does not otherwise recognize (see herdrError.errorCode in // client.go for the recognized set). The underlying herdr message is // preserved in the wrapping error text. ErrCommandFailed = errors.New("herdr: command failed") // ErrInvalidPromptText means text passed to [Client.AgentPrompt] failed // the newline/control-character check described in doc.go and in // spec/ideas/daemon-as-coordinator.md's "the newline is the authority // boundary" section: herdr's own newline submits the prompt, so this // package refuses to let a caller smuggle one in through the text // argument. ErrInvalidPromptText = errors.New("herdr: invalid prompt text") // ErrInvalidKeyName means a key name passed to [Client.AgentSendKeys] // was empty or contained characters no herdr key name uses. ErrInvalidKeyName = errors.New("herdr: invalid key name") // Client configured with [WithSocketPath] or [WithSessionName]. // "current" resolves from the calling process's own ambient // HERDR_PANE_ID, which is meaningless once a Client explicitly targets // a different socket or session; call [Client.PaneGet] with a known // pane id instead. ErrCurrentUnavailable = errors.New("herdr: pane current is unavailable on an explicitly targeted client") )
Sentinel errors this package returns. Every failure path wraps one of these with fmt.Errorf's %w, so callers match with errors.Is rather than string comparison. New sentinels should be added here, never inferred from herdr's own JSON error codes at the call site.
Functions ¶
func OSLookupEnv ¶
OSLookupEnv is the production EnvLookup: os.LookupEnv. Production code passes it to ReadIdentity and NewClient; tests pass a map-backed lookup instead so they never read, and never risk depending on, the caller's real environment.
func ResolveBinary ¶
ResolveBinary finds the herdr executable: HERDR_BIN_PATH (read through lookup) first, then PATH. It never runs the resolved binary; callers learn whether it actually works from their first real call. A configured HERDR_BIN_PATH that does not exist on disk — stale after an uninstall or a move — is not trusted blindly: ResolveBinary falls back to PATH instead, the same as an unset HERDR_BIN_PATH.
func ValidateKeyName ¶
ValidateKeyName reports whether key is an explicit herdr key name: non-empty, and built only from alphanumeric segments joined by "+", as "esc" and "ctrl+c" are. Client.AgentSendKeys never sends a key this rejects.
func ValidatePromptText ¶
ValidatePromptText enforces the boundary spec/ideas/daemon-as-coordinator.md calls "the newline is the authority boundary": herdr submits a prompt by sending the text followed by its own encoded Enter, so a newline embedded in text would let a caller smuggle an extra, unreviewed line of input into the founder's session. This package refuses any text containing:
- a newline, carriage return, or other C0/C1 control character (unicode.IsControl);
- a Unicode line or paragraph separator, U+2028/U+2029 (unicode.Zl/unicode.Zp) — visually invisible line breaks a control check alone would miss;
- a Unicode format character (unicode.Cf) such as U+202E RIGHT-TO-LEFT OVERRIDE or U+200B ZERO WIDTH SPACE, which can make submitted text render differently than it reads;
- a leading "-", because herdr's own CLI parser has no `--` end-of-options separator to escape one (`herdr agent get -- x` exits 2 with a usage error rather than treating "x" as the target, confirmed live 2026-09-19 via the read-only `agent get`) — a leading hyphen in the text argument risks being parsed as a flag instead of positional text.
so Client.AgentPrompt can only ever submit exactly the one line the caller passed. Client.PaneSendText — literal text into a pane, advisory rather than submitted — reuses this same check: none of the above has a legitimate reason to be in either.
func ValidateTarget ¶
ValidateTarget reports whether target — a pane id, agent id, or other value a Client method passes to herdr as a positional argv argument — is safe to send: non-empty, and not starting with "-". kind labels the argument in the returned error ("agent target", "pane id").
herdr's CLI parser has no `--` end-of-options separator to escape a leading hyphen: confirmed live, 2026-09-19, via the read-only `agent get` (safety.md permits it; `agent prompt`/`send-keys` do not) — `herdr agent get -- x` exits 2 with a usage error rather than treating "x" as the target. A target argument beginning with "-" therefore risks being parsed as an unrecognized flag instead of positional text, the same argv-shape risk ValidatePromptText guards against for the text argument. Every Client method that passes a target or pane id as argv — AgentGet, AgentRead, AgentPrompt, AgentSendKeys, AgentWait, PaneGet, PaneSendText — validates it with this function before invoking herdr.
Types ¶
type Agent ¶
type Agent struct {
PaneID string
WorkspaceID string
TabID string
Kind string
Status AgentStatus
Session *AgentSession
CWD string
ForegroundCWD string
Focused bool
TerminalID string
TerminalTitle string
Revision uint64
StateChangeSeq uint64
}
Agent describes one live agent, as `agent list`, `agent get` and `agent wait` report it. It carries the pane, workspace and harness session identity a caller needs to target or recognize the agent again, plus StateChangeSeq so a caller can confirm nothing changed between an observation and a later action (e.g. the wake flow's check-then-send). Field set verified against AgentInfo in herdr's own socket API schema (`herdr api schema --json`, read-only); fields not listed here — name, title, display_agent, interactive_ready, launch_pending, screen_detection_skipped, state_labels, tokens, terminal_title_stripped — are not yet exposed.
func (Agent) HarnessSessionID ¶
HarnessSessionID returns the harness session identifier herdr associated with this agent, or "" when herdr did not expose one.
type AgentSession ¶
type AgentSession struct {
Agent string `json:"agent"`
Kind string `json:"kind"`
Source string `json:"source"`
Value string `json:"value"`
}
AgentSession identifies the coding-agent session herdr has associated with a pane, as herdr itself reports it — not this package's own Identity. Observed live: {"agent":"claude","kind":"id","source": "herdr:claude","value":"<CLAUDE_CODE_SESSION_ID>"}.
func (*AgentSession) HarnessSessionID ¶
func (session *AgentSession) HarnessSessionID() string
HarnessSessionID returns the underlying harness session identifier when herdr reports one by id (Kind == "id"), or "" when session is nil or herdr identified the session some other way.
type AgentStatus ¶
type AgentStatus string
AgentStatus is one of herdr's agent lifecycle states, as reported by `herdr agent list`, `herdr agent get` and `herdr agent wait`.
const ( StatusIdle AgentStatus = "idle" StatusWorking AgentStatus = "working" StatusBlocked AgentStatus = "blocked" StatusDone AgentStatus = "done" StatusUnknown AgentStatus = "unknown" )
The five states herdr's --skill documentation and live `agent list`/`get` output use. "idle" and "done" both mean the agent is ready for input; herdr distinguishes them by whether the completion has been seen, which this package does not interpret further.
type Client ¶
type Client struct {
// contains filtered or unexported fields
}
Client is a bounded, injectable-exec-seam adapter over the herdr CLI. Every method shells out to the resolved herdr binary as argv, never through a shell, under a per-call timeout derived from the context passed in and the Client's configured timeout, whichever is shorter.
func NewClient ¶
func NewClient(lookup EnvLookup, opts ...ClientOption) (*Client, error)
NewClient resolves the herdr binary via ResolveBinary and returns a Client for it. lookup must not be nil; pass OSLookupEnv in production and a map-backed fake in tests.
func (*Client) AgentGet ¶
AgentGet reports one agent by its unique live name or the pane ID currently hosting it, via `herdr agent get <target>`.
func (*Client) AgentList ¶
AgentList lists live agents with their status, pane, workspace and harness session identity where herdr exposes it, via `herdr agent list`. It requires the result's own "type" discriminator to read "agent_list" and every entry to be [rawPaneOrAgent.valid] before returning anything, so a differently-shaped or partially-populated response is reported as ErrUnparseableOutput rather than a silently incomplete or wrong list.
func (*Client) AgentPrompt ¶
AgentPrompt submits text to target as one line, via `herdr agent prompt <target> <text>`. It refuses text containing a newline or another control character (see ValidatePromptText and "the newline is the authority boundary" in doc.go) before ever invoking herdr, and never appends one itself: herdr's own submission behavior is exactly what makes this call binding on whoever reads target's pane, which is a decision for this package's caller, not for this method.
func (*Client) AgentRead ¶
func (c *Client) AgentRead(ctx context.Context, target string, source ReadSource) (ScreenText, error)
AgentRead returns the raw screen text herdr has recorded for target, via `herdr agent read <target> --source <source>`. herdr agent read is one of the commands safety.md forbids running against the founder's real herdr session (it reads the founder's screen), so this decodes the schema-verified ScreenText shape (see its doc comment) against fixtures only, defensively enough that an unexpected real shape becomes ErrUnparseableOutput rather than a wrong answer.
func (*Client) AgentSendKeys ¶
AgentSendKeys sends one or more explicit key names to target without submitting them, via `herdr agent send-keys <target> <key> [key...]`. Every key must pass ValidateKeyName; this method never forwards arbitrary text as a "key".
func (*Client) AgentWait ¶
AgentWait blocks until target reaches one of the requested states (or herdr's own settled-state default when until is empty), via `herdr agent wait <target> [--until STATUS]...`.
herdr agent wait is one of the commands safety.md forbids running against the founder's real herdr session, so this package's success decoding is not verified against a live response; it is inferred from spec/ideas/daemon-as-coordinator.md's report that a verified wait "returned `agent_status: working`" — the same field agent get/list report — and decoded defensively enough that a different real shape becomes ErrUnparseableOutput, never a panic or a silently wrong Agent.
func (*Client) EnsureMinimumVersion ¶
EnsureMinimumVersion calls Client.Version and returns an error wrapping ErrUnsupportedVersion when the resolved herdr binary is older than MinimumVersion.
func (*Client) PaneCurrent ¶
PaneCurrent reports the pane hosting the calling process, via `herdr pane current --current`. It refuses with ErrCurrentUnavailable on a Client configured with WithSocketPath or WithSessionName: "current" resolves from the calling process's own ambient HERDR_PANE_ID (confirmed live, 2026-09-19), which names a pane on whichever server the caller's own environment happens to point at — not necessarily the one this Client was explicitly told to target, where the same ID could name a different pane entirely. Callers with an explicit target use Client.PaneGet instead.
func (*Client) PaneList ¶
PaneList lists panes, via `herdr pane list`. An empty workspaceID omits the --workspace filter and lists every pane the current session can see. It requires the result's own "type" discriminator to read "pane_list" and every entry to be [rawPaneOrAgent.valid] before returning anything, so a differently-shaped or partially-populated response is reported as ErrUnparseableOutput rather than a silently incomplete or wrong list.
func (*Client) PaneSendText ¶
PaneSendText sends literal text to a pane without any key encoding, via `herdr pane send-text <pane_id> <text>` — herdr's own group help distinguishes this from `send-keys`, which sends key presses, not text. It is validated exactly like Client.AgentPrompt's text, via ValidatePromptText: no newline, carriage return, or other control character, so a caller can never smuggle extra lines into a live pane through this call either.
func (*Client) SessionName ¶
SessionName reports the named session configured via WithSessionName, or "" when this Client does not target one.
func (*Client) SocketPath ¶
SocketPath reports the socket path configured via WithSocketPath, or "" when this Client relies on the ambient HERDR_SOCKET_PATH.
type ClientOption ¶
type ClientOption func(*Client)
ClientOption configures a Client constructed by NewClient.
func WithRunner ¶
func WithRunner(runner Runner) ClientOption
WithRunner overrides the Runner a Client uses to execute herdr. Production code never needs this; tests use it to inject a fake so no test call reaches a real herdr socket.
func WithSessionName ¶
func WithSessionName(name string) ClientOption
WithSessionName targets a specific named persistent herdr session by prepending herdr's own global `--session <name>` flag to every call (see `herdr --help`: "herdr --session <name> [options]" and "--session <name> Use or create a named persistent session"). Combine with WithSocketPath only when the named session's server is not the one the ambient HERDR_SOCKET_PATH already resolves to.
func WithSocketPath ¶
func WithSocketPath(path string) ClientOption
WithSocketPath makes the herdr server this Client talks to explicit: it runs every call with HERDR_SOCKET_PATH set to path, overriding whatever the ambient environment has (see [buildEnv]). herdr's own agent and pane IDs are scoped to one server (herdr --skill: "IDs and live agent names are scoped to one server"), so a caller that must reach a specific session's herdr — such as a daemon coordinating several — should always configure this explicitly from that session's own Identity.SocketPath rather than relying on the ambient HERDR_SOCKET_PATH the daemon process itself happens to have (which may belong to a different session, or none).
func WithTimeout ¶
func WithTimeout(timeout time.Duration) ClientOption
WithTimeout overrides DefaultTimeout for every call the resulting Client makes.
type EnvLookup ¶
EnvLookup reads one environment variable, reporting whether it was set at all (as os.LookupEnv does). Identity is read through this seam so tests never depend on, and never risk touching, the founder's real herdr environment.
type Identity ¶
type Identity struct {
// PaneID, WorkspaceID, TabID and SocketPath are herdr's own coordinates
// for the pane hosting this process. They are empty outside herdr.
PaneID string
WorkspaceID string
TabID string
SocketPath string
// BinPath is HERDR_BIN_PATH, herdr's own hint for where its CLI binary
// lives. It is empty when herdr did not set it, in which case a Client
// falls back to PATH.
BinPath string
// Harness names the coding-agent harness this process believes it is
// running under, such as "claude-code". It is empty when no known
// harness environment variable was found.
Harness string
// HarnessSessionID is the harness's own session identifier —
// CLAUDE_CODE_SESSION_ID for Claude Code — or empty when the harness
// does not expose one, or none was found.
HarnessSessionID string
// HarnessPID is the harness's own process identifier — CLAUDE_PID for
// Claude Code — as reported, or empty when not set. It is kept as a
// string because it identifies a harness process, not a herdr count.
HarnessPID string
}
Identity describes the herdr and coding-agent-harness environment a process inherits. Every field is read once, through the injected EnvLookup, and never re-read from the live environment.
func ReadIdentity ¶
ReadIdentity reads the herdr and harness environment through lookup and returns the resulting Identity. lookup must not be nil.
type Pane ¶
type Pane struct {
PaneID string
WorkspaceID string
TabID string
AgentKind string
AgentStatus AgentStatus
AgentSession *AgentSession
CWD string
ForegroundCWD string
Focused bool
TerminalID string
TerminalTitle string
Revision uint64
}
Pane describes one herdr pane, as `pane current`, `pane list` and `pane get` report it. Field set verified against herdr's own socket API schema (`herdr api schema --json`, read-only): PaneInfo's fields not listed here — label, title, display_agent, state_labels, tokens, scroll, terminal_title_stripped — are not yet exposed; add them when a caller needs them.
type ReadFormat ¶
type ReadFormat string
ReadFormat selects `herdr agent read` / `herdr pane read`'s output encoding. Unlike ReadSource, the CLI flag and the JSON wire value use the same spelling.
const ( ReadFormatText ReadFormat = "text" ReadFormatANSI ReadFormat = "ansi" )
type ReadSource ¶
type ReadSource string
ReadSource selects which buffer `herdr agent read` / `herdr pane read` reads from. Values here are the JSON wire spelling, as herdr's own socket API schema (`herdr api schema --json`, read-only) declares them for ReadSource: snake_case, unlike the CLI flag's kebab-case spelling (`--source recent-unwrapped`, from `herdr pane`/`herdr agent`'s bare group help). [ReadSource.cliArg] converts between the two; this package found no other place either spelling needs converting.
const ( ReadSourceVisible ReadSource = "visible" ReadSourceRecent ReadSource = "recent" ReadSourceRecentUnwrapped ReadSource = "recent_unwrapped" ReadSourceDetection ReadSource = "detection" )
type Runner ¶
type Runner interface {
Run(ctx context.Context, binary string, args []string, env []string) (stdout, stderr []byte, err error)
}
Runner executes one herdr invocation as argv — never through a shell — and returns its captured stdout and stderr separately, alongside the process's own error (nil on exit 0). env is the exact process environment to run herdr with; nil means "inherit the caller's ambient environment unchanged" (os/exec's own default). Tests inject a fake Runner so no herdr call in this package's test suite ever reaches a real socket.
type ScreenText ¶
type ScreenText struct {
PaneID string
WorkspaceID string
TabID string
Source ReadSource
Format ReadFormat
Text string
Revision uint64
Truncated bool
}
ScreenText is one screen-text read result, as herdr's PaneReadResult reports it (`herdr api schema --json`, $defs.PaneReadResult, read-only). `agent.read`'s own request/response schema declares no distinct result type of its own, and PaneReadResult ("pane_read") is the only screen-text shape in herdr's closed success-response union, so Client.AgentRead decodes into the same struct `herdr pane read` would. This was verified by reading the schema, not by invoking `agent read` against the founder's real herdr session, which safety.md forbids; treat the field set, not the sample values in testdata/agent_read.json, as the verified part.
type Version ¶
Version is a parsed herdr release number, as `herdr --version` reports it ("herdr 0.9.1").
func ParseVersion ¶
ParseVersion parses herdr's `--version` output, or a bare "X.Y.Z" string such as MinimumVersion. It never panics: any input that does not contain a recognizable X.Y.Z number is reported as an error wrapping ErrUnparseableOutput.