* feat(history): group agent runs and restore any version (BEA-6)
BearDrive recorded everything and could restore nothing. Now every version
of a file has a Restore button — in the hub's History view and as
`bdrive restore` — and the changes one agent run made read as one card
instead of N loose rows.
Restore is a NEW put op pointing at the old blob: journals are never
rewritten, so one-writer-per-journal holds and peers converge on the
restore like any other edit. The hub reuses RemoteSource.Commit (the
upload commit minus the upload); the CLI writes the bytes into the working
folder and lets the ordinary cycle journal them, so the sync engine gains
no new write path.
Grouping is a pure frontend group-by on (note, device) over the existing
/history response — no journal or API change.
Known gap, stated in the UI and the docs: nothing in the hub writes a
delete op yet, so a file a run *created* cannot be un-created.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(history): don't repeat a run's note on every row in its card
UI pass on the real hub: inside a run card the note is the card's header, so
printing it again on each row said the same thing N times. The header now
carries the note (linkified, so an agent's session link still opens) and the
collapse control is its own button rather than the whole header — the link
could not live inside a button.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The local-first claim was asserted, never demonstrated: nothing anywhere
told you what your laptop chose *not* to send. `bdrive scope --explain`
walks the folder and prints two sorted lists — synced and not synced —
with counts and a pointer at what it does not answer.
The decisions come from the same walk the sync cycle uses. scan()'s
WalkDir decision tree moves into walkFolder (internal/syncer/walk.go),
the only copy of the rules; scan and Explain both go through it, so the
output provably cannot drift from real sync behavior.
Pure read: its own Filter, no Session, no volume flock, no network.
Fully-excluded directories collapse to one counted line; nested mounts
are annotated as syncing through their own project rather than called
"not synced", which would be a lie in a trust surface.
Known gap, deliberate: this answers "what leaves from now on", not
"what is already on the hub" — the footer points at `bdrive forget`.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
A /s/<token> page promises the reader it always shows the latest
version, then never says when latest was — so a stranger can't tell
whether a living wiki page is from today or last March.
Print FileInfo.Time, already in hand at the point handleShared renders,
as a muted line above the content: human date, precise RFC3339 in the
title attribute. Zero time prints nothing rather than a 1970 date.
Markdown shells only. The .html/.htm branch stays a raw io.Copy and
binaries stay untouched — the new test asserts byte equality for both.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
ShareDB.List ranged over a map, so the project Settings → Public links
table and the org-wide share audit came back in a different order on
every load — with a Revoke button on each row. Sort in List (Created
desc, then Path, then Token) so both surfaces inherit one total order.
Created.Equal rather than !=: a time.Time carries a monotonic reading
and a location, so two logically-equal instants can compare unequal,
which would make the comparator non-transitive.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The CLI has had --expires all along and the Public links table has always
said "no expiry" — naming an alternative the hub UI never offered. Every
link minted from the browser was permanent.
Expiry is offered AFTER minting, not before: the one-click share stays one
click, and PATCHing the token we just handed out keeps the URL already on
the clipboard valid. Minting with a TTL would instead create a second link,
since ShareDB.Create only reuses permanent ones.
- ShareDB.SetExpiry re-dates a live share in place; repo-write failure
restores the previous row rather than deleting it (unlike Create's
rollback, this row already existed — dropping it would revoke a live
link over a disk hiccup).
- PATCH /api/shares/{token} mirrors handleShareRevoke: resolve the token,
requirePerm(PermWrite), act. Duration parsing is copied from
handleShareCreate so the two routes can't drift.
- Share dialog gets an Expires select (Never / 24h / 7d / 30d). A failed
PATCH toasts and reverts the control. Copy link keeps the dialog's
initial focus — the new control would otherwise have taken it.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix(hub): a folder URL with a trailing slash is the same page as without (BEA-28)
* docs(architecture): Route.trailingSlash in the frontend diagram (BEA-28)
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
A history row was already an address for the version it describes — BEA-7
made clicking one open that version, and the pinned file page has had
"Download this version" since. But the row is a bare `div role="button"`
with no visible affordance, so a reviewer who dumps every <button> and <a>
on the page finds neither, and concludes recovery from a bad edit is
impossible. Undiscoverable is close enough to absent.
Every content-bearing row now carries "Open this version" and "Download",
sitting on the same line as "show changes". No API work — /blob?sha= has
always served the bytes.
`apiBase` becomes HistoryRow's own prop rather than riding inside the
optional `diff`, which is per-file-only: without that the two feeds that
pass no diff (project/subtree, and the folder's Recent changes) could not
build a download URL at all.
Neither control claims aria-expanded — only the note and the diff really
expand anything — and both stop click and keydown from reaching the row so
acting on a version never doubles as navigating it.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(hooks): register agent sync hooks per machine, not per project
Agent platforms read hook config only from the directory a session starts
in — never a parent, never a subfolder. Project-level hooks therefore fired
only for sessions that happened to start at the mount, and, living inside a
synced folder, they replicated one machine's agent config to the whole team
(a second writer of a file bdrive already owns). Claude Code additionally
ignores project hooks until the folder is trusted, so in practice they were
often inert without any visible sign.
Hooks now go to each platform's user config, once per machine, covering
every session in every folder; the existing shell guard keeps them a no-op
outside BearDrive projects. Install migrates away blocks older versions
wrote into projects, and `bdrive hooks uninstall` removes ours while leaving
foreign hooks untouched.
Setup is also one command now. init absorbs the skill install, prints the
hub link, and takes --server, so connecting to a named hub no longer needs a
separate login; the runbook forbids preflight and command chaining, since
each distinct command costs the user a permission prompt. For plugin users a
PreToolUse hook auto-approves bdrive's own setup subcommands — narrowly: any
shell operator in the command disqualifies it.
Also drops --shared in favor of `init . --only wiki,docs`, which writes a
managed block of .bdriveignore rules instead of a second scope mechanism.
Because those rules sync, `sync --prune` now refuses on a scoped project
rather than stripping everything outside the scope from the hub for everyone.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016aYntCWwdUhpzUfEk3ddyJ
* docs: fix stale claims an audit found against the new CLI
An audit of every doc surface against the code turned up claims that the
user-scope hook move and the one-command init made false: project-level
hooks "riding the repo", the Claude trust prompt, Codex's //hooks project
layer, `--no-hooks` skipping the skill (it does not), prune reconciling
against a per-device scope (it now refuses on a scoped project), and
`--scan-interval`/`--remote-interval` documented as init flags when they
only exist on `bdrive daemon run`.
Also documents the surface added today — `--server`, `bdrive hooks
uninstall`, and the plugin's PreToolUse auto-approval — refreshes the two
sample `init` transcripts to the real output, and corrects hook matchers
that had drifted from agenthooks.go.
`bdrive scope` told users to narrow an existing mount with `bdrive init .
--only <dirs>`, which resume then ignored — a dead end. Init now applies
--only on resume, writing the scope block, so the advice works.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016aYntCWwdUhpzUfEk3ddyJ
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Adding a path to .bdriveignore only stopped future uploads: anything that
synced before the rule existed stayed on the hub forever, with no command
that removed it without deleting it from local disk on every device.
Two engine changes make an explicit removal safe:
- materialize's delete loop now consults the filter. A cached path absent
from the replayed target that the rules exclude is dropped from tracking
instead of unlinked — without this, any delete op for a now-filtered path
wipes every peer's local copy, which is the data loss this issue is about.
- the filter is reloaded mid-cycle from the pulled .bdriveignore, before
materialize. A peer receiving the new rules and the deletes they justify
in one batch would otherwise materialize with stale rules and the guard
would never fire. materialize's write side is split into materializeFile
so the ignore file can land on its own.
On top of that, Session.Prune journals a delete for every path the replayed
state still holds that the SHARED rules exclude — reconciling against the
replay, not the local cache, because a path filtered out in an earlier cycle
was dropped from the cache back then and is invisible locally today. The
rules are deliberately ignore-only: the include scope lives in each device's
own .bdrive/config.json and does not sync, so pruning against it would let a
narrow-scope device delete a whole-folder teammate's files.
Plain `bdrive sync` and the daemon are unchanged — pruning is never a side
effect of editing .bdriveignore.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(hub): the history kind is a badge, not a fake disclosure toggle (BEA-17)
The +/x in a history row is the kind glyph (added/edited/deleted), but
sitting leftmost inside a role="button" row it read as a tree disclosure
control — clicked, it navigated away instead of expanding. Merge the glyph
and its word into one text badge and vacate the toggle slot: the kind is
now text (no icon shape, no colour-only meaning), and the row's only real
expander stays the note, which keeps its own control and aria-expanded
and now turns its chevron when open.
* fix(hub): fit DELETED in the kind badge and align the row's meta under the path
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Minting a public link was one click on the file page; revoking it meant
knowing to go avatar menu → Organization → scroll to "PUBLIC SHARE LINKS".
The action was instant and local, the undo remote and unhinted.
GET /api/p/{project}/shares already existed at PermRead with no frontend
consumer, so this is UI-only:
- A "Publicly shared" banner on the file page whenever the open file has
live links: the count, each URL, and copy / open / revoke — the same
words the Share dialog uses. Revoking updates it in place.
- A "Public links" card in project Settings listing that project's live
links (path, who, when, expiry) with Revoke.
- One SharesTable behind both, plus the org-wide audit, which stays as the
cross-project view and now links each row back to its file.
The banner shows for anyone with read (a member should know the folder
they rely on is exposed); Revoke only where the Share button already is.
The spec's double-mint bug does not exist: ShareDB.Create already reuses a
live share for (project, path) when neither side has a TTL, and the web UI
never sends expires_in. The real defect was the dialog claiming "Public
link created" on a second click — it now just says "Public link".
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The reported symptom was a read counter inflating while you watched: reopen
one file a few times as one account and it climbs. It does not — the ledger
debounce is correct, and a handler-level repro proves it. What moves is the
displayed *total*, which sums three kinds of reader (human, agent, share)
with independent debounces: your own revisits fold into one visit, but the
syncing agent's reads and share-link hits keep landing in the same number
with nothing to say so.
So the count was right and its framing was wrong. heatText now breaks the
total out by reader kind whenever more than people are reading — the seeded
"14 reads (9 agent)" becomes "15 reads (6 human, 9 agent)" — at the single
chokepoint every heat surface already routes through (file meta line, folder
row meta, folder subtitle, heat-dot tooltip).
No ledger change: the investigation found nothing to fix there. The spec's
prime suspect (a sandboxed-iframe fetch losing the session and recording as
"anonymous") cannot fire in hub mode — authGate 401s an unauthenticated
/api/ request before recordRead runs, and every authenticated identity
carries an account email. TestReadCountsOnePageOpenOnce pins the invariant
so this stays legible: one signed-in person, one file, one 10-minute window
is exactly one read, whichever of /render, /file, /download served it; a
second account adds exactly one; the sandboxed iframe's cookie-less fetch
adds none; history spelunking adds none; and no actor identity reaches the
/heat response. TestReadLedgerDebounce now also covers the other half — once
the window passes, the same actor counts again.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Below 430px `.dl-meta` was `display:none`, so a folder listing on a phone
showed a filename and an unexplained coloured dot — no read count, no size,
no date. The comment justifying it assumed the dot carried the signal, but
the dot's meaning lived entirely in a `title=` attribute: never shown on
touch, never read by a screen reader on any viewport.
- `.dl-row` wraps and `.dl-meta` takes a full-width second line at ≤430px,
indented 27px to align under the filename. The name still wins line one
and is never truncated; the full string fits at 360px, so no shortened
variant is needed.
- The heat dot gets `role="img"` + `aria-label` on every viewport, which
also fixes desktop screen-reader users.
- Playwright assertion in layout.spec.ts at 360/390/430: meta matches the
desktop string, filename untruncated, rows ≥44px, no horizontal scroll,
every dot has an accessible name.
Desktop (≥431px) is unchanged — measured identical row heights and meta
positions before and after.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
`bdrive logout` and the beardrive skill both told users the device token
"stays valid until it expires — revoke it from the hub's device list".
All three parts are false: there is no device list page or route, device
tokens carry no expiry field, and logout makes no server call at all — it
only rewrites the local settings file.
Both strings now say what is true. The CLI note moves to a package-level
`logoutNote` const so `login_test.go` can assert it, plus SKILL.md's logout
row, mentions neither a device list nor expiry.
Correcting the strings only; the real device list + revoke route touches
the `AuthProvider` seam and is filed separately.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(hub): open the project Dashboard to every member, rename /insights → /dashboard (BEA-12)
"Dashboard" was the first sidebar item a new member clicked and it always
refused: it landed on /<project>/insights showing "Insights is for hub
admins and org owners." The gate was client-side only — GET /heat is
gated on project membership and returns counts without actor identities,
so every member's browser could already fetch every number the page draws.
Drops the canInsights gate (nav item, dedicated route, project-home
embed, ⋯ menu entry) and renames the view route insights → dashboard so
the nav label, the URL and the page title finally agree. The shipped
/insights URL still resolves and normalizes to /dashboard (LEGACY_VIEWS
in router.ts) so bookmarks don't 404 and only one URL stays live.
No server change: /heat gating and response shape are untouched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs(architecture): router VIEW_ROUTES now names dashboard, with LEGACY_VIEWS
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Per-file history rows carried identity and a byte size and nothing else —
to answer "what did the agent change?" you had to download two blobs and
diff them by hand. Every non-first version now expands to a line diff
against the previous version of that path, with a +N −M count.
No new endpoint: /blob?sha= already serves both sides and the history
response already names both shas. No new dependency: the LCS is ~40 lines
in src/lib/diff.ts, unit-tested on node's built-in runner (npm test) —
node ≥ 23 strips the types, so the frontend gains no dev dependency.
Blobs are fetched only on expand and cached by sha with an infinite
staleTime (content-addressed, so staleness never applies). Binary is
decided on the bytes, never the extension; either side over 1 MB gets the
too-large fallback, checked against Content-Length before the body is read.
Diffs are per-file only — the subtree feed mixes paths.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(webapp): a history row opens the version it describes (BEA-7)
Clicking a row in any history feed called onOpen(e.path) and dropped the
row's blob, so every row opened the CURRENT file — a 7/25 "added" row
rendered content written on 7/26 with nothing on screen saying so. The
backend already served the exact bytes (/blob?sha=); only the UI could
not reach them.
A version is now an address: /<project-id>/<path>?v=<sha>. Routes carry
it (useLocationPath had to snapshot search too, or the URL would change
and nothing would re-render), the file view fetches the pinned blob, and
a banner names the version's time and author, says it is not the current
file, and offers View current + Download this version. /render gains an
optional ?sha= so historical markdown renders as markdown instead of raw
source; history views are still never counted as reads. Delete rows have
no content, so they stay unclickable.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(webapp): keep an old version from borrowing the current file's framing
An unknown ?v= sat on a blank pane through react-query's retry before
saying anything, and the topbar still showed the path's read counts next
to content the banner had just called historical. A pinned version now
fails fast and drops the heat line.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
`bdrive init --shared wiki` wrote `include: ["wiki/"]`, which compile()
treats as an unanchored gitignore pattern — so any nested directory named
`wiki` synced too. Shared-subfolder mode is what people use to keep private
material out of a project, and it was silently widening the scope: 15 files
under .agents/, .claude/ and .gemini/ leaked into a real project from
.../detector/shared/ dirs.
cleanShared now emits "/wiki/", which fixes both callers (init --shared and
bdrive scope add). config.LoadProject anchors legacy single-segment entries
on read, so the existing mounts are fixed without a re-init — and that also
keeps `bdrive scope rm wiki` working against pre-fix configs, with a
belt-and-braces unanchored candidate key in scopeRemove for any config that
bypasses LoadProject.
Not touched: compile() itself, and no delete op for the already-leaked
remote files (BEA-20 — a delete would unlink teammates' local copies).
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(hub): Billing entry with current plan in the account menu (managed hubs)
webapp.Server gains an optional Billing hook — the display mirror of the
Quota seam: managed deployments return (plan, url) per signed-in user and
/api/config exposes it as the 'billing' block; OSS hubs leave it nil and
nothing changes. The frontend renders a Billing item with a plan chip under
the Organization section of the account menu when the block is present.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* architecture: Server gains the Billing display seam
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(hub): Billing is an in-app view at /billing (managed hubs)
The account-menu Billing entry now routes to a real SPA view instead of a
standalone server page: /billing is a top-level route like /orgs, rendered
in the app shell from BillingView, which fetches config.billing.url with
Accept: application/json (plan, usage, seats, plan cards, checkout/portal
form URLs). OSS hubs without a billing block get an honest 'no billing on
this hub' page at that path.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The ignore file is exempt from filtering in Filter.Skip: it syncs even on
--shared mounts (where it sat outside the include list and was local-only)
and even when one of its own patterns matches it. One guard covers both
scan and materialize since they share the filter. Docs updated on all
three surfaces (SKILL.md, install.md, web/docs).
Claude-Session: https://claude.ai/code/session_01G3AdFdps7seYbW6FhyK58t
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(cli): init --shared accepts multiple subfolders (repeatable or comma-separated)
--shared is now a slice flag: `--shared wiki --shared docs` or
`--shared wiki,docs` sync several subfolders into one project
(include list ["wiki/", "docs/"]). The interactive scope prompt takes a
space- or comma-separated list. Entries resolving to ".", "", or ".."
error out — silently dropping them would widen scope to the whole folder.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AubcaQscjcQucXwh578vqG
* docs(plugin): skills/commands propose multiple --shared folders at init
The init/install flows now scan for all knowledge folder candidates and
offer them as one --shared list (one project, one permission set), noting
that folders needing different access belong in separate projects.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AubcaQscjcQucXwh578vqG
* feat(cli): bdrive scope — add/remove shared subfolders without editing JSON
`bdrive scope` shows the include list; `scope add`/`scope rm` edit it from
the mount root. The daemon re-reads config each tick, so changes apply in
seconds. rm deletes nothing (newly filtered paths drop from the cache with
no delete op); removing the last entry is refused since an empty include
list means whole-folder sync. add onto a whole-folder project is refused
for the same narrowing hazard. Skill/README/docs updated; scope added to
the cli-sync diagram's command list.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AubcaQscjcQucXwh578vqG
* docs+cli: scoping guide covers multi-folder --shared and bdrive scope; init hints on ignored --shared at resume
The scoping guide (the dedicated page for this feature) now shows
--shared wiki,docs and a "Change the scope later" section for bdrive
scope; setup-by-hand and project-files point at it. init resume with an
explicit --shared now says the flag is ignored instead of staying silent.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AubcaQscjcQucXwh578vqG
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Access was binary and org-wide: any org member got full read+write on every
project. Now each project carries four ordered levels, resolved by one
resolver and enforced at one choke point.
- `projectPerm` (perms.go) replaces `projectAllowed`; `proj(level, h)` in
server.go gates every per-project route by the level it declares at
registration, so no handler grows its own check.
- `Project` gains Creator/Default/Perms. `Default == ""` means write, so an
upgraded hub behaves identically until someone edits permissions.
- Creator becomes the first project admin; org owners are implicitly admin
everywhere in their org and a grant naming one is refused, not ignored; a
project always keeps at least one explicit admin.
- Default `none` makes a project invite-only. A `none` member is treated
exactly like a non-member, including on create-or-join by name.
- Rename/delete move from org-owner-only to project `admin`.
- Both metadata backends persist it: the file store rides along, the SQL
store gains `project_perms` plus an idempotent ALTER for the two new
columns (migrate() had only ever created tables).
Client side, a refusal stops looking like an outage: `remote.ErrForbidden`
plus `Result.ReadOnly` (push refused → pull-only) and `Result.NoAccess`
(pull refused → paused, working folder untouched). Neither sets Offline,
neither loses a local op, and re-granting self-heals on the next cycle.
`bdrive status`/`sync` and the daemon (once, on transition) say which.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The hub's brand fell back to config.volume — the bucket/dir basename —
so a hub on s3://beardrive/... rendered a lowercase "beardrive" logo and
tab title. Drop the fallback at the source (/api/config reports only what
a Brander provider returns) and let each app default on its own: the hub
to the literal "BearDrive", volume mode to the folder name (unchanged).
The e2e harness now seeds Volume: "beardrive" so hub.spec.ts's existing
#vault-name assertion actually catches this.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Project gains two optional fields — Description (<=280 chars) and Icon (a
lucide icon name) — and PATCH /api/projects/{id} becomes a real partial
update: every field is a *string, so only the keys present in the body
change, and {"description":""} clears where an omitted key leaves alone.
Validation returns 400 for an empty or >120-char name, a sibling-name
collision, a >280-char description, and an icon failing ^[a-z0-9-]{1,32}$.
The permission gate is deliberately untouched.
Storage: the file backend marshals Project whole, so it rides along; the SQL
backend needs the two columns added to an already-created table, which
CREATE TABLE IF NOT EXISTS can't do — hence addColumns(), an idempotent
ALTER helper (same shape BEA-2 introduces for creator/default_level, so the
two merge into one map).
Frontend: Settings is now shadcn sectioned cards (General / About / Danger
zone — adds card, separator, textarea to components/ui), with an RHF+zod
form that PATCHes only its dirty keys and refreshes the hub queries, so the
nav mark and dashboard header update without a reload. Icons come from a
curated ~30-icon lucide shortlist (named imports, so Vite still tree-shakes
the rest); an unknown or empty name renders the folder placeholder. The
glyph shows in the project mark on the switcher trigger and every menu row,
and beside the name on the dashboard header with the description under it.
The org admin panel loses its per-project Rename button, which collapses its
two project lists into one read-only list for everybody.
One fix found while driving the real UI: Tailwind preflight is off in this
app, so copied shadcn form controls rendered monospace/black and cards drew
a near-white hairline. Both are now supplied by slot in style.css.
Three 1440×900 screenshots of the seeded e2e hub for the Product Hunt
gallery (BEA-10): per-file history with agent attribution, Knowledge
Insights heatmap + reads×staleness quadrant, and the main hub file view.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Recorded for the Wed 2026-07-29 beta (BEA-33): /beardrive:install → agent
writes plan.md → teammate's agent reads it fresh → share link. Outputs
replay the real fresh-machine run verified against v0.10.0; vhs tape +
storyboard committed for reproducible re-renders.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
go install github.com/runbear-io/beardrive/cmd/bdrive@latest skips the
release ldflags, so every module-built binary claimed to be 0.1.0-dev —
useless in beta bug reports. Fall back to the module version Go stamps
into the binary (debug.ReadBuildInfo) when ldflags didn't set one.
Found during the BEA-33 fresh-machine quickstart check.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(cli): agent hooks never sync folders this device didn't opt into
The turn hooks decided "this folder is managed" from the mere presence of
.bdrive/config.json — a file designed to travel with the folder. Two holes:
- A config.json arriving via git clone / copied dir made one hook firing
silently mint a device identity, register the mount, create a volume
store, journal the whole folder, and inject the hub-link formula — on a
device that never ran init or login.
- `bdrive stop` only killed the daemon: the next agent turn's
`bdrive sync --hook` resumed a full sync cycle and kept injecting links,
and `stop --forget` was undone within one turn by registry self-heal.
Fix: one gate (`syncBlocked`) in the paths all hooks route through —
sync/sync --hook/read-log now require the mount to already be enrolled in
this device's mounts.json (read without ResolveMount's enrolling
self-heal) and not paused. Hook mode exits silently; plain `bdrive sync`
errors with a `bdrive init` pointer. New per-device paused marker in the
volume dir: set by `bdrive stop`, cleared by `bdrive init` (startSync).
Only init enrolls or resumes; folder moves still self-heal since
enrollment is keyed by mount id, not path.
Docs updated (README, SKILL.md, docs cli reference, CHANGELOG). Tests:
hook/read-log no-op + no-enrollment on unenrolled and paused mounts,
plain-sync refusals, stop→pause→forget regression, paused marker contract.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: architecture-diagram PRs must show before/after excerpts of changed classes
The "Architecture changes" PR section now names exactly what changed and
shows Before and After mermaid excerpts scoped to the affected classes and
their immediate relationships — never the full diagram (Before = merge
base). Convention updated in CLAUDE.md, architecture/README.md, and the
pre-PR hook's reminder text.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: add cli-sync architecture diagram; widen diagram convention to the CLI
architecture/cli-sync.md draws the CLI and sync engine (cmd/bdrive +
internal/{syncer,store,journal,config,daemon,agenthooks}): the Session
cycle over Store/journal/remote, and the command layer with the new
syncBlocked opt-in gate, paused marker, and enrollment ownership. The
pre-PR hook and CLAUDE.md now watch these packages too, so CLI-side
structural changes trigger the before/after-excerpt convention the same
way server changes do.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: full-coverage architecture diagrams — overview, frontend, agentskills
Every application package is now drawn somewhere: overview.md (system
diagram — package map, device↔hub↔storage flow, agent surfaces, and the
private cloud/ repo as an external seam consumer), webapp-frontend.md (the
hub SPA's modules: App/HubApp/VolumeApp/Browser, the in-repo nav/router,
api layer, hooks, components), and agentskills added to cli-sync.md. The
pre-PR hook now watches all of cmd/, internal Go code, and frontend/src
(generated static/ excluded); CLAUDE.md and architecture/README.md state
the coverage rule: every code change lands in exactly one detail diagram's
scope, web/docs and cloud/ deliberately excluded.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: PR bodies start with a TL;DR — max 5 informal one-liners
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The anti-lock-in story for cloud-hesitant users: export a project's
complete store (every device's journal + every blob, i.e. full history
and authorship) into a portable tar.gz, and import it as a fresh project
on any other hub — cloud → self-hosted or back.
The archive is simply the remote store layout plus a manifest, streamed
through the existing remote.Backend, so no server-side support is needed
and it works against every existing hub. Import verifies each blob's
content hash, rejects foreign tar entries, requires an empty target
project, and refuses journal-less archives. Reconnecting devices resume
exactly where they were, because their journals are byte-identical.
Docs: README + SKILL.md command tables, docs-site CLI reference section,
and a new step-by-step reference page (Migrate between hubs).
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* tooling: pre-PR hook keeps architecture/ diagrams honest
PreToolUse(Bash) hook blocks gh pr create when internal/webapp or
internal/remote changed but architecture/ didn't; CLAUDE.md documents the
rule and the '# skip-diagram-check' escape hatch for non-structural changes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* ci: bump beardrive-cloud's OSS pin on every merge to main
Each OSS main push commits the new sha to OSS_COMMIT in
runbear-io/beardrive-cloud (CLOUD_BUMP_TOKEN: fine-grained PAT, that repo
only, contents r/w — already set). The bump push runs cloud CI against the
new pin and, only if green, the prod deploy — closing the OSS half of the
CI/CD loop. Rebase-retry loop absorbs racing merges.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
- init -f no longer prints 'daemon now keeps this folder in sync' after
the foreground daemon has exited
- one stdinIsTTY() helper (TTY or Cygwin pty) shared by init's prompt
gate and login's headless fallback — the two sites disagreed on Cygwin
- the daemon drops its remote backend when the device token changes, so
an account switch mid-run reconnects with the new credential instead
of pushing with the old one (httpBackend captures the token at open)
- whoami reports a settings read error instead of claiming 'not signed in'
- self-hosting/authentication and manual/setup-by-hand now describe the
automatic device-code fallback instead of presenting --device as required
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
architecture/ holds mermaid class diagrams of the bdrive web server: the
Server core with its seams (Source, AuthProvider, Directory, QuotaProvider,
remote.Backend) and the MetaStore persistence layer.
Convention (CLAUDE.md): a PR that changes the drawn structure updates the
affected diagram in the same branch and embeds only the changed diagrams'
mermaid blocks in the PR description. A PreToolUse hook on gh pr create
(.claude/hooks/check-arch-diagrams.sh) blocks PR creation when
internal/webapp or internal/remote changed but architecture/ didn't;
override with '# skip-diagram-check' when nothing structural changed.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
BEA-7 launch-critical set from the onboarding audit:
- login: shells without a TTY auto-fall back to the device-code flow
(agents/CI/SSH no longer hang on the browser callback); a failed
browser open also falls back, and the waiting state hints --device
- bdrive --version now works (cobra root Version), same output as
bdrive version
- init prints a next-steps block: daemon auto-sync note, the
'bdrive init --project p-xxx' connect command for teammates,
bdrive log / bdrive share
- authorship: the daemon re-reads settings.json every tick so a
login/logout/account switch is reflected in op authorship instead
of stamping a stale identity forever; whoami now shows the
signed-in account and labels the git/OS author as the signed-out
fallback
Docs updated in README, plugin SKILL.md, and web/docs reference/cli.md.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Root --help still described the retired direct-to-bucket model (S3/GCS as
the sync transport); clients sync only through a hub now. And with
database: sqlite/postgres the hub startup line printed a projects.json
path that is never read — misleading for self-hosters checking where
their metadata lives.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
.dockerignore excluded plugin/, which is now a compiled package
(plugin/embed.go embeds SKILL.md). Add .gcloudignore so source uploads
skip node_modules; anchor /bdrive so cmd/bdrive isn't excluded.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UdqEkKvj4Dc2d718mcV6EY
A fresh hub following docs/self-hosting.md was a locked room: invite-only
(the default) showed "Sign up disabled" with nobody to mint an invite, and
the approval-gated posture stranded the first admin as pending forever.
Emails on the config's admin list are operator-vetted, so they now
activate immediately on signup (any posture), and while the hub has zero
accounts they may sign up even on an invite-only hub. Strangers still
can't take the bootstrap slot, and the door closes after the first
account. Validated end to end from scratch: hub boot → admin signup →
device-code login × 2 devices → init → bidirectional sync → hooks install.
Also adds the missing GitHub Actions CI workflow (build/vet/test on
ubuntu + macos) — the repo previously had no CI at all.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add a job-shaped use-case page for engineering teams whose customer
context lives where coding agents can't see it: sync a context/ folder
into the repo via --shared, gitignore it (one-writer invariant), point
AGENTS.md at it, and run the in→used→back loop. Register it in the
sidebar between team-wiki and company-brain.
Also sharpen team-artifacts' "send a person a link" story — markdown/HTML
render as pages at a URL, member-only vs public links.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012sSVdMviADW8pu6sAJ4SfX
The hub already abstracted authentication — AuthProvider, with BuiltinAuth
as the built-in implementation — and then reached around that seam three
times: Accounts() was declared on neither interface, admin.go type-asserted
*BuiltinAuth (five handlers silently degraded to 404/empty under any other
provider), and organizations were not on the seam at all.
That last gap had teeth. A deployment whose identities come from elsewhere
had no way to own its orgs, so the code that did own them wrote into the
hub's OrgDB from the side — and nothing stopped the hub from inventing an
org that the identity system had never heard of. One did: a hub-created org
held every project while the mirrored one sat empty, and no sync path could
see the difference.
Directory (directory.go) is where organizations live now. LocalDirectory
wraps today's OrgDB unchanged — same last-owner protection, same normEmail,
same "o-"+randHex(4) ids, same file/SQL persistence — so a self-hosted hub
behaves exactly as before. A deployment whose orgs are owned elsewhere
implements the same interface, returns ErrManagedElsewhere from the write
half, and the handlers answer 409 with ManageURL. The hub never learns why
a write was refused, only where to send the user.
Two rules shape the interface. Reads are on the request path: Role runs on
every project request, including the /store/* endpoints a device hits every
few seconds with a token that carries no identity claims, so an
implementation backed by a remote system answers from its own cache — and
that cache is its business, not the hub's. Writes are optional, because
"this hub owns its orgs" is a deployment fact, not a code path.
- Server.Orgs *OrgDB becomes Server.Dir Directory: 28 call sites, 8
nil-checks, one writeDirErr helper for the 409 translation.
- /api/orgs gains manage_url per org — the destination of the account
menu's Settings entry. The client follows a link and never branches on
which kind of hub it is talking to.
- Org administration becomes a real route, /orgs/<id>, retiring one of the
two URL-less panels CLAUDE.md grandfathers. When a directory's ManageURL
is not hub-local, the SPA fallback redirects there instead — so a hub that
cannot administer its orgs cannot paint a console whose every control 409s.
- Accounts() moves onto AuthProvider. admin.go's type assertion becomes an
optional AccountApprover, and a provider without one now answers 503
rather than an empty approval queue: "no queue here" and "queue is empty"
are different answers and only one of them was true.
Two reviews drove the rest. The architecture review caught a browser page
load that could delete org members (a display read ran the full membership
reconcile, and a 200 with an empty user list evicted everyone), one write
site that escaped the 409 translation, and a webhook that could wedge an
event stream behind an unappliable event. The design review, over eight
rounds, caught the org page rendering live controls on a hub that cannot
use them, a share link made unrevokable by a long filename, nine keyboard
tab stops parked off-screen behind a closed drawer, and — five separate
times — a fix of mine that looked right in the source and did nothing in
the browser.
Conformance tests run both a writable and a read-only implementation against
one contract; the seat, prune, and out-of-order regressions each have a test
written to fail against the old code.
Sidebar order is now Start here -> Working with agents -> Manual setup
(optional) -> Use cases -> Self-hosting -> Reference -> Concepts.
README and CLAUDE.md carry the group order and the rule for what belongs
in each, so both move with it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
"Is this for me?" was answerable only by reading the guides and doing the
translation yourself. These five pages answer it directly, sit between
Start here and Working with agents, and route out rather than re-teaching
features:
- Share work across your team's agents — a team that doesn't live in a
terminal: Cowork and Claude Code share plugins, so the agent does the
setup and nobody opens a shell.
- Keep a wiki your agents maintain — knowledge written as a side effect
of work; Insights' hot-and-stale quadrant is the maintenance queue.
- Turn a personal brain into a company brain — OKF bundles, gbrain repos,
Obsidian vaults. They are already markdown directories, so there is
nothing to convert.
- Run a personal wiki, publish part of it — history as the point, plus
per-file public links.
- Carry one context across agents and devices — one project, many mounts,
and what actually happens when two machines edit one file.
Titles are job-shaped; the persona is named in the first line and in the
description, which is also the search snippet and the llms.txt line.
The company-brain page is the long one (790 words vs ~400) because it
carries two frictions worth being honest about. gbrain's own team setup
shares a brain through a remote Postgres, an HTTP MCP server, and
per-teammate OAuth with isolation enforced in SQL; file sync plus each
person's local brain skips all of that at small scale, and the page says
what the server still buys you rather than dunking. And privacy does not
map cleanly: gbrain scopes per person, BearDrive's unit of membership is
the project, so a walled boundary is a separate project — stated plainly,
with a table.
Unverified: whether "let one machine run consolidation" matches how
gbrain teams actually work. Written from the docs, not from practice.
Verified: 23 pages build, zero broken internal links, both external
references (Google Cloud's OKF announcement, the gbrain repo) resolve 200.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The sidebar and the homepage disagreed. index.md's "Where to start"
already led with connecting an agent, but the left rail read Install
(brew) -> Quickstart (bdrive login, bdrive init) -> ... -> Connect an
agent, three groups down. Anyone following the rail met the CLI first and
the skill last — the opposite of how the product is meant to be adopted.
The agent page was also filed under guides/, which this repo defines as
agent-workflow docs rather than setup.
Sidebar order is now the recommended path, and that path is agent-first:
Start here what it is -> set up with your agent -> your first hour
Working with agents shared memory, artifacts, read heat, scoping
Manual setup (opt) install the CLI, set up by hand, skills and hooks
Self-hosting / Reference / Concepts unchanged
- start/setup (was guides/connect-an-agent): rewritten as the front door.
Claude Code's plugin, then the one-paste for Codex/Gemini/Hermes, then
what the agent just installed and how to check it.
- start/first-hour (new): the page that was missing — ask for a doc, get
a link back, share it, a teammate's agent picks it up. What success
looks like without a command you have to type.
- manual/skills-and-hooks (new): the mechanics lifted out of the old
onboarding page — per-platform paths, hook events, idempotency,
project-level vs per-user — so the Start page can stay conversational.
- manual/install and manual/setup-by-hand (were start/*): both now open
by saying you probably don't need them, and link back to the agent path.
- index.md leads with "You don't install it — you ask your agent to";
the CLI and hub sentence moves below it.
No `brew install` appears anywhere in Start here. Reference -> CLI stays
exactly where it was: the people most likely to self-host are CLI-first,
and burying it would read as condescending.
Three public URLs moved, so astro.config.mjs declares redirects. Static
builds emit meta-refresh only, so README carries copy-paste 301 rules for
the host — Firebase Hosting and a Cloud Storage + load balancer URL map.
Verified: 18 pages build, zero broken internal links across the built
output, all three redirects resolve. CLAUDE.md and the docs README record
the rule so this doesn't quietly revert.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replaces the 🐻 emoji standing in for a logo everywhere. The mark is the
letter B built from three rectangles — a rail and two blocks, the same
shape as the product (a spine with volumes hanging off it). One fill, so
`currentColor` themes it in the sidebar, the favicon, and flat ink.
- Web app: <Mark> in shell.tsx replaces the emoji-in-a-gradient-tile
badge; the mark takes the honey and the wordmark takes text colour, so
the accent lands once. #vault-name sets in Jersey 10 at 18px — the face
is condensed, so that measures like 13px of the UI face.
- Auth pages (authlocal.go): server-rendered, so they had their own emoji
logo. Same mark, inline.
- Docs: bear.svg becomes the mark (fixed honey fill — Starlight renders
the logo as <img>, which can't inherit currentColor), and .site-title
sets in Jersey 10. Starlight tints that title with the accent by
default, which put honey on white in light mode and failed contrast;
it now takes --sl-color-white, matching the app.
- Favicon: the mark, as a data URI.
Jersey 10 is SIL OFL and self-hosted in both trees — Vite fingerprints
the app's copy into static/assets/, the docs serve theirs from public/ —
so no surface makes a third-party font request. Licence ships beside each
file. It is deliberately not a design token: tw.css's @theme block is
mirrored by the cloud landing's tokens.css and a drift check fails the
build if they diverge, so the logo face lives in plain CSS.
The cloud landing page carries the same mark and face (separate repo).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds web/docs — the public product documentation, built with Astro +
Starlight and deployed on its own rather than embedded in the binary.
Why a standalone site in the OSS repo, rather than a section of the cloud
landing page:
- Docs change far more often than the binary does. Embedding them would
mean a Go rebuild and redeploy to fix a typo, and would ship a Pagefind
search index inside every self-hoster's install.
- Self-hosting instructions and the CLI reference document the OSS
project, so "edit this page" should resolve to something an outside
contributor can open a PR against.
- Design tokens get easier, not harder: scripts/tokens.mjs generates
src/styles/tokens.gen.css from the @theme block in the hub frontend's
tw.css, so there is one source of truth and nothing to police. (The
cloud landing sits across a module edge and has to keep a *copy*,
guarded by its own check-tokens.mjs.) custom.css maps Starlight's
--sl-color-* onto those tokens and invents no colors of its own.
Content is seeded from README.md, docs/self-hosting.md, and the plugin
skill. Guides deliberately cover agent workflows — connecting an agent,
the two-file AGENTS.md orientation pattern, artifacts and links, read
heat, scoping the folder — rather than re-teaching the CLI, which lives
in Reference.
starlight-llms-txt emits /llms.txt at build. Convention wants that at the
root domain, so beardrive.ai/llms.txt should point here; that redirect
belongs to the cloud landing and is the one cross-repo coordination point
this split introduces.
Claude-Session: https://claude.ai/code/session_018GcqsM6prjdv9rUrhVVEiC
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>