Engineering guidance for agents working in this repository. Read this alongside
README.md, the relevant crate documentation, and the code before making changes.
- Keep changes small, explicit, and focused on the root cause.
- Preserve user and other agents' changes. Never revert unrelated edits.
- Prefer existing standard traits and generics over unnecessarily concrete APIs.
- Protect protocol compatibility, bounded resource use, and UI responsiveness.
- Add regression tests for changed behavior and report what was actually verified.
- Never modify or patch Herdr itself. It is owned by an unrelated external team; sibling Herdr checkouts are read-only references. Propose an upstream PR only when genuinely justified, never make incidental upstream edits for this client.
Herdr UI is a native client of an existing local Herdr daemon, not a terminal emulator, server, or TUI wrapper. Herdr owns terminal processes and session state. Closing or detaching the GUI must leave the daemon and its terminals running.
| Location | Responsibility |
|---|---|
crates/herdr-protocol |
Generation-1 wire types, framing, validation, and atomic surface patches |
crates/herdr-acp |
herdr-acp binary run inside a Herdr pane: the only ACP client of an agent, its in-pane chat view, the attach socket Herdr UI connects to, and Herdr status reports |
crates/herdr-chat-protocol |
Attach wire between herdr-acp and its clients: transcript model, the shared change reducer, framing, and endpoint discovery; no ACP or GPUI types |
crates/herdr-client |
Discovery, socket worker, session transitions, ordered commands, and client-local activity projection |
crates/herdr-gpui |
Native UI, connection bridge, presentation state, semantic input, geometry, and painting |
crates/herdr-control-protocol |
The control socket between herdr-gpui <command> and the running app: request and response types, bounds, and the Unix socket or named pipe; no GPUI |
crates/herdr-commands |
The command catalog: each command's name, default shortcuts per platform, and the daemon request it becomes against a snapshot; no GPUI (the app binds the keys and runs window commands) |
crates/herdr-usage |
Plan usage of AI services on a host: the providers, their probes and sign-ins, the registry, and the read worker; no GPUI (the app draws the status bar and panel) |
crates/herdr-credentials |
Where saved sign-ins live: the store policy, the macOS Keychain, Linux Secret Service, Windows Credential Manager, and the private 0600 file; GitHub and the cloud providers keep their secrets here; no GPUI |
crates/herdr-github |
GitHub sign-in (device flow, pasted tokens, the GitHub CLI, additional accounts), its REST and GraphQL transport, the [github] config table, the read-only pull request prefetch, and explicit pull request actions; no GPUI (the app keeps the account avatars, the shared PR store, and badge colors) |
crates/herdr-vcs |
Git on the checkouts the app shows: working-tree status, commit, push, and pull request creation, checkpoints kept as Git refs on each host, and the code search index; no GPUI (the app runs the workers from its views) |
crates/herdr-orchestrator |
The orchestrator's backend: issue and pull request records, the per-repository SQLite database, the GitHub, GitLab, and Beads readers, agent dispatch, and the worker; also the app's cache database and private-file setup; no GPUI (the app draws the sections and pages) |
crates/herdr-remote |
Machines beyond this computer's daemon: the cloud providers (one feature each), their config tables and saved devices, running scripts on any host, and Teleport's core; no GPUI (the app runs its jobs and draws its views) |
crates/herdr-remote-control |
Remote control from a phone. With the server feature, the herdr-remote-control daemon: HTTPS with its own CA, dual-approval pairing, Web Push, and chats and terminal tabs through Herdr's API socket. Without it, what Herdr UI uses: the daemon's control protocol, the [remote] config table, the user service install, and the pairing state; no GPUI, and no Tokio without server |
crates/herdr-updater |
The in-app update service: release checks, signed manifests, staging, the install helpers, and the release identity (APP_ID, APP_VERSION); no GPUI |
crates/herdr-diagnostics |
The app's log file and crash reports: the bounded capture of the crates the app names, the writer thread and rotation, the reader the log window tails, the panic hook, and Windows minidumps; no GPUI |
crates/herdr-gpui-launcher |
Windows-only windowed launcher that starts herdr-gpui.exe without a console window; std plus MessageBoxW for start failures |
crates/herdr-automation |
Automation scripts (docs/automations.md): the sandboxed Luau state, the step catalog, schedules, caps, and the plan a person approves; no GPUI or runtime |
crates/herdr-automate |
The automation daemon and its command line: schedules approved scripts per host, runs their steps through the herdr CLI, keeps state in SQLite, and answers --json reports for Herdr UI |
crates/embed-resource |
No-op stand-in for GPUI's embed-resource build dependency, patched in so GPUI embeds no Windows manifest beside crates/herdr-gpui/windows.manifest; not a production crate |
crates/test-support/sandbox.rs |
Shared isolated process setup for opt-in integration tests; not a production crate |
crates/test-support/pe.rs |
Windows executable (PE) reader for tests that check subsystem and resources; not a production crate |
- Keep dependency direction from UI to client to protocol. Protocol/client code must not depend on GPUI.
- Keep session transitions separate from socket scheduling, and connection ownership separate from window rendering.
- Reuse
ConnectionBridge, domain targets, geometry helpers, and the test sandbox rather than duplicating their policies. - File size limit: 1,000 lines. No Rust, Python, shell, or Swift file may exceed it,
tests included;
just check-file-sizeenforces it injust ciand CI's checks job. There is no allowlist: when a change would cross the limit, split the file by responsibility in the same change rather than adding an exception. - Split modules by responsibility; the line limit is a ceiling, not a reason for arbitrary cuts. Prefer wiring and exports in entry points as code grows; do not perform unrelated file reshuffles.
- Keep APIs narrow. Use private items by default and
pub(super)orpub(crate)only where needed; do not expose every field merely to ease extraction. - Define shared types once in the lowest appropriate layer and re-export them. Do not create mirror enums or convert between them through strings.
- Do not add a crate, runtime, service container, or speculative transport abstraction without a concrete need. Sibling checkouts are references, not build dependencies.
Many pull requests are open at once, so code that every feature appends to is a merge-conflict hotspot. Most past conflicts were two branches adding lines at the same spot, or both bumping one hard-coded count.
- Do not assert hard-coded totals such as
assert_eq!(list.len(), 55)or fixed array lengths like[T; 98]for lists features grow. Compare against the source list, assert uniqueness or membership, or use a slice. - Declare each item of a growing list once. Prefer one table, or a
macro_rules!table, that generates the matchingmatcharms andALLlists over parallel lists edited in several places. - Keep lists whose order carries no meaning (enum variants without derived
ordering, asset tables, registries) sorted between
// keep-sorted startand// keep-sorted endmarkers. Additions then land on different lines.just check-sorted(part ofjust ci) enforces the markers andjust sortfixes them. Never mark a list whose order is shown to users or is a wire contract, such as menu rows or the vendored bincode types. - Give a new feature's failures their own
thiserrorenum namedErrorin its module'serror.rs, with variants not prefixed by the feature name, and add one#[error(transparent)] Feature(#[from] feature::Error)variant to the crate's rootError, aspane_moveandbackdropdo, rather than a run of variants inerror.rs. The root enum stays sorted. Callers name itfeature::Error, never through anas FeatureErroralias. - Wrap another crate's error the same way (
GitHub(#[from] herdr_github::Error)). Do not mirror its variants in the app's root and convert one by one: each failure has one definition, and callers match the nested variant. - Shared state structs (
HerdrWindow,Config,herdr_settings::Settings) and their constructors keep their fields sorted. Run an initializer that has an effect or depends on another (registering a subscription, creating an entity, reading a value before it moves) in aletbefore the struct literal, so the literal itself has no order to preserve. DeriveDebugrather than listing fields by hand; redact a private field through its own type'sDebug. just hooksalso enablesrerere, so a resolution is reused when the same conflict returns on another branch, and aCargo.lockmerge driver that keeps one side and has Cargo rebuild it before the commit. After a clean merge whose only conflict was the lockfile, the merge stops with the rebuilt lockfile staged: rungit commit --no-editto finish it. The rebuild refuses while aCargo.tomlorCargo.lockchange is unstaged, and an aborted merge's pending rebuild is dropped with a warning. Squash merges rebuild like merges. Cherry-picks, reverts, and rebases commit without these hooks, so they stop withCargo.lockconflicted: runcargo metadata,git add Cargo.lock, and continue. GitHub's Update branch button runs no hooks; there, runcargo metadataand commitCargo.lockby hand.
herdr-gpui COMMAND drives the running app over its control socket, so
agents in Herdr panes can use what only this client shows or keeps
(docs/cli.md). Agents learn about it from the managed skill in
skills/herdr-gpui-browser/SKILL.md, which the app installs and refreshes.
- When a feature adds something an agent or script could reasonably ask for
(open or focus a view, read state the app holds, change a setting, wait for
an event), add a command in the same change: the request in
herdr-control-protocol'sprotocol, its handler incontrol/server, parsing and output incontrol/client, its--helptext, and tests for each. - Do not add a command that repeats Herdr's own
herdrCLI (workspaces, terminal tabs, panes, agents, scrollback), that the app cannot really do, or that skips a confirmation. Destructive or outward actions open the app's own dialog, pointed at the caller's checkout or the machines named, and the user confirms there; a batch of them is confirmed once. The one exception iscloud exec, a command on a cloud machine the user saved, which is the agent's own work and asks nothing, as the agent's shell does not. - Keep the shared contract:
--jsonand--windowon every command, the exit-code table indocs/cli.md, bounded requests and responses, and targeting that refuses rather than acting in another session's window. - Add the command to
docs/cli.md. When an agent should reach for it unprompted, also add it to the skill's command table and, if it is a new kind of task, to the skill'sdescription, which is all an agent sees before loading it. Keep the skill'sname: installed copies are found by it.
- Require only the capabilities an operation uses. Prefer existing traits such as
Read,Write,IntoIterator,AsRef<Path>, andAsRef<OsStr>over specific streams, collections, or owned paths. - Use slices,
&str, and&Pathwhen borrowing is sufficient. Keep owned data where it must cross a thread boundary or outlive its caller. - Prefer
impl Traitor named generics for static dispatch. Usedyn Traitfor actual runtime heterogeneity/type erasure, not by default. - Use
FnOnce,FnMut, orFnfor injectable behavior. Do not invent a callback or clock trait when a closure or explicit time value suffices. - Do not introduce custom traits merely to wrap one struct. A new trait needs a concrete behavioral contract and a real implementation/substitution need that existing traits cannot express.
- Use enums for closed alternatives such as navigation targets, pane/popup input destinations, connection phases, and launch modes. Avoid string dispatch and boolean-plus-ID pairs internally.
- Parse strings at boundaries, then match on domain types. Prefer
From,TryFrom,Into, andTryIntofor conversions. - Prefer typed request/result structures when shapes are known. Preserve genuinely open-ended protocol envelopes rather than forcing a speculative schema.
- Derive standard traits such as
Default,PartialEq, andEqwhere their semantics are valid. Compare complete values when deduplicating operations. - Every crate must expose its own root
ErrorandResult<T, E = Error>(pub use error::{Error, Result}), with errors defined usingthiserror. Use meaningful typed variants for internal failures and match on variants, never error text. - Use
anyhow::Resultand context at application/reporting and test-harness boundaries, not as a replacement for typed internal errors. Keep dependency versions in workspace dependencies. - Never use
Stringor&stras an error type, includingResult<(), String>, worker-channel results, and intermediate operation state. Strings are allowed as diagnostic payloads, unchanged wire-protocol fields, and final UI display text, not as catch-all internal error wrappers. - Propagate errors with
?; preserve original causes with#[from]/#[source]and retain structured operation/path context until the display boundary. Do not flatten sources withto_string()orformat!()during propagation. Keepio::Resultwhere an actual I/O trait or connector contract requires it, preserving typed sources when adapting errors. - Test error variants and source chains as well as user-facing diagnostics. Preserve redaction, bounded remote diagnostics, retry/cancellation classification, and protocol compatibility when changing error handling.
- No
unwrap()orexpect()in production. Test-only allowances must be scoped to test code. - Prefer safe Rust. Do not introduce
unsafeunless there is no practical safe alternative; investigate standard-library APIs and maintained safe wrappers first. Any unavoidable exception must be tightly scoped and document both why safe alternatives are insufficient and the safety invariants. - Prefer guard clauses and readable iterators. Avoid clones, allocations, helper layers, and generic parameters that provide no benefit.
- Comments should explain invariants, ownership, or non-obvious decisions, not narrate assignments.
- Treat app, window, and entity contexts as UI-thread execution. A foreground
spawnfuture does not make blocking work safe. - Never perform blocking socket/disk I/O, process waits, sleeps, or expensive background computation on the UI thread. Use the existing worker threads or GPUI background executor, then apply results through the appropriate context update.
- Start child processes with
herdr_client::new_command, neverCommand::new(clippy rejects it outside tests). On Windows a console child built any other way can open a window of its own. - Keep render and input paths thin. Render from prepared state and bounded caches; do not query the daemon, scan files, or launch processes while rendering.
- Preserve continuously drained ordered events and the coalesced latest-state mailbox. Do not replace them with an unbounded UI event queue.
- Route connection/startup failures through the authoritative inbox. Reconnect and detach must isolate old inboxes so delayed events and paint acknowledgements cannot affect the new connection.
- Derive connection text/indicators from
ConnectionStatus. Old local operation errors must not hide a disconnection reason. - Queue success is not daemon acknowledgement. Keep names such as
last_queued_optionshonest and include cell pixel metrics in resize comparisons. - Use the same geometry for painting, hit testing, and IME placement. Popup input and composition bounds must target the popup, not the underlying pane.
- Keep glyph visibility separate from decorations: spaces and wide-character continuation cells may still need underline/strikethrough painting.
- Preserve menu input isolation, focus behavior, Unicode composition, and semantic input routing. Do not synthesize terminal keys for operations with endpoint API methods.
- Activity indicators come from the daemon's snapshot, not guessed terminal output or a client-local seen ledger. Every client shows the same dot; do not reintroduce per-client acknowledgement without asking.
- Animate UI that appears, disappears, moves, or changes in response to a click or hover when motion helps the user follow it: tabs, panels, popovers, revealed controls. Animate both ways, so what slides in slides back out, and reverse from where it is when interrupted. Skip it where motion would only delay the result, such as typing, scrolling content, or terminal output.
- Time animations through
crate::motion: readmotion::now()instead ofInstant::now()for start stamps and progress, pass GPUIAnimationlengths throughmotion::duration, and prefermotion::ENTER/LEAVE,progress, andToggleover new curves and constants. That keeps motion consistent and lets QA > Slow Animations slow every animation tenfold.
- Vendored bincode field and enum variant order is a compatibility contract. Do not reorder declarations as cleanup. Preserve attribution in
crates/herdr-protocol/NOTICE.md. - Keep frame/response/geometry bounds, strict decoding, and atomic patch validation. Test malformed inputs and limits, not only valid fixtures.
- Never mix snapshot metadata and surface cells across boot IDs or projection revisions. Preserve future-surface buffering and stale-boot rejection.
- Preserve the single in-flight API request, bounded command/event queues, FIFO ordering, bounded write batches, and request correlation.
- Partial frame prefixes/payloads must survive read timeouts. Finish an inbound partial frame before dispatching commands against potentially stale state.
- Cancellation must not flush queued work, replay commands, or join a worker on the UI thread. Do not add automatic reconnect/replay as an incidental refactor.
- Crossbeam receiver clones compete for events; they are not broadcast subscribers.
- Treat terminal content and daemon messages as untrusted data. Do not execute terminal escapes, follow graphics file paths, or run displayed update commands automatically. The one exception is the daemon's OSC 52
ServerMessage::Clipboard: decode it and put only its bounded UTF-8 text on the pasteboard, never arbitrary bytes, files, or images. - The normal app never installs, starts, stops, or upgrades a personal daemon. Explicit sockets refer to the binary client socket, not the JSON API socket.
- Never commit secrets, credentials, private terminal output, or machine-local configuration.
- Put dependency versions in root
[workspace.dependencies]; member crates inherit with{ workspace = true }. - Use the pinned
rust-toolchain.tomland GPUI version. Do not copy toolchain, feature, or release assumptions from Moltis/Arbor. - Keep workspace lint inheritance and fix warnings rather than widening allowances.
- Keep
integration-testopt-in. Gate imports, modules, and call sites consistently, and verify both default and all-feature builds. - Do not introduce Tokio or another runtime to wrap the existing blocking socket worker.
- Avoid unrelated lockfile upgrades. Use
--lockedfor reproducible verification.
Users learn what changed from the GitHub release body, which is generated from
git history by git-cliff (cliff.toml) rather than written by hand. The
release workflow's changelog job runs scripts/release/generate-changelog.sh
and publish passes its RELEASE_NOTES.md to gh release create --notes-file.
CHANGELOG.md is generated for each release and is not tracked in git.
- Commit subjects are the changelog entries, verbatim apart from a capitalized first letter. Write the subject line for a user reading a release page, and put rationale in the body; only the first line is published.
- The type chooses the section:
featbecomes Added,fixbecomes Fixed,perf/refactor/revertbecome Changed,docsbecomes Documentation, and a(security)scope becomes Security. A scope prints as a[scope]prefix, and anything non-conventional falls into Changed. chore,ci,build,style,test, and merge commits never reach users, so nothing user-visible may ship under those types. A breaking change is the exception:type!:or aBREAKING CHANGE:footer is always published, under Changed and prefixed**BREAKING:**, whatever its type.- Previewing the generated changelog before a release is covered by the
releaseskill. - These rules are enforced, not advisory.
just hooksinstalls acommit-msghook that refuses a bad subject before the commit exists, and CI'scommitsjob checks every commit in a pull request; it is part of theFormat, lint, and testgate. Runjust hooksonce per clone, andjust check-commitsto check a branch by hand. Fix a rejected subject withgit commit --amendrather than--no-verify.
Prefer the existing just recipes. Run focused tests while iterating, then the
workspace gates for Rust changes before handoff or a requested commit:
just format
just ci- For linking, startup, or packaging changes, also use
just test-buildto build the release executable and exercise its CLI without a desktop. - The
windowsCI job checks formatting, lints every target and feature, and runs workspace tests with default and all features, including headless UI and CLI tests, split likechecksinto paralleldefaultandalllegs. Thewindows-nativejob runslive_gui(thetest-guiandtest-sidebarequivalents) against a pinnedherdr.exe, and the release-mode performance budget, on both Windows runners, except the live-daemon test on ARM64, where the emulated daemon ignores Enter; the optimized Windows build and its installer run inwindows-packagewhen their inputs change, and in the release workflow, which only smoke-tests the CLI of the bytes it ships and requires CI to have passed on the commit. Keep thecfg(unix)/cfg(windows)split honest and say plainly what a platform cannot do instead of stubbing behavior that appears to work. - A
cfggate is only proven on the platform it names. Before handing off a change that touches one, runjust lint-linuxandjust lint-windowsas well asjust ci: a macOS run cannot see a Linux-only gate, and neither can the Windows cross-check. - Documentation-only changes need command/path/link review and
git diff --check; do not claim a code test run that did not happen. - Add deterministic tests for invariants: ordering, cancellation, fragmentation, revision fencing, error paths, geometry, and cleanup. Prefer explicit coordination and bounded waits over timing guesses.
- Test pure session/CLI/geometry logic without a socket or window where possible. Exercise public behavior with mock peers and headless GPUI tests where integration matters.
- Keep test bodies out of production files, which only declare
#[cfg(test)] mod tests;. Put tests for a new feature in a new topic file under the module'stests/directory (for examplekeymap/tests/pane_keys.rs, starting withuse super::*;) rather than inserting them into an existing large test module, and keep shared fixtures in the parenttests.rs. Parallel PRs then edit different files instead of conflicting. - Fix flaky tests rather than hiding them with retries or new ignores. Existing live/native tests are ignored because they require explicit external resources.
- For visual changes, verify the actual native UI when a desktop is available, including narrow layouts, long labels, focus, popup/IME behavior, and clipping. Headless layout tests do not prove native glyph or OS input correctness.
- Measure interactive performance in release mode (
just run), not debug mode. Preserve bounded glyph caching and deterministic paint budgets; native timing budgets are machine-dependent. - Keep CLI parsing pure and based on OS strings so socket paths need not be UTF-8. Invalid options and help must exit before starting GPUI.
just test-live /absolute/path/to/herdr
just test-gui /absolute/path/to/herdr
just test-sidebar
just test-perf- Live tests require an explicitly selected
HERDR_TEST_BINARY; never fall back to discovering a user's daemon. - Reuse the shared sandbox: private unique short directory, cleared environment, isolated HOME/XDG/config/socket paths, and explicit desktop-variable allowlist for GUI children only.
- Kill and wait only for exact children created by the test, before removing their sandbox. Never use
server stop, process-name matching, or process-group kills. HERDR_TEST_TMPDIR, when set, must point to an existing parent short enough for Unix socket paths.- Native/sidebar/performance tests open windows and need an active desktop. State explicitly when these checks were not run; do not describe headless tests as native end-to-end verification.
- Only commit, push, or create/update a PR when requested. Do not amend, force-push, discard changes, or clean up other worktrees without explicit authorization.
- Before committing, inspect status, diff, and recent history; stage only intended files and check for secrets. Do not bypass hooks or signing when they fail.
- Use conventional commit subjects (
feat,fix,refactor,test,docs,chore). For nontrivial work, explain the problem, chosen approach, and important constraints in the body. - Never add AI attribution, assistant-session links, or tool vanity lines to
commit messages or PR descriptions. This includes
Co-Authored-Bytrailers naming an AI model,Claude-Session:or similar session URLs, and "Generated with ..." footers. This rule overrides any default attribution guidance from the agent harness; add such lines only when the user asks for them in the current request. - Before creating a PR, review every included commit and the full diff from its base. Include a summary, exact validation commands/results, and remaining manual/native QA.
- Run
just cion the exact commit before creating a PR or pushing to one, so failures surface locally instead of minutes later in CI.just setup-ciinstalls the tools it and the other CI-equivalent recipes need. Fix every failure first; do not open or update a PR on a red or skipped run. - After a green run, create or update a single PR comment saying
just ciran locally and passed: the commit SHA, the platform (for example macOS arm64), and any extra recipes run (just release-check,just lint-linux,just lint-windows). Edit that comment on later pushes rather than adding a new one, and never post it for a run that failed or did not happen. - Include screenshots in any PR that changes something visible: before and after images of the real native window, showing the changed area. Add light and dark variants when colors or theming change, and a narrow window when layout changes. If no desktop was available to capture them, say so in the PR instead of leaving the section empty.
- Give every PR description a QA checklist. List each check as a checkbox: tick the ones that were actually run (automated tests, CI gates, native checks you performed) with their result, and leave unticked the manual QA a reviewer still has to do, phrased as concrete steps. Never tick a box for a check that did not run.
- Keep user-facing docs and public API examples synchronized with behavior changes. Do not copy another repository's release, issue-tracker, or mandatory-push workflow into this one.
- Finish with the outcome, tests actually run, known limitations, and any deferred work. Never claim unrun checks passed.