Collie (Young Security fork: youngsecurity/collie; upstream: AltanS/collie) — a phone web UI
for your Herdr agent herd, served over Tailscale. A mobile-first PWA (Vite + React + TS + Tailwind
v4 + shadcn) plus a Bun/TS bridge that
talks to Herdr's Unix socket, letting you monitor and reply to agents from a phone. The Herdr
plugin id is herdr.collie (manifest: herdr-plugin.toml). Orientation:
README.md · ARCHITECTURE.md · verified API
HERDR_API.md.
This plugin is SemVered, and the version is enforced, so it never silently drifts. Young
Security releases use X.Y.Z+ys.N: X.Y.Z identifies the upstream base and N identifies the
fork release. SemVer build metadata deliberately has the same precedence as its upstream base, and
the update checker ignores the +ys.N part when comparing against upstream releases.
The version lives in three files that must always agree, plus a matching CHANGELOG entry:
herdr-plugin.toml (canonical — Herdr reads it) · package.json · web/package.json ·
newest ## [X.Y.Z+ys.N] heading in CHANGELOG.md.
Before committing any functional change (anything under bridge/, web/src/, scripts/, or the
manifest) you MUST:
- Bump the version in all three files to the same Young Security version:
- Keep the upstream base and increment the fork counter for fork-only work
(
0.14.0+ys.1 → 0.14.0+ys.2). - When adopting a newer upstream release, use its version and reset the fork counter
(
0.14.0+ys.2 → 0.14.1+ys.1). - Never publish a bare
X.Y.Zversion from this fork.
- Keep the upstream base and increment the fork counter for fork-only work
(
- Add a
CHANGELOG.mdentry under a new## [X.Y.Z+ys.N] - YYYY-MM-DDheading (Added / Changed / Fixed). Use the real date. Style: super crisp and short — one line per change, no prose paragraphs, and cite the feature's short commit hash at the end of the line (… (abc1234)). Land features as their own commits first, then cut the release commit so the entry can cite them. - Run
scripts/check-version.sh— it must print✓.
Doc-only changes (*.md) don't need a bump. This is enforced two ways, but you are the first
line — do it as part of the change, not after:
scripts/check-version.shruns insidescripts/collie-ctl.sh build(a release can't build while versions disagree).- A git pre-commit hook (
scripts/git-hooks/pre-commit, activate once withscripts/install-hooks.sh) blocks commits where functional code changed but the version didn't. Escape hatch for a single commit:SKIP_VERSION_CHECK=1 git commit ….
Tag the release when you push it. Cutting a release means the three version files + the newest
CHANGELOG.md heading agree on X.Y.Z+ys.N (steps 1–3). When that release lands on main and you
push, always push a matching annotated git tag with it —
git tag -a 'vX.Y.Z+ys.N' -m 'Collie X.Y.Z+ys.N' && git push origin 'vX.Y.Z+ys.N' (or
git push --follow-tags so the tag ships with the release). One tag per shipped version on the
remote. Not hook-enforced — it's on you. (Adding/adjusting this note is a doc-only change and needs no
version bump.)
Update notice (user-facing). The app's in-app update banner links to the newest release's GitHub
page and shows the command to run. Pushing a v* tag auto-creates that GitHub Release (with the
commands) via .github/workflows/release.yml. Always express user-facing update/restart
instructions as Herdr plugin actions — herdr plugin action invoke update --plugin herdr.collie
(or restart) — never collie-ctl.sh … / systemctl … collie, which depend on the caller's cwd and
the unit name; the Herdr action runs from anywhere.
- Frontend changes (
web/): rebuild withbun run build(root) orcd web && bun run build. The bridge servesweb/distfrom disk at request time, so on the deployment host a rebuild is immediately live — no restart. - Backend changes (
bridge/*.ts): Bun does not hot-reload the service — you mustsystemctl --user restart collie. Forgetting this is the #1 "my change didn't take" trap. bun run build(root) andcollie-ctl.sh buildtypecheck both sides first (root tsc + web tsc), then build web todist-stagingand swap it in atomically — a failed build never empties a liveweb/dist. Barecd web && bun run buildstill skips typechecking; don't ship from it.- Tests: frontend
cd web && bun run test(Vitest + jsdom + Testing Library + MSW; no headless browser); backend pure-logicbun run testat the root (Bun's own runner — coverscheckAccess,StateEngine,loadConfig). A pre-push hook (scripts/git-hooks/pre-push) runs both before every push — override once withSKIP_TESTS=1 git push. The bits that genuinely needBun.serve/Bun.connect(HTTP handlers, the socket client) stay unit-untested — Vitest-on-Node can't run them, so keep new backend logic pure/injectable enough forbun test, or exercise it throughweb/. - Service:
systemd --userunitcollieon the deployment host; logsjournalctl --user -u collie -f. - TS is strict on both sides, with
noUnusedLocals/Parameterseverywhere.web/additionally enforcesverbatimModuleSyntax+erasableSyntaxOnly(useimport type, no parameter-property shorthand there). The bridge tsconfig does not enable those two — bridge code uses parameter-property shorthand by convention; keep each side consistent with itself.
- Data flows through React Router (
createBrowserRouter, data mode): route loaders (web/src/lib/loaders.ts) fetch the snapshot + pane; polling isuseRevalidator()on an adaptive interval (web/src/hooks/use-polling.ts); mutations are directlib/api.tscalls followed byrevalidator.revalidate(). There is no TanStack Query — don't reintroduce it. - Routes:
/(home) and/pane/:paneId(detail). The idle-lock inApp.tsxunmounts theRouterProviderto pause polling; the router instance is module-scoped so it keeps its location. - PWA via
vite-plugin-pwa(web/vite.config.ts): manifest +sw.js, registered manually fromvirtual:pwa-registerinmain.tsx(bundled = CSP-safe). Install/SW need a secure context — over plain HTTP they no-op silently (Chrome insecure-origin flag, or HTTPS, to test).
- RPC is one-shot: one request per connection; the server closes after one reply.
idmust be a string. Onlyevents.subscribestreams. pane.send_keysgrammar is+-joined, not tmux:ctrl+c(NOTC-c),shift+tab,Up,Tab,Escape,Enter,Backspace.PageUp/Home/End/Deleteare unsupported.- Pane output is rendered as React text nodes (never
innerHTML); the ANSI parser only derives colors/weights. Keep it that way — it's the XSS boundary. Strict CSP + same-origin gate stay.
Loopback bind only · exactly one hardened front door — tailscale serve (never funnel) or a
conforming reverse proxy per README Variant C (COLLIE_SKIP_SERVE=1) · same-origin gate · optional
identity/device gates · strict CSP. A socket call can type into a real terminal — treat the bridge as
remote shell access.