herdr

package
v0.172.8 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 29, 2026 License: Apache-2.0 Imports: 13 Imported by: 0

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

View Source
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.

View Source
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.

View Source
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

View Source
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")

	// ErrCurrentUnavailable means [Client.PaneCurrent] was called on a
	// 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

func OSLookupEnv(key string) (string, bool)

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

func ResolveBinary(lookup EnvLookup) (string, error)

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

func ValidateKeyName(key string) error

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

func ValidatePromptText(text string) error

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

func ValidateTarget(kind, target string) error

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

func (agent Agent) HarnessSessionID() string

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

func (c *Client) AgentGet(ctx context.Context, target string) (Agent, error)

AgentGet reports one agent by its unique live name or the pane ID currently hosting it, via `herdr agent get <target>`.

func (*Client) AgentList

func (c *Client) AgentList(ctx context.Context) ([]Agent, error)

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

func (c *Client) AgentPrompt(ctx context.Context, target, text string) error

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

func (c *Client) AgentSendKeys(ctx context.Context, target string, keys ...string) error

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

func (c *Client) AgentWait(ctx context.Context, target string, until ...AgentStatus) (Agent, error)

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) Binary

func (c *Client) Binary() string

Binary reports the resolved herdr executable path this Client calls.

func (*Client) EnsureMinimumVersion

func (c *Client) EnsureMinimumVersion(ctx context.Context) error

EnsureMinimumVersion calls Client.Version and returns an error wrapping ErrUnsupportedVersion when the resolved herdr binary is older than MinimumVersion.

func (*Client) PaneCurrent

func (c *Client) PaneCurrent(ctx context.Context) (Pane, error)

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) PaneGet

func (c *Client) PaneGet(ctx context.Context, paneID string) (Pane, error)

PaneGet reports one pane by ID, via `herdr pane get <pane_id>`.

func (*Client) PaneList

func (c *Client) PaneList(ctx context.Context, workspaceID string) ([]Pane, error)

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

func (c *Client) PaneSendText(ctx context.Context, paneID, text string) error

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

func (c *Client) SessionName() string

SessionName reports the named session configured via WithSessionName, or "" when this Client does not target one.

func (*Client) SocketPath

func (c *Client) SocketPath() string

SocketPath reports the socket path configured via WithSocketPath, or "" when this Client relies on the ambient HERDR_SOCKET_PATH.

func (*Client) Version

func (c *Client) Version(ctx context.Context) (Version, error)

Version calls `herdr --version` and parses its plain-text reply. Unlike every other Client method, this command does not use the JSON envelope.

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

type EnvLookup func(key string) (string, bool)

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

func ReadIdentity(lookup EnvLookup) Identity

ReadIdentity reads the herdr and harness environment through lookup and returns the resulting Identity. lookup must not be nil.

func (Identity) InHerdr

func (id Identity) InHerdr() bool

InHerdr reports whether this Identity was read inside a herdr-managed pane. HERDR_PANE_ID is the one variable herdr sets on every pane it hosts, so its presence is the check.

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

type Version struct {
	Major, Minor, Patch int
	Raw                 string
}

Version is a parsed herdr release number, as `herdr --version` reports it ("herdr 0.9.1").

func ParseVersion

func ParseVersion(output string) (Version, error)

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.

func (Version) Less

func (v Version) Less(other Version) bool

Less reports whether v is an older release than other, comparing major, then minor, then patch.

func (Version) String

func (v Version) String() string

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL