@knpkv/herdr-connect
v0.29.5
Published
Fleet Herdr agent directory and authenticated terminal control
Downloads
5,236
Readme
@knpkv/herdr-connect
Fleet agent directory and authenticated Herdr terminal control.
The directory combines the local inventory with peer /v1/connect/agents/local responses. Peer requests time out after 1.5 seconds and run with bounded concurrency. An offline, unavailable, timed-out, or malformed peer becomes a typed partial failure; healthy agents remain usable. The directory names each host that didn't answer, with its cause, above the list and in the Host filter, so missing agents are explained rather than silently absent.
ConnectAgent.hue and ArchivedConnectAgent.hue optionally carry one of eight agent palettes: rose, ember, amber, lime, moss, teal, sky and iris. Plum belongs to the Relay mark. Older peers and archives may omit the field. Every live row, cast, crew member, pin and stage uses the served family when present, as do Home cards and closed archive snapshots. Older rows without it keep the name hash fallback. Device pins retain and refresh the last served family, including while an agent is away; older stored pins without a family still use the fallback.
Hue assignment is optional enrichment. If its store operation fails, the validated directory still serves its agents without hue fields, so clients use the hash fallback. One ConnectHueAssignmentFailed warning records the host and typed cause tag, without database details.
AgentActivityStore assigns families during a validated local directory build, persisted by case-insensitive host and stable agent ID in the existing private SQLite database. Each assigning host releases absent reservations from its own strictly newer complete local inventory, even when the canonical hub archives that host in a separate database. Complete means available inventory without an error, at most 256 agents, and successful agent and relationship-forest validation. It proves absence in that snapshot, not permanent closure. Partial, failed, offline and equal-time absent reads retain reservations. The canonical archive still requires its existing two complete absent reads and five-minute delay before recording closure.
A live lifetime keeps its family across polling, renaming and restart. Both seeds use the lowercase host identity. The first-sighting hue hash is unsigned FNV-1a over lowercase(host):canonical-id, without the transport agent- prefix; simultaneous new identities are processed by stable agent ID. Release, selection, insertion and the host observation watermark share one BEGIN IMMEDIATE transaction and a per-host unique exclusive-reservation index. Older host observations cannot insert a previously unseen identity or revive a retired one. Equal-time positive sightings may enrich without releasing absent reservations. Activity and archive stores sharing a database share accepted observation ordering; an older archive closure cannot release a newer live reservation.
A returning identity first recovers its old family if it is free. New identities start at their hash and take the next unused family in table order, preserving released families while unused ones remain. Once every family has history, they reuse the longest-available family, ranking each free family by its latest holder closure and breaking equal closure times in palette order. With all eight families live, allocation chooses the first family in table order whose live holders have a different independently seeded body shape. Shapes use rev6's unsigned FNV-1a of the lowercase host and stable ID payload without the transport agent- prefix, selecting bits 8 upward modulo five over circle, squircle, pear, star and bean. Every stored body kind is recomputed before allocation in the same transaction, including holders absent from a partial inventory. If every family already holds that shape, it falls back to the hue hash; this can occur with nine same-shape agents. Existing overflow assignments remain stable as the fleet shrinks.
On the rev6 shape upgrade, exclusive hue reservations stay fixed. Conflicting overflow holders are repaired in the same transaction: the earliest holder keeps its family, later holders take the normal next available shape-safe family. Legacy reservation order is SQLite insertion rowid, retained by UPSERT. During repair, the recorded family supplies the starting and fallback family; an unavoidable repeat keeps that hue. This one-time upgrade may recolour overflow agents. Subsequent reads do not repeat the repair.
Archived snapshots retain the family at closure while retained. A returning identity keeps its old family if no live holder uses it, otherwise it is allocated a current family under the same rules. As before, its reappearance deletes the old archive row; preserving multiple lifetimes is outside this contract.
The terminal connector resolves an opaque agent ID against the current local inventory, starts herdr terminal session control, streams Schema-decoded frames, and writes bounded input, resize, scroll, and release commands. Its session is scoped, so interruption releases or kills the child instead of leaving an attach process behind.
A terminal already open on another device shows "Open on another device" in the selected agent's stage. Only Take over sends takeover=1 on /v1/connect/session; ordinary attachments and Reconnect omit it. The owning host closes the previous controller with the exported TerminalTakenOverClose identity, code 4424 and reason taken_over, before admitting the replacement. The previous device releases terminal input and its viewport lock, retains the agent, and shows "Taken over on another device" with Reconnect. Reconnect can return to the controlled state while the other device is alive. During the up-to-ten-second cleanup wait, the stage shows Connecting; Back, Close or Escape cancels that admission. Generic refusals and detached-agent failures keep their existing handling.
TerminalSelection.takeover is optional and defaults to an ordinary attachment. A relaying hub forwards takeover only after a fresh authenticated /v1/connect/terminal/capabilities response decoded by the exported TerminalCapabilities schema. Unsupported hosts remain controlled without an ordinary-attach fallback. Transport failures retain the unavailable-host path.
The connected terminal also exposes a touch-friendly key rail with fixed Escape, Tab, arrow, Ctrl, and Alt controls. Ctrl and Alt are one-shot modifiers: choose one, press a supported key or compatible terminal input, and the modifier clears after the typed terminal.input command is sent. Unsupported combinations are disabled or rejected; the rail never accepts arbitrary command text, and ordinary Ghostty input remains unchanged.
Copy is explicit: a mouse selection (drag, double-click a word, triple-click a whole line including the rows it wrapped onto) is copied with Cmd/Ctrl+C, which only sends ^C to the agent when nothing is selected. On touch, a long-press or the rail's Select key shows the screen as selectable text with Copy selection and Copy screen. Copied text has trailing spaces trimmed and wrapped rows joined; because herdr's screens carry no wrap marks, a row that fills the full width counts as wrapped. Cmd/Ctrl+click, or a tap on touch, opens http and https links in a new tab without an opener; other schemes are never opened.
herdr owns the scrollback and scrolls in whole lines, so touch scrolling draws the remainder on the client: the screen follows the finger 1:1, a flick carries on with iOS-style deceleration, and at most one scroll command is sent per frame. herdr keeps a pane's scroll position while output arrives and between viewers, so only the host knows where a pane really is. The connector reads it with herdr pane get and sends it as terminal.scroll_state, but only to a client that asked with scrollState=1 on the session URL: an older client would close the terminal on a signal it cannot decode. The hub passes that opt-in on to the host that owns the pane, so a fleet can upgrade hosts and hub in any order. A reading is used only when no scroll was in flight on either side when it was taken, so the offset already includes every scroll the client sent. The connector reads only once no scroll was forwarded for 300 ms and herdr has drawn a frame since the last one (a scroll herdr never draws, clamped at an end, stops blocking after a second), and drops a reading if a scroll was forwarded while it ran. Each reading carries how many scrolls the session had forwarded, and the client uses it only if that equals the scrolls it actually wrote to its socket, so a reading that predates a scroll never lands however late it arrives, and the next quiet one does. herdr does not acknowledge scrolls, so this is how a reading is matched to them; the cost is that the count can trail active scrolling by about half a second. A known limit of that: a frame is taken as evidence that forwarded scrolls applied, and a scroll herdr draws no frame for counts as applied after a second, so a scroll herdr takes longer than that to apply, one overtaken by unrelated output, or several queued scrolls the first of which drew a frame, can be certified early; the next reading after it corrects the position. Closing that needs herdr to report the offset with its frames, an upstream ask. Reads happen when the session opens, after scrolling, as output arrives while the pane is scrolled back, and otherwise at most every two seconds while frames arrive, so a failed read or another viewer's scroll is picked up. They are coalesced and capped at two per second per session and ten per second across the host; a read past the host cap is not queued, but each session retries once when the host window frees, so the last read after scrolling still lands. A read that fails reports the position as unknown (null), never as the bottom, and is logged once per session; the client then keeps its own estimate, carried on from the last known position. While the pane is behind, the rail says how many lines with "Older output", and from there wheel, Page Down and touch scroll toward the newest output as far as the pane is back. Latest sends exactly that many lines, one command per frame and however deep the pane is, then waits up to five seconds for the next quiet reading — if none comes, the rail says "Position not confirmed" rather than implying the bottom: 0 ends it, anything else is output that arrived meanwhile, and a whole page follows (herdr clamps it at the bottom), so it lands there even while output keeps arriving. One known limit: Latest pressed before the first reading arrives (about half a second after opening) probes a page per frame, capped at 300 pages, so a pane more than 120,000 lines back needs a second press. The hub never drops a reading for good, locally or relayed from a remote host: under backpressure it holds the newest one until the socket drains. When the position is unknown, or the hub does not send it, the client falls back to its own estimate and Latest scrolls down a page per frame until the screen stops changing.
Browser consumers can import @knpkv/herdr-connect/client for the mounting entry, @knpkv/herdr-connect/surface for the embeddable state and view, and @knpkv/herdr-connect/styles.css for package-owned styles. The host selector matches case-insensitively; the stable agent selector matches exactly. connectTargetSelection, sameConnectTargetSelection and connectTargetSelectionMatches share strict selection decoding, equality and ownership checks. Unrelated query changes do not restart a terminal, and stage Close clears the selectors regardless of host casing. The relationship store keys metadata by exact host and stable agent ID. Trusted live inventory may replace a stored parent only for the same host, stable agent ID, and pane with a strictly newer observation. It may also move the same stable ID to another pane on that host when the observation is strictly newer and its parent and relation are unchanged. Durable, stale and equal-time observations cannot move a pane; a simultaneous pane move and reparent stays rejected. Pane moves share the relationship batch transaction and never replace another stable ID's retained row. Local inventory failures emit one diagnostic per failed read with only the step, validated stable agent ID when known, and reason category; raw inventory, prompts and working directories are not logged. The view renders reported roots and parent relations; missing, cross-host, cyclic, or ambiguous ownership stays explicit and is never guessed.
Home row creatures use a 44-design-unit frame with thin, scaled state rings in their 44px phone and 52px desktop slots. Home archive rows use the same frame in 36px phone and 40px desktop slots. Fleet rows retain their fitted track; stage framing follows the existing stage size.
Browser contracts are available through @knpkv/herdr-connect/model; browser subpaths do not pull SQLite or Node process code into the bundle. Its pure agentStateBucket helper returns null for unknown/new states. connectAgentGroup names the policy explicitly: Fleet retains the needs-you safety fallback, Home uses Unknown and demotes remembered offline-host states, and push treats unknown states as incomplete evidence. Known state words and caution glyphs remain shared.
FleetConnectAgents and the first FleetConnectAgentPage may carry hosts, the configured machine names supplied by the hub. The hint lists the first 256 machines in configuration order. These are configuration, not hosts inferred from agents or usage readings. AgentDirectory.configuredHosts uses them for the empty-directory hint while keeping search and filters available. An older hub can omit hosts; the directory then omits the hint.
A relationship's optional parentName remembers a missing parent's display name from the same host's retained archive observations or archive rows. AgentArchiveStore.namesFor reads those names by host and stable agent ID. An archive name lookup failure logs a warning and leaves the live directory available without remembered names. A live parent's name takes precedence. A missing parent with no retained name reads "Parent agent not found", without exposing a truncated ID. Siblings of one missing parent share a group notice; a lone orphan keeps its inline alert and icon. A filter showing one sibling keeps the group notice. Remembered names do not establish live presence or change relationship authority.
ConnectLimitsView.problems retains typed unavailable readings for the warning card, including the vetted cause when the usage service is absent. A warning names its host when other successful readings or host problems are present. A request failure shows a cause-neutral warning without a Usage link; limits previously marked off remain hidden. First-load directory reads have a ten-second deadline: only a typed timeout uses timeout copy, while non-success HTTP responses name the status. Polling waits for an in-flight read instead of restarting its deadline. HTTP recovery and unfiltered search clearing use 48px controls; timeout recovery and scoped filter clearing retain compact controls with 44px hit areas. Pinned agents stick to the scrolling viewport and settle in reserved footer space: 156px on phones and 260px on larger screens. Phone pills are capped at 144px, truncate names while keeping state words visible, and retain at least 12px of inline-end padding. The limits footer appears after the first directory loads and stays available while retained rows are stale; initial loading and directory failure omit it.
The directory opens in Active. Its Archived link selects ?view=archived, retained by refresh and Back/Forward. Archived reads one authenticated canonical-listener /v1/connect/archive page at a time, with up to 64 records. Each page's request and decoding have a ten-second deadline, producing a typed ConnectTimeoutError and the archive's failed state with Retry. Directory polls do not restart that deadline or retry archive reads. The server includes closures from the last 90 days, including the exact cutoff, using its clock; connectArchiveWindowDays in the model governs the query and directory copy. Stored closures remain retained until reappearance. Show older appends the cursor's next page; failures retain loaded rows and expose Retry. Counts show a + while another page exists, and search covers loaded names, hosts, kinds and work only. Closures are keyed by host, stable agent ID and closure timestamp, so reopening a live ID does not merge its historical records.
An archived stage is read-only: first-seen and closure timestamps, state at close, kind, host, work, and optional full last words with their timestamp. Parent links require an already loaded active or archived record; unloaded parent IDs stay plain text. Archived parent links preserve the owning module and fragment while selecting the closure in closed. Refresh follows the retained archive's bounded pages until that closure is found or the cursor ends. A missing or expired selection is removed quietly with history replacement. Closing removes that query through shared history, so refresh stays closed and Back can revisit the stage. Agent-written words render as text. Archived stages do not expose pin, terminal or goal actions, or report a live stage for heartbeat handling. Standalone Archived keeps the Approvals and Connect application links in loading, failure and loaded states; embedded Archived omits them.
Creature.closed defaults to false. For row, cast and stage presentations, it keeps the seeded outline with a neutral tint, shut lids and lowered brows, without a halo or motion while the prop is true. It is static with either reduced-motion preference and preserves the body's forced-colours outline. Clearing it resumes the current state's normal presentation.
ConnectSurface.onStageChange is an optional application callback receiving the open live stage's authenticated client-visible { host, id }, or null on close and unmount. Reporting follows the stage identity, so replacing an inline callback does not close and reopen the reported stage. Reports use the latest committed callback. It observes live stages only; the application owns any reporting and persistence. The callback defaults to absent. The canonical hub uses it for stage-viewing notification reporting.
ConnectSurface.stageFooter optionally renders application-owned controls in the open live stage body after the crew. It defaults to absent and does not change the stage's actions or terminal. Archived stages omit it. The canonical hub supplies notification settings there; standalone applications own their controls and persistence.
@knpkv/herdr-connect/usage exports the existing UsageSurface and atoms, plus the stateless UsageTab, report loaders, selectors and view-state helpers for a host-owned page. UsageTab defaults to its Fleet presentation. Optional presentation: { page: "limits" | "tokens", labelledBy } omits its toolbar and h1; the owning page supplies the referenced h1, range control and conditional AGENT_USAGE_PAGE link using onLoopback(). Internal sections use h2 headings. Limits includes the current limit cards, tokens and limit history; Tokens includes its token chart and notes. The existing uncontrolled surface is unchanged. These reports carry only token and limit readings, with no cost, balance, booking, session, path or bot identity data.
ConnectAgent.self is optional authenticated client-visible creature state, populated by the canonical hub as ConnectSelfState | null. It contains optional mood and say, with at least one present, an accessory, the server timestamp at, and reserved replyTo, always null in P1. ConnectSelfBody, ConnectSelfState, ConnectSelfMood, ConnectSelfAccessory and TooManySelfUpdates are exported from @knpkv/herdr-connect/model. Words are trimmed, nonblank, at most 140 characters and contain no control characters. Moods are happy, focused, worried, sleepy, excited and waiting; accessories are none, headphones, chef-hat, hard-hat, scarf and glasses.
AgentPostStore.selfUpdate merges partial updates into the latest state, defaults a first accessory to none, and rate-limits each host and stable agent ID to a burst of three with one token refilled per ten seconds. selfLatestMany reads those rows; confirmed archive clears state and the rate bucket in the shared SQLite transaction. Self-state reaches clients through directory polling. Its row and bubble presentation is pending design integration.
Agent voice
Agents can report a short status, progress update, skill feedback, note, or blocker through the canonical hub's authenticated POST /v1/connect/posts. Reports contain agentId, kind, text, eventId, and optional goalStep. The exported connectPostTextLimit sets status text to 240 characters and other kinds to 1024, measured with string.length. Posting schemas reject empty or whitespace-only text, preserving valid text unchanged, including URLs. Stored-report and last-words schemas still accept historical whitespace-only text. These are plain-text updates, never terminal transcripts or HTML. Consumers must render text as text.
The hub authenticates the calling Tailscale node, resolves its configured host, and accepts only a stable agent ID in that host's fresh, complete live inventory. Identity is host-bounded in v1: a process on the same host can claim a sibling agent's ID. Self-state uses the same host boundary; no per-pane credential is added. Other listeners reject voice and self-state writes.
Connect stores use WAL with SQLite's FULL synchronous mode. Each connection waits at most 100 ms for a writer lock. Primary activity, archive, relationship, post and self-state writes then retry complete atomic statements or IMMEDIATE transactions with exponential jitter in Effect, for about five seconds, including time waiting for the local transaction permit. Both SQLITE_BUSY and SQLITE_LOCKED, including their extended codes, use that budget. It ends when SQL succeeds; post-commit file-permission finalization remains inside the store permit but outside the busy timeout. Slow finalization can delay success or return its existing filesystem error, never a lock-exhaustion error for a committed write. Retry delays yield to other hub work. Within each activity-store connection, observations and hue allocations share one permit through file-permission finalization, so concurrent requests do not each block the event loop with a native wait. The hub gives optional hue work its own scoped connection. The live directory uses AgentArchiveStore.observeAvailable: the optional pass stops on its first failed attempt, giving the fleet one native busy wait instead of one per host. It skips occupied local archive permits and invalidates prior absence evidence for every skipped host. Optional hue lock acquisition and SQL use half the 1.5-second peer-directory deadline; contention then falls back to the uncoloured live inventory. Hue permission finalization also runs outside that lock budget. Parent-name and voice/self-state enrichment each have a 100 ms deadline; optional failures are logged and live agents remain available. Directory and post-page reads only select rows within the inclusive retention window. Post-page reads bypass the writer permit during asynchronous write backoff. They secure lazily created WAL and SHM sidecars to 0600 after their synchronous SELECT and before returning, without pruning reports. Each accepted post write, including an identical retry, evicts at most 256 expired rows hub-wide under the existing asynchronous write budget, then trims the writer to 128 reports. A different agent's post can evict reports from agents that never write again. A backlog drains over subsequent post writes; with no post writes, expired text stays in SQLite but is never returned by these reads. Exhaustion returns ConnectStoreBusyError with operation, timeoutMillis and a private cause; other failures keep their existing types. Interrupted retries leave no open transaction; cancelled archive observations break prior absence evidence, including equal-timestamp retries. Only a strictly newer successful observation can clear that taint. The state directory stays 0700, and the database and its lazy -wal/-shm siblings stay 0600. Symlink and writable-path refusals still apply. Work's read-only snapshots retain deferred transactions.
makeConnectHueEnrichment(path) acquires a separate SQLite connection in the caller's scope. Acquire it once in the server lifetime and pass it as the sixth argument to localConnectAgents. Only one hue allocation runs at a time; later polls skip it while it is pending. The directory waits at most half the peer deadline for allocation and permission finalization together, then returns uncoloured agents. The admitted worker retains its own transaction permit through finalization, independently of mandatory activity observations and request cancellation. Direct AgentActivityStore.assignHues and calls without the scoped enrichment owner still await their existing complete finalization.
Closing the enrichment scope stops admission, interrupts its worker and waits up to two seconds. Cooperative cleanup finishes before the connection closes. If cleanup remains uninterruptible, shutdown logs the tagged HueFinalizerShutdownTimeout warning and closes the connection at the deadline. The interrupted cleanup may finish later; shutdown does not wait a second time, and the closed owner accepts no further SQL work. No force cancellation of an uninterruptible filesystem call is claimed.
fetchPeerConnectAgents(peer) keeps its 1.5-second directory deadline. The canonical reporter path passes { timeout: "reporter" } to wait for the five-second inventory write budget plus a one-second transport margin. Each local inventory shares that acquisition and SQL retry deadline across relationship persistence and every activity observation, rather than restarting it per write. An exhausted operation reports the original five-second budget. Committed file-permission finalization remains outside the lock timeout under its permit. Only a bounded, schema-validated peer 503 for a known inventory write carries safe busy operation and timeout fields onward; the private cause stays on the host. Other peer failures keep their generic failure behavior. connectReportResponseTimeoutMillis gives report clients the reporter-inventory budget plus the primary-write budget plus the same one-second transport margin, currently twelve seconds, so an exhausted server write can return its typed busy response before client cancellation.
AgentPostStore persists reports in owner-only SQLite beside the agent archive. Retries use the exact host, stable agent ID, and event ID; changed content with the same identity returns Conflict. Reports retain 90 days and at most 128 entries per host and agent. GET /v1/connect/posts?host=HOST&agentId=AGENT returns the newest 64, with nextCursor.sequence supplied as cursor for the next page. Reads require canonical listener authentication. InvalidReport, UnboundAgent, Denied, Conflict, and Unavailable name the report failures.
The newest retained report supplies the directory's optional voice text and timestamp. Confirmed closure snapshots only words still retained at the closure observation time as optional lastWords on the archive row, including reports exactly on the cutoff. Existing archives migrate additively, and last words survive report eviction and reopening.
Creature identity and motion
Creature seeds its circle, squircle, pear, star or bean body from lowercase(host):canonical-id, where canonical-id is the stable ID without the transport agent- prefix. It uses rev6's unsigned FNV-1a, bits 8 upward modulo five. Drawing and persisted hue reservations share this selector; legacy motion phases retain their separate mixed full-ID seed. Body identity ignores host casing and stays fixed across state changes and sizes. Hue uses hash(lowercase(host),name) over eight design families. name defaults to id for standalone drawings; Connect supplies the agent name. hueFamily overrides the hash with the hub-assigned family. Connect passes each agent's served hue across every live and archive surface; missing fields keep the hash fallback, which can still collide on older peers. Plum is reserved for the chef mark and is never selected by the agent hash.
accessory defaults to none. Agents may wear neutral headphones, hard-hat, scarf or glasses; every accessory keeps the eyes clear. Chef headwear belongs only to the app mark and is outside the agent accessory set. talking defaults to false. A mouth and squash/stretch require both talking and size="stage"; rows, cast, pins and crew keep blush and their true body. Slot sizes and stage hero sizes stay owned by Connect's layout.
Ordinary agents use focused flat lids for working/running, raised brows and wider eyes for waiting/needs-you, worried brows for blocked/error, and happy eye arcs for done. Idle and ready rest with open eyes, and unknown states use waiting. Without task evidence, faces have no generic blink, brow twitch or reading motion.
ConnectAgent.taskKind is optional typed evidence from the fleet inventory. Creature.taskKind defaults to absent, preserving the legacy state presentation in Fleet and Home at row, cast and stage sizes. Supplied kinds select the motion vocabulary: reading scans and leans, writing bobs with stage-only ticks, testing crouches and releases, waiting sways and glances, and blocked gives a quiet double nudge. Row and cast variants drop stage travel and small details. Unknown is the quiet Working fallback. Explicit idle retains its seeded breath, blink, fidget and habit.
The Fleet service holds each new kind for 500ms before publishing it; the component adds no second delay. Trusted raw needs-input and done evidence wins over a stale debounced kind; explicit failing has highest priority. The task controller stays mounted through lifecycle changes, so matching kind catch-up does not replay a celebration or hop. Unknown raw state text cannot override a typed kind. Same-kind updates change the surrounding data without remounting the drawing or restarting its clock. Other changes blend over 300ms, except failure's 560ms wince, completion's 900ms celebration and the 360ms hop from blocked into reading, writing or testing. These are nominal durations multiplied by the agent's tempo; amplitude scales their travel. Named one-shots start immediately without a phase offset, play once and finish before the latest queued kind starts. Repeated failing or done updates never replay them. Mounting an already-failing or already-done agent uses its settled pose.
Removing taskKind returns ownership to the legacy state presentation. Needs-input and terminal states take precedence over incompatible debounced task evidence. Closed, stale, an explicit mood, talking and reactions also take ownership instead. The system or in-app reduced-motion preference swaps directly to the new settled pose and completes any pending one-shot; restoring motion cannot replay it. Forced colours use the plain static outline and eyes. See the browser-backed drawing examples for intrinsic task entry, externally owned stage reactions/poses, same-kind updates and reduction during a one-shot.
Motion signatures share the allocator's unsigned, un-mixed FNV-1a over lowercase(host):canonical-id, never the display name. Its bytes select tempo 0.85–1.15, amplitude 0.80–1.20 and a phase fraction. The final byte plus the rendered hue-family index selects tilt, flick, hop or squint on a separate 8000ms clock. Habits therefore follow the rendered family and can differ from the hub's intended family only when the served hue is absent; the legacy display-name palette fallback stays unchanged. Visible sibling order shifts only the second phase by one third when adjacent same-task signatures would run together. The base signature, body and hue remain unchanged. Offscreen animations pause through one shared visibility observer per document; one shared size observer converts habit travel to painted pixels. Rows halve hop travel. Typed motion also shares one document preference observer and two media listeners, releases them after the final subscriber leaves, and keeps nested in-app preferences scoped to their own roots. No animation-frame polling or per-creature observer is used.
mood overrides the state pose. Talking stages use the richer stuck pose for blocked/error. Entering done plays once for 900ms before happy; mounting or selecting another already-done agent starts happy. Happy loops over 1600ms, sleepy/waiting over 4800ms, thinking over 1500ms and speaking over a nominal 420ms mouth cycle. Focused keeps its lean and flat lids while scanning on the talking stage's 2400ms clock. Loops retain a negative per-agent phase, so polls do not synchronize the fleet. Worried plays once and holds its smaller talking pose; excited plays once for 640ms and settles. Stuck holds a uniform 96% talking body until the state or mood changes, then the next pose restores its body.
Stage reaction="listening" plays once for 720ms; reaction="poke" plays once for 620ms. reactionKey defaults to zero; increment it to replay, or remove reaction to resume the state pose. Each reaction starts at its beginning, independent of the looping phase, and returns to the true body. Talk controls own those triggers; row and cast sizes ignore reaction props entirely.
The system or in-app reduced-motion preference selects the reference's static face and stops all animations, including triggered reactions. A pending done celebration settles immediately to happy when motion is reduced, including a preference change during the animation. Poke replays restart the body, tap and tick effects together. closed defaults to false; it keeps body identity, drops chroma to ten percent, closes lids, lowers brows and hides halos/orbits. Stale creatures use grayscale, keep half lids, stay still and show no halo or orbit. Closed creatures stay still. Forced colours retain the body outline, eyes and adjacent state glyph; adjacent text carries the state in every theme.
ConnectSurface defaults to its Fleet presentation. Embedders may pass presentation={{ _tag: "home", summary }} to place a host-owned summary above the Home directory, or { _tag: "fleet" } for the existing surface. Home omits the Fleet cast and limits section; directory reads, stage selection, pins and terminal input keep the same owner and lifetime. The summary and empty-state ReactNode slots are optional only within Home. The empty slot replaces activity groups only for an unfiltered complete empty reading; Fleet does not render it. Silent-host Home rows say Unknown, retain cached work and name the reported last-activity time; Fleet keeps its last-known state rendering. Home groups Needs you, Working, Ready, Recently done and Unknown. Ready and Idle keep their own state words and show the reported last-activity time. Relationship metadata names Primary, Worker with, Pair partner with or Reviewer with; it does not invent coordinator roles. Clearing a filter never focuses search inside an inactive or hidden owner.
AgentStage.presentation defaults to "fleet". Home changes only its navigation header: Back to Connect on phone, Close on desktop, with the existing close, Escape and focus-return path. The dialog remains named by the agent; its body and terminal actions keep their existing owner.
AgentStage.terminalControl is optional. Absent keeps the ordinary stage body. controlled and taken-over replace it with the ownership panel and focus its Take over or Reconnect button on entry or an agent/state change. Directory refreshes update the copy without moving focus. connecting retains the stage while admission waits. The application owns onAction and terminal state. Closing returns focus through the same Sheet owner.
Embedded ConnectSurface owners may pass active={false} to hide the stage portal and report no watched stage while their module is inactive. The default is true. Selection, pins, directory polling and terminal/session ownership survive; reactivation restores the selected stage.
ConnectSurface.directoryLocation optionally supplies { url, navigate(href, "push" | "replace"), closeSelection(parentHref) } together. Absent preserves standalone URL subscription and history behavior. Present is fully host-controlled: link builders clone the supplied URL, navigation and selection dismissal call only the supplied callbacks, and there is no synthetic popstate event. The host consumes its own pushed selection through Back and replaces a direct or reloaded selection with its parent. Effects follow the URL string rather than object identity. Home includes Active/Archived, with the same bounded archive paging and read-only stages; its archive has no search. active={false} also hides archived stages and suspends expired-closure URL repair, retaining loaded pages and selection.
Cross-package browser checks
Changes to packages/herdr-hub/src/styles.css or packages/herdr-connect/src/styles.css require both packages' browser suites before push. Run pnpm --filter @knpkv/herdr-connect test:browser as well as the hub suite: Connect's fixtures consume the hub stylesheet. Embedded legacy fixtures use test/browser/legacy-fleet-fixture.ts to build .fleet-shell[data-fleet-layout="legacy"] > .fleet-shell-main, protecting the retained public FleetShell composition. The standalone /connect/ route uses bare Connect; canonical Home geometry belongs to the hub suite.
