Conversation
# Conflicts: # web/src/lib/loaders.ts
…et-upstream bypass note .env.example still claimed an absent device header means the on-host operator, which 0.15.0 inverted. That's a security claim someone copies into their own .env, so it's the worst kind of stale. README/.env.example also told operators to revoke a device with systemctl, which CLAUDE.md reserves for contributor docs. Pane history shipped in 0.15.0 undocumented: added it to the pitch and an ARCHITECTURE note on why the transcript, not the terminal, is the scrollback source. Added a Variant B warning for the case where the proxy's upstream is the bridge's own tailscale serve URL — that URL stays tailnet-reachable by construction, so the device gate is what keeps the direct path read-only.
The shape where a central ingress node fronts several tailnet services and Collie is one of them. Distinct from Variant B because the proxy can't reach loopback, so the bridge is published with `tailscale serve` and that published URL is a permanent path around the proxy. Two things verified against a live tailnet while writing this, both of which change the advice: - `tailscale serve --http` forwards a client-supplied device header untouched, so the proxy's mandatory override protects only the proxy path. A tailnet peer that sets an allow-listed id on the direct path gets full write access. The device gate closes the header-ABSENT case, not the forged one, so a Tailscale ACL is mandatory in this variant rather than a nice-to-have. - `tailscale serve --http` does inject Tailscale-User-Login, but it names the calling node's owner, so through a proxy every request carries the ingress node's identity. COLLIE_TRUSTED_USER is not a person gate here; it still rejects other tailnet owners, which is worth having. Host and Origin take different values in this shape (the proxy rewrites Host so tailscale serve can route), which is the setting people get wrong. The verify-it-works block ends on a probe that must FAIL to connect.
Three near-identical tricolons in the first 70 lines (intro, Motivation, Who is this for) all pitched the same thing, which is what made the opening feel padded. Cut the duplicates from the intro and the audience section; Motivation keeps its version, since that one earns the list by contrasting against Termux. The intro paragraph was also carrying four jobs at once after the 0.15.0 docs pass: pitch, a caveat about voice dictation, a feature list, and an alternate-screen implementation note. Split into pitch and features, and dropped the implementation note, which now lives in ARCHITECTURE where it belongs.
Two corrections, both from a parallel review that caught me testing from the wrong machine. The verification block was wrong in a way that would have taught readers my own mistake. A connection to the host's own tailnet IP is handled locally and never crosses the peer packet filter, so running the direct-path probe on the agent host returns 200 no matter how tight the ACL is. It tests the bridge, not reachability. The block now splits by what is actually under test: the ACL from a peer, the device gate on the host, with the trap called out. The old block was also internally inconsistent — with a correct ACL its probe 2 would have timed out, not returned the output shown. The grants snippet assumed Tailscale. On headscale it needs >= 0.29, and an unparseable policy takes the control plane down rather than failing safe; tags may not be usable at all, since older versions make tag ownership and user ownership mutually exclusive. Added the acls: form, and the point that adding an accept rule does nothing while a broader rule still covers the port — it has to be carved out of the range.
`cmd_unserve` ran a blind `tailscale serve --https=443 off` (or `--http=$PORT off`) keyed only off COLLIE_SERVE_MODE. If anything else on the host owned that root mount, `unserve`/`uninstall` silently unpublished it. Publishing had the mirror problem: `tailscale serve --bg … /` REPLACES an existing root handler, so `start` could take over a mapping Collie never created. Record the one mapping we publish in `<config-dir>/tailscale-managed-handler` (`<mode>:<port>|<HostPort>|<proxy>`), and make both directions prove ownership: - publishing refuses when the target root is occupied by a handler we don't own, or when the listener already speaks the opposite protocol; - teardown removes the recorded mapping only when the live root still matches the record, clears the record when the root is already gone, and refuses (keeping the record) when it was replaced out from under us. Also adds scripts/collie-ctl.test.sh, the first test coverage the control script has had. It fakes `tailscale` and `systemctl` on a scratch PATH with a throwaway $HOME, so the lifecycle runs anywhere and touches nothing real. Extracted from AltanS#26, which wrapped this in a NetBird front door that isn't being taken. The NetBird track, COLLIE_FRONT_DOOR and their tests are omitted; COLLIE_SKIP_SERVE stays the proxy switch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The ownership check refused to publish over ANY existing root mount on the target port. Every install that predates ownership tracking has exactly one: Collie's own mount, and no record of it, because no version that writes the record has run there. So `serve` failed on precisely the deployments that already worked: tailscale serve: no Collie-managed mapping recorded error: Tailscale serve already has an unowned root mount on :8787; refusing to overwrite it Reproduced against this host's real `tailscale serve status --json` and in the sandbox in both HTTP and HTTPS modes, with a fresh-host control that succeeds. Decide ownership by where the mount points, not by whether we have a record of it. A root already proxying to our own `http://127.0.0.1:$PORT` is adopted: republishing over it is a no-op, and we record it on the way through. A root pointing anywhere else is still refused, and a foreground serve session is never adopted — it belongs to a live process that is not us. Also stop a failed front door from aborting `start`. cmd_serve can now return nonzero, and under `set -e` a bare call exited before print_status_banner, so a serve problem left the operator with no banner and no URL — while the bridge was up on loopback the whole time. That inverted the README's troubleshooting flow, which says the bridge is fine and only the ingress failed. Wires the lifecycle suite into the pre-push hook. `bun run test` alone doesn't cover it: the hook calls `bun test ./bridge` directly, so the suite would only ever run when someone remembered to. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The README read as though Tailscale were a requirement. It isn't: the bridge's entire Tailscale coupling is one header read, and `COLLIE_SKIP_SERVE=1` has always been the "you own the ingress" switch — it was just filed under "reverse proxy", so a NetBird or ZeroTier user had no reason to find it. Variant E states the general case: point any tunnel at 127.0.0.1:$COLLIE_PORT, carry the Variant B proxy requirements over, know that COLLIE_TRUSTED_USER is inert outside `tailscale serve`, and pin a stable hostname before installing the PWA. Plus the reason the funnel rule isn't about Tailscale — it's about reachability, and any public URL inherits it. Records in CLAUDE.md why Collie manages exactly one front door, so the next proposal doesn't have to re-derive it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
feat: own the tailscale serve mapping (supersedes AltanS#26)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The reasoning for declining a second managed front door lived in a PR thread, which is exactly where it would be lost. The next NetBird-shaped proposal — Cloudflare Tunnel, ZeroTier, Twingate — would have re-derived it or quietly reversed it. .adr/ is for decisions that close off an option someone will reasonably propose again: the ones where you find yourself explaining why NOT rather than how. Not for what the code, a test name or CLAUDE.md already says. ADR 0001 records the front-door decision with what was actually measured — the one-line Tailscale coupling in the bridge, the credential leak into argv that PR AltanS#26 demonstrated, and that COLLIE_SKIP_SERVE=1 already made every other tunnel a one-command integration. Also the reversal condition, so it can be superseded deliberately rather than ignored. CLAUDE.md keeps the rule and drops the argument, per the split the folder's README sets out: rules there, reasoning here. Doc-only, no version bump. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Collie already binds an approval to the dialog the operator tapped: entryGuard re-reads the pane, re-derives the model and compares it before sending. That check is entirely client-side, so the bridge enforces nothing. POST /api/pane/:id/keys and /reply accept any keystroke with no precondition, which means a client that skips the guard writes blind, and the guard's own window still contains a full round trip from the phone to the bridge. This moves the decisive check behind the API without moving the dialog grammar. Both endpoints accept an optional expected_prompt: the region the client verified. When present, the bridge does one fresh pane.read immediately before writing and requires that region to still be there. On drift it answers 409 with code "prompt_changed" and writes nothing; the client maps that onto the existing "changed" path, so the UI refuses and refreshes as it already does. When the field is absent nothing changes, so existing clients are unaffected. The comparison is line-based over a normalised region. Only trailing padding and blank lines are ignored: leading indentation and internal alignment survive, because in a displayed diff or command they are content rather than redraw noise, and erasing them would let two different edits compare equal. The match must also end within a few lines of the tail. Both bounds are measured against the committed fixtures rather than guessed: at most one normalised line follows a real match, so a replaced prompt pushes a stale region well outside the window and is refused. The read depth for the check is derived from the expectation rather than from COLLIE_READ_LINES. The client reads 600 lines while that setting legally goes down to 1, so a small value would make every approval fail permanently. preview-select and multi-select gain an additive regionSignature: their existing signatures deliberately normalise the pointer and checkboxes out, so they are identities rather than verbatim screen text and the bridge cannot find them. The existing fields and every client comparison are untouched. In the multi-step flows only the first write after each guard is bound. Those macros change the dialog on purpose, so binding a later step to the region captured before the first one would reject every valid run. The later steps stay protected by the identity re-check the macros already perform. This is a mitigation, not a guarantee. The re-read and the send are two separate herdr RPCs, so a TOCTOU window remains by construction; it shrinks from seconds of poll, push and human latency to the few milliseconds between two local calls. Closing it completely needs a conditional input primitive in herdr, which does not exist today. A pair of contract tests pins both halves to the same committed regions, since the client derives them from parsed text while the bridge matches raw text and nothing else couples the two. Either side drifting turns one of them red.
Review asked for this, and rightly: entryGuard returned ActionResult |
string, so the success value was a truthy string where the old contract
had returned null. TypeScript caught the call sites, but the shape read
as a failure on the passing path. GuardOutcome is {ok:true,region} |
{ok:false,result}, the same discriminated-union pattern this feature
already uses on the bridge side for PromptBindingResult, ExpectedPrompt
and PromptBindingCheck.
Also moves the 409 carve-out out of the generic transport. doReq and req
now take an optional recover callback; sendKeys supplies the one that
recognises a prompt_changed body. The transport knows a caller may claim
a non-ok response, not which status or which body shape.
Moving the 409 carve-out out of doReq gave the handler to sendKeys only, but the bridge runs the binding check on both endpoints that accept expected_prompt. sendReply would have started throwing ApiError where sendKeys returns a value. Both now share one handler, and a test pins the reply path; it fails if the handler is dropped again.
docs: add .adr/ and record the one-managed-front-door decision
…he identity gate §6 called the loopback bind load-bearing without saying against whom. It bounds remote callers; locally it bounds nothing, because a TCP port is shared by every uid in the netns while herdr's socket is uid-bounded by file permissions. An agent deliberately run under another uid to contain it can therefore still drive the whole herd through the port — installing Collie silently removes that boundary. Say so, and point at the device gate, which is the one write gate that doesn't rest on "local means trusted". Also correct two claims. §6 said the owner login is asserted and other tailnet users rejected; checkAccess only rejects a *mismatching* Tailscale-User-Login and passes an absent one — safe under tailscale serve, which always injects it, and not safe behind anything that stops. The README's "reject anyone but you" had the same gap. Records that the device gate fails closed as of 0.15.0, and that device ids are proxy-asserted names rather than secrets. Reported by @simonallfrey. Fixes AltanS#33 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Variants C and E each re-derived the same mechanism in different words, and neither said which way it fails. It fails *open*: with no injector the login is absent, and checkAccess only rejects a *mismatching* Tailscale-User-Login (bridge/server.ts:890), so every request passes the check. State that once in Variant C and have Variant E point at it, per the docs rule against restating what lives elsewhere. Both now mention the startup warning that fires for exactly this case (startupWarnings, bridge/server.ts:338).
The heading is "##⚠️ Security — …". github-slugger strips the ⚠ and the em dash but keeps the U+FE0F variation selector, so the real anchor is "<U+FE0F>-security--read-before-you-run-it". Verified against the rendered page: id="user-content-\357\270\217-security--read-…". Two of the three links omitted that codepoint and silently didn't scroll — including the top-of- README Contents entry. The third had it as a raw invisible character. All three now use the percent-encoded form GitHub itself emits, so the source shows what it means.
docs: scope the loopback bind to remote reach (AltanS#33)
"Two prior PRs from a different contributor had described changes their diffs did not contain" is a durable negative claim about people in a permanent record, and the argument — we cannot exercise NetBird's CLI, so its contract would be maintained by reading prose — stands without it. Also drops "shell code sat outside both test suites" from the same sentence: 0.16.0 added scripts/collie-ctl.test.sh and wired it into the pre-push hook, so the claim contradicted this ADR's own "Shipped in: 0.16.0" header.
There is no global gate — each route calls checkAccess/guard itself — and /api/config returned before doing so. It was the one endpoint that bypassed the same-origin check and the COLLIE_PUBLIC_HOSTS allowlist. Low severity: it exposes the VAPID public key (public by design) and the build id, and with no CORS headers a browser can't read the response cross-origin anyway. But the host allowlist exists for DNS rebinding, where the attacker's page *becomes* same-origin, and this route skipped it. No client impact. The PWA calls it same-origin, read level doesn't require the device header, and ConnectionBanner short-circuits to AuthErrorBanner before its red-state probe runs, so a refusal can't surface as "bridge unreachable". Noted in passing by @Optic00 in AltanS#32; the rest of that issue is a separate design question.
fix(bridge): gate /api/config like every other endpoint
… input box Fixes AltanS#34. Reproduced on a real Claude pane: with a Bash permission dialog focused ("Do you want to proceed? ❯ 1. Yes"), a free-text reply had its text swallowed and its Enter ANSWERED THE DIALOG — the command ran. The message was destroyed and the bridge returned {ok:true}. The bridge is not at fault and cannot be: both Herdr RPCs genuinely succeed, because an ack means "herdr took the bytes" (HERDR_API.md), never "the TUI acted on them". Only a client that can read the input box can tell the difference, so the guard is client-side and there is no bridge change. Every other path that types into a live TUI (prompt-/wizard-/preview-action) already refuses to send a key it hasn't verified the pane is ready for — "Enter is never sent blind". The reply path was the one exception. It now uses the same choreography submitPreviewNote uses: 1. refuse outright while a dialog is on screen (free, from blocks agent-chat already builds), and do it BEFORE the destructive pre-clear sweep — those ctrl+k/Backspaces would land in the dialog 2. type with submit:false 3. poll fresh reads until the adapter sees our text on the "❯" line 4. only then submit — empty text + submit:true, so the bridge sends nothing but its configured submitKeys and the submit-key contract stays server-owned 5. never verified → NO key is sent at all, and the caller keeps the draft Scoped to harnesses with an adapter. Without one the input box is unreadable, and a raw-mirror heuristic would strand a no-echo input (a shell's sudo prompt) with the submit key withheld forever, so those keep the previous one-shot send. The verification read happens before the first sleep, so the common path now costs one live pane.read instead of the old fixed 350ms REPLY_SETTLE_MS. Test harness: the fake pane models a real input box (typed text appears on the "❯" line, submit clears it), so the composer suite exercises the real two-step protocol rather than bypassing it. Both new regression tests were negative-controlled — reverting the guard fails them with "expected 'sent' to be 'stalled'", and removing the fail-fast fails the composer test. Design reviewed before implementation; the verify-between-the-two-steps shape came out of that rather than the pre/post bookends I started with.
fix: never send the submit key until the reply text is verified in the input box (AltanS#34)
…ack does not mean Two facts that cost real debugging time on AltanS#34, both live-probed rather than assumed: send_text uses no bracketed paste — verified by sending into `/usr/bin/cat -v`, which prints control bytes: the text came back bare, no ^[[200~ framing. Since a PTY is an ordered byte stream, that kills every "the Enter overtook the text" hypothesis, which is where AltanS#34 debugging started and wasted time. And an ack means herdr took the bytes, not that the TUI acted on them. That distinction is the whole reason AltanS#34 could not be detected bridge-side, and the reason reply-action.ts reads the pane back. (`cat` is aliased to `bat` on this host — the probe needs the absolute path or it proves nothing.)
Both places that point a uid-containment reader at the device gate oversold what it buys. The gate is writes-only: guard() consults deviceAuth for "write" alone, reads need no Origin, COLLIE_TRUSTED_USER passes an absent login, and the Host allowlist always permits loopback. So the other uid keeps reading snapshots, pane output and transcript history, which for that reader is the half that actually leaks. README's sharp edge now qualifies the gate as writes-only; ARCHITECTURE §6 said "close the port with the device gate" and now says it makes the port read-only. Neither restates the Variant B text that already calls the header out as not a confidentiality boundary. §6 also records that closing the read side is outside what the bridge does: it needs an unshared port (own netns, or a uid owner-match such as nftables `meta skuid`), since a plain port firewall rule won't stop a same-host peer. Reported by @simonallfrey, following up on AltanS#33. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…side
Reworked exactly as asked, verified here before merging.
`entryGuard` now returns `GuardOutcome`, so the passing path carries its region instead of borrowing a type that reads as a failure, and `doReq` is generic again — it knows a caller *may* claim a non-ok response, not which status or which body shape.
I negative-controlled the reply-path regression you caught yourself: dropping `recoverPromptChanged` from `sendReply` alone fails `api.test.ts:86` with `Serialized Error: { status: 409 }`. The test fires, so that asymmetry can't come back silently. 364 backend and 1074 web tests pass on the merge result, both typechecks clean.
Two notes for the record, neither blocking:
The audit line carries the expected region, but `sanitize()` caps every string at 120 chars, so a guarded write costs a preview rather than 8 KB of dialog text per tap. That happened to be already handled — worth knowing it's load-bearing if anyone raises `MAX_STR`.
`prompt-action` and `wizard-action` bind `model.signature` directly while preview/multi-select needed the additive `regionSignature`. That asymmetry is only safe because those two signatures are verbatim screen text, which is exactly what `prompt-binding-regions.json` pins. The fixture set is the thing keeping it true.
Thanks also for raising the precondition upstream in the existing revision thread rather than opening a competing one, and for establishing that `pane.read` returns `revision: 0` unconditionally on 0.7.5. That's the fact that keeps this a mitigation, and it's now written down where it belongs.
…lled PWA The service worker answered every navigation except /api/ from the precached app shell, so a proxy's sign-in page was invisible to an installed app — and an installed app has no address bar to fall back on. A reload re-rendered the same refused UI. The only crack in the precache was the API's own namespace, so the workaround was to serve an HTML page under /api/, squatting a path Collie could reclaim at any time. /auth/ is now reserved: the SW always passes it to the network, Collie routes nothing there, and the bridge answers with a placeholder saying so, since the SPA fallback would otherwise hand back the very UI the operator is trying to escape. The refusal banner links to it. Reload alone was never enough — the banner said it let a proxy serve its sign-in page, which the precache made false. The link is an <a> with a real href on purpose: a button's click handler is a same-document action the SW never sees as a navigation. The denylist lives in web/src/lib/sw-routes.ts, imported by both the SW and the banner, and is asserted against the built dist/sw.js contract in tests — too narrow and the sign-in page is unreachable, too wide and Collie's own deep links stop resolving offline. Both failures are silent. Fixes AltanS#31
The README had grown to the point where the thing you came for was three scrolls past the thing I wanted to explain. This cuts it roughly in half and puts the how first. - Motivation and "Who is this for" are gone; the one load-bearing sentence (single user, one tailnet) sits under the hero where it belongs. - Features is one flat scannable list instead of four unlabelled groups. - Security keeps its opening and the funnel warning word for word; the four sharp edges and the defenses collapse into one list, with the uid boundary and the audit log linking to ARCHITECTURE.md §6 rather than restating it. - First run loses the internals it was narrating: every transcript and the "Is it actually working?" check stay, "Surviving reboots" moves to Manage & update, and the launchd and enable-linger detail now has one home. - Configure answers how, not why: slash commands in twelve lines, a new Multi-session note, dark mode without the contrast maths. - Commands and Herdr actions were the same table twice; now they're one. - Troubleshooting gains a symptom index, plain-search synonyms on the bold lines, and two entries it was missing — no push notifications, and `herdr plugin list` showing the old version. The pre-0.23.1 update repair lives here now, with Update pointing at it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Bun's socket.write() accepts only what the socket has room for and returns that count; the unaccepted tail was discarded, so a request past roughly 219 KB arrived truncated and the one-shot RPC then waited for a newline that never came. The dialer now parks the remainder and resumes it from `drain` until the payload is complete, treating a zero-byte accept as a stall to wait out and a negative one as the transport error it is; a connection that dies mid-write drops the remainder and rejects through the existing close/error path. The continuation arithmetic is a pure cursor (write-drain.ts) so it is testable without a socket. dial.test.ts drives a real ~1 MB payload over both dialers against a server that refuses to read for a beat — verified to fail without the resume. node:net (the Windows named-pipe branch) never short-writes: it queues and returns a boolean, so it needs no cursor. Live-probed against herdr 0.7.5 while fixing this: a request line is capped at 1 MiB server-side, now recorded in HERDR_API.md.
…live there The startup warning, the config doc, the SW reserved-path rationale and the QR comment all sent readers to "README → Variant C"; C moved to DEPLOYMENT.md in 0.31.0. Variant A stayed in the README, so its warning keeps pointing there — and the README's first-run log transcript, which quotes that warning verbatim, stays truthful unchanged. server.test.ts now pins the whole pointer (doc name included) for both, so the next doc move breaks a test instead of a reader.
…bs are commands too The Multi-session section said 1/0 while the Windows section said off and .env.example documents on — every form parses, but a reader shouldn't have to know that. Both now read on/off. The Commands table also gains push-keys and push-test, which are real Herdr actions and were only reachable from the Web Push walkthrough.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Herdr's pane.send_keys grammar accepts F1–F12 bare, and harnesses bind them to real actions (tmux windows, CLI hotkeys, agent-extension views like pi's CE Workflow: F7/F8/F9). The Keys tray had no route to any of them from a phone. Same collapsible shape as the Ctrl Presets: collapsed by default so the tray doesn't grow, expanding to a 4×3 grid rendered through navBtn — so the press echo, key-queue staging, and chords with armed modifiers (ctrl+F7, …) come for free.
feat(nav-tray): F1–F12 behind an F keys disclosure
Routine update follows tags within the installed major; crossing a major needs an explicit --major, because the consent must be typable on the no-TTY herdr action path the banner names. The gate ships in a final 0.x gatekeeper release (0.32.0) before v1 merges to main, and 0.x is frozen (not maintained) after 1.0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> (cherry picked from commit ce379b4)
… consent `update` targeted origin HEAD, so the day `v1` lands on `main` every 0.x install in the field crosses a major on its next banner tap — a release that says "you must act" delivered with no moment in which to act (ADR 0020). A managed (detached) checkout now resolves the newest strict `vX.Y.Z` INSIDE the installed major and detaches onto that tag; a linked clone keeps its branch and its ff-only pull, gated by a pre-flight read of `herdr-plugin.toml` at FETCH_HEAD. Both refuse a crossing and print the command that consents to it — `update --major`, also wired as the `update-major` plugin action, because a Herdr action has no TTY to prompt on. Backport of v1's fcb48a9. On `main` the update verb is bash, not the `cli/` binary that lands with 1.0, so target selection is ported into `scripts/collie-ctl.sh` and the shell suite (`scripts/collie-ctl.test.sh`) is the whole gate for that half — it drives the real git grammar against throwaway repos: tag targeting, both shapes' refusals, the strict tag filter, and the loud unknown-version fallback. (cherry picked from commit fcb48a9) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The pre-flight read the manifest at FETCH_HEAD of `git fetch origin HEAD` —
the remote's DEFAULT branch — while `git pull --ff-only` advances the current
branch from its own upstream. On any clone not sitting on the default branch
those are different commits: this repo's own deployment host is a clone on `v1`,
and once 1.0 lands on `main` a clone kept on a 0.x maintenance branch would read
main's major and refuse a pull that never leaves major 0.
Fetch with the configured refspec, resolve `@{u}`, and read the manifest there.
No upstream at all is left to git: there is nothing to judge and nothing to pull,
and `--ff-only`'s own "no tracking information" says more than we could.
Backport of v1's f8ad03d, into `main`'s bash update path.
(cherry picked from commit f8ad03d)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two ways to be behind exist now, and only one of them is fixed by tapping update. A pending major gets its own line and the `update-major` command, ranked below a routine release so the operator takes what the plain action can actually deliver first; "Up to date" no longer contradicts a banner three inches below it. (cherry picked from commit 76b2813)
The rows land as declared, in a file next to commands.toml: [[keys]] scope = "claude" label = "Yes" keys = ["Down", "Enter"] danger = true The Keys tray is two things, and only one of them is anyone's to configure. The KEYBOARD — Esc, the arrows, Enter/Tab/Space, the modifiers, the digits, F1–F12 — is fixed, because it is the phone's only route to keys the phone does not have. The CATALOG under "Presets" is six Ctrl chords someone guessed at, which is exactly the shape commands.toml already answers for the slash palette: on a pane your rows address, your rows are the whole list (ADR 0018), and a pane none of them address keeps the shipped six. So this is commands.toml's mechanism, not a second one: - One reader for both files (bridge/operator-file.ts) — the mtime check, the "no file is not an error" rule and the hold-the-last-good-rows failure posture are shared, so the two files cannot drift into two reload contracts. - One scope ladder for both (web/src/lib/operator-scope.ts): exact beats family beats unscoped, a family scope is only ever the catalog's own name for the family, and one name is one button. commandsFor() now reads it too. - One channel: the rows ride the same /api/config read the palette rows do, and the store behind it holds both (web/src/lib/operator-config.ts, renamed for what it now is). What differs is the grammar, and it is stricter than the palette's because these strings go to pane.send_keys: every chord is validated against Herdr's verified key list (HERDR_API.md) and normalised to one spelling, so `CTRL+Escape` and `ctrl+escape` cannot render as two buttons. PageUp/Home/End/Delete and tmux's `C-c` are dropped with a reason rather than left to fail on the wire, and one bad step drops the WHOLE row — a sequence missing a step is a different sequence, and this one types into a real terminal. The rows render through the tray's ordinary preset path, so the press echo, the two-tap on a danger row, key-queue staging under an armed modifier and the one-batch send of a multi-chord row all come for free. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
README gets the section next to its slash-commands twin, `keys.toml.example` ships the commented rows to copy, and `.env.example` points at it from where someone would otherwise go looking. ARCHITECTURE.md and CLAUDE.md name the two things the pair now shares — one reader, one scope ladder — so the next change teaches both files rather than one. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The mechanism line still described the first cut, whose gate read the manifest at FETCH_HEAD of `fetch origin HEAD` — the remote's default branch. f8ad03d corrected the implementation to judge the branch's own upstream; the record now says what the decision actually is. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> (cherry picked from commit 89f6e0624c522cde9c2e9f8f3f8b25399ec81ce7)
…shot/pane A phone leaves Collie for the Tailscale app, the browser discards the hidden page, and the operator comes back before the tunnel is up. The PWA then boots from zero: empty module caches, a first fetch that fails, and an empty herd where the screen they left should be. The in-session story was already right (a failed poll keeps the last good data, flagged); the hole was only the cold-boot-without-network path, which no in-memory cache can cover because the process that held it is dead. So every successful loader fetch now writes its payload through to sessionStorage (lib/last-seen.ts), and a failed fetch on a page with an empty module cache reads it back — flagged stale and DATED, so the UI can say when. The polling loop is untouched and still recovers on its own. sessionStorage, not localStorage: a terminal mirror is worth keeping for the seconds a tunnel takes to come back and misleading a day later, and a tab the browser discarded and restored keeps its sessionStorage. ADR 0017 rider: a pane sitting at a password prompt is never written through, and any text already stored for it is dropped. The recognition is the same detectNoEchoPrompt the refusal copy already uses, run over the tail of the mirror. The snapshot is still cached — the exclusion is that pane's text. Also fixes two lint findings the file carried (isAbortError/isAuthError taking unparsed input) as a side effect of touching it.
…d read "No agents running." is a claim about the herd, and only the bridge can make it. An empty list from a failed fetch means "we don't know", and `bridge` alone can't tell the two apart — a snapshot restored from cache still says "connected". So the placeholder now branches on the stale flag first and names the outage instead. Both stale surfaces are dated from the write-through cache's own stamp: the empty-herd placeholder reads "Disconnected — last seen 14:32", and the red connection bar appends the same to whichever cause it named. That matters most in the case the cache exists for — a full herd rendered from cache on a boot with no network looks exactly like a live one. The connection-health store is untouched: the timestamp rides the loader data that already reaches both surfaces, so escalation keeps its single source of truth.
The connection bar lives in RootLayout and was dated by HomeData alone. Cold boot straight into /pane/:paneId and the two stamps come apart: the operator opened that pane at 12:05 and left the dashboard polling until 14:32, so the 12:05 terminal text on screen sat under a bar claiming "last seen 14:32" — the same undated-old-screen dishonesty, one level down. paneLoader already carried the pane's own stamp and nothing read it. The pane route now has an id, RootLayout asks the router for that route's data (react- router returns undefined whenever it isn't active), and shownLastSeenAt picks the stamp of the data actually on screen: the mirror's while a stale mirror is what's being read, the herd's otherwise. An undatable mirror shows no time at all rather than borrowing one that isn't about it. No new store or context — both stamps were already in the loader data. ADR 0017 behaviour is untouched.
The gesture this gives a phone is the one a desktop gets from a herdr popup, and Collie can never show a popup: it has no pane id and is absent from herdr's pane API, so `pane.list`/`pane.read` — everything Collie renders — cannot see it. A pane is the only surface a phone can read, so the ephemerality has to be the pane's own lifecycle. Herdr already drops a tab whose last pane closes and a Space whose last tab closes, so a command that closes its own pane takes the whole Space with it: tap, look, quit, nothing left running. Rows live in `launchers.toml`, the third sibling of `commands.toml` and `keys.toml`, sharing their reader, their mtime-checked live reload and their hold-the-last-good-rows failure posture. A row is `command` plus an optional `label` (defaults to the command's first token) and `cwd` (defaults to home, `~` expanded). A control character in `command` drops the row rather than being stripped: the line is typed verbatim through `pane.send_text`, so a newline would submit a second line the operator never reviewed, and silently rewriting what runs is worse than refusing it. The configured rows are the allowlist. `POST /api/launch` takes a command STRING and runs it only on exact equality with a row, so the client names a row and never supplies a command line; the bridge interpolates nothing. That is why the route exists rather than the client calling /api/workspace and then typing into the new pane itself — doing it there would move the command line to the phone and leave no allowlist to match against. It carries the same write guard, the same session scoping and the same audit posture as the other structural creates, including the fail-closed redaction default: `command` is not added to METADATA_KEYS, and the line still answers who launched what, where, when. Enter is sent literally rather than COLLIE_SUBMIT_KEYS: that setting is the agent-dependent submit sequence for a TUI composer, and this is a bare shell prompt where Enter is the only key that means "run it". A send that fails rolls the Space back, so a launch that did not start cannot leave an empty shell. Two surfaces, because the rows answer two different questions. The dashboard carries a Launch section, folding on the same terms as Spaces and Recent — it is the one section whose height a config file decides, and `flex-wrap` fits two labels per row, so six launchers would put three rows of buttons between the herd you came to read and the navigator below it. Folded, its header still carries the count, which is the reason to unfold. The Space and pane headers carry a rocket that opens the same rows as a sheet. That is the case the dashboard strip cannot serve: you are reading an agent and want a glance at something else, which otherwise costs Home, tap, Back. A sheet row is a full screen width, so it shows the command under the label — the difference between trusting a button and wondering what it runs. Both surfaces render nothing at all when no rows are declared, so an operator who declares none keeps the dashboard and the headers they already had.
Owner
|
Thanks this is a cool addition!! I'm going to check the exact proposal and its shape in the upcoming days and get back at you. |
Contributor
Author
|
Happy to contribute to collie! You've built something great 👏 |
AltanS
added a commit
that referenced
this pull request
Sep 3, 2026
Contributed by enieuwy (12dd5e8, authorship kept). Changed on top: - Wait for the new pane's shell before typing, and one launch per tap. - The launcher moved out of the pane and Space headers into the dashboard and the "Switch pane" sheet, which now follows the thumb. - A launcher's cwd is optional: absent means here. - From a pane, a launch opens a tab beside it instead of a new Space. - Rows read per host over the pack link, from /api/launchers. - Gemini pass on the de, es, ja, ko and zh strings. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> # Conflicts: # CHANGELOG.md
Owner
|
Thanks @enieuwy. Merged as d3de982. We cherry-picked your commit with
You can close this as merged by cherry-pick. The merge commit is d3de982. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This is a prototype more than a proposal — I built it for my own phone, it has been running on my install for a day, and I am posting it mostly to ask whether the shape is right. Happy for it to be closed, or to be told the whole idea belongs in a fork.
What I wanted
On the desktop I have a herdr popup bound to a key that runs a small TUI, I glance at it, and I quit. From the phone I could not have that. As far as I can tell a popup is invisible to Collie by construction — it has no pane id and is not in herdr's pane API, so
pane.list/pane.readcannot see one. A pane is the only surface the phone can read, so the nearest equivalent I could think of was a pane that closes itself: herdr already drops a tab whose last pane closes and a Space whose last tab closes, so a command that closes its own pane leaves nothing behind.What this adds
launchers.toml, next tocommands.tomlandkeys.toml:I deliberately did not reach for a new env var — #109 → #112 settled where operator rows live, and this reuses that machinery rather than reopening it: same
operator-file.tsreader, same mtime-checked live reload, same hold-the-last-good-rows posture, same drop-the-row-never-the-file validation.A tap creates a Space labelled from the row, types the command, sends Enter, and navigates into the fresh pane with the existing
freshPaneflow.POST /api/launchtakes a command string and runs it only on exact equality with a configured row. My reasoning for a route rather than doing it client-side:/api/workspaceplus a reply into the new pane would put the command line on the phone and leave nothing to match against, whereas this way the client can only name a row. Same write guard, same session scoping, same audit posture as the other structural creates —commandis not added toMETADATA_KEYS, so it redacts underCOLLIE_AUDIT_CONTENT=nonelike any other content-bearing detail.Enter is sent literally rather than
COLLIE_SUBMIT_KEYS, since that is the submit sequence for an agent's composer and this is a bare shell prompt. A failed send rolls the Space back.Screenshots
Rows are
rumen-peek,showy-quota-peek, and alazygitrow that exists only to showcwd.The pane header at 390 px — Find, the launcher, the status badge:
One tap later, and then the part I actually care about: while it runs it is an ordinary Space, and after quitting it is gone.
SPACES (7)while runningSPACES (6)after quitting(Images live on an orphan
assets/launcher-screenshotsbranch in my fork, so they are not in this diff. Say the word and I will re-upload them as attachments instead.)Where I am least sure
commands.toml? I chose separate because a command row addresses an existing pane and has ascope, while a launcher creates the pane, soscopewould be meaningless for it —keys.tomlfelt like the precedent. Easy to fold in if you disagree./api/launchshould exist at all, per the reasoning above. This is the decision I would most like checked.confirm = truefor launcher rows, though command rows have it. Trivial to add if you want the symmetry.Checks
bun test ./bridge ./scripts→ 666 pass. Web suite → 114 files / 2393 tests pass. Both typechecks clean. New tests cover the grammar (defaults,~expansion, each drop case, later-row-wins, a non-arraylaunchers, a non-table row among good ones), the route (unlisted command rejected without creating anything; a listed row creates a Space with that label and cwd and types the command plus Enter; a send failure closes the created pane), and the two UI surfaces.Per CLAUDE.md I have left the version files and
CHANGELOG.mdalone.