* feat(webapp): history can be narrowed by path, author and date (BEA-67)
The project History view was a flat scroll with no controls but per-row
restore/open/download. Fine at eleven rows; unreadable after a month of
agent writes, and agents write far more than people do.
Four reader filters on GET /api/p/<id>/history — q= (case-insensitive
substring of the path), user= (exact account), since=/until= (UTC bounds,
inclusive at both ends, RFC3339 or a bare YYYY-MM-DD). They compose with
each other and with the existing path=/prefix= scoping, and they are
applied in the same walk as path/prefix — BEFORE the sort and the cursor
skip — so next_cursor keeps meaning "the next matching entry" and paging
under a filter needed no new machinery. kinds[] is still computed over
every op, so a filtered view classifies edits the same as the full feed.
A malformed since/until is a 400, not a silently unfiltered feed.
The filter bar drives those params through the URL rather than component
state: a narrowed feed is a link you can send, it survives reload, and
Back undoes it. The author list accumulates across fetches — rebuilding
it from the current feed would strand a reader who filtered by one
author with only their own name to pick from. Zero matches gets its own
empty state with a Clear.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs(architecture): the frontend diagram parses again
The escaped quotes inside the NewProjectDialog note made the whole
classDiagram fail to parse — it has been rendering as an error box, not a
diagram, since that note landed. Same breakage on origin/main; noticed
while adding HistoryFilters to the same file.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three defects on one panel, all of them stopping it from delivering its one
insight — "these docs are hot but stale, go fix them":
- The hottest file plotted at exactly y = M.t with a radius up to 7px, so the
most important point on the chart straddled the top border. The plotted box
is now inset on both axes by the largest radius the size formula can
produce, derived from that formula rather than hardcoded, so a future radius
change can't quietly reintroduce the clipping. Thresholds, the danger rect
and the dots all read X/Y and shift together; the axis lines use M and stay.
- No dot said which file it was — identity lived only in the hover tooltip.
The six busiest hot+stale files now carry their basename beside their dot,
flipped to the dot's other side rather than leaving the frame and stacked
when two would print on one baseline. Placement is a pure function in
lib/heat.ts so it can be unit-tested; every dot keeps its title and click.
- "dot size = agent share of reads" was drawn inside the <svg> directly under
the right-anchored "hot + stale" label, 14px apart. It moved to the panel
heading row, capped at the chart's own max-width.
The e2e seed grew three hot-but-months-old files: every seeded file was hours
old, so the danger quadrant — the whole reason this panel exists — was empty
in every test that had ever run against it.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The newest row for a path IS the file's current content, so its `restore`
button could only ever journal a +0 −0 change — attributed to a real person,
on a real device, replicated to every teammate, in the audit trail the whole
history story depends on. It was also the single most tempting row to click.
One rule, enforced at both ends. handleRestore now 409s when the requested
sha is already the path's head (journal.Replay, the way the CLI already
answers this question), placed after the "no such version of that path" 404
and before CheckWrite so an unknown sha still 404s and a refused restore
records no quota. HistoryView computes each path's head from the loaded
window — entries are strictly newest-first, so a path's first occurrence
decides — and restoreSha returns undefined for bytes that already are the
head, which removes the button, its title and its busy state together.
The rule is content equality, not row index: an older row hand-reverted to
the current bytes is just as much of a no-op, and matching what the server
checks means the UI can never show a button that errors.
A newest DELETE leaves the path out of the replay, so a deleted file still
restores — that is a real change. Confirm-on-restore stays out, deliberately
(HistoryRow.tsx:154): the defect was never "restore should ask", it was
"restore should not be offered where it cannot do anything".
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
⌘K on a path that doesn't resolve degraded to History ×2 and Sign out —
the tree-derived entries are gone on a dead route and the switcher lists
only OTHER projects, so on a single-project hub the palette, which is the
natural escape hatch there, was the one surface with no way out.
Four static entries now lead the candidate list whenever hub && project:
Go to project root, Dashboard, Installation, Settings — the same four
destinations (and icons) the sidebar has. They're independent of the tree
and of whether the path resolves, and each navigates to a real
VIEW_ROUTES URL, so a reload renders the same view. Panels only close on
a location change, so selecting the page you're already on closes them
explicitly.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Dashboard's file panels built every point by joining the heat map onto
the current file tree, so a heat row whose path had left the project was
silently dropped — while the agent-coverage panel below, which does no such
join, rendered those same reads. One page, one ledger, two answers.
The production consequence is the real bug: delete or rename a well-read doc
and its whole read history vanishes from the map, which is exactly the
signal the Dashboard exists to give.
Hot path now ranks orphaned rows alongside tree files, labelled "no longer
in the project" and opening that path's History (the file view would land on
the not-found page). The two plots stay tree-only — both position by
freshness and an orphan has no mtime, so any position would be invented —
but each carries a count of what it can't show. "No reads in the window yet"
can now only render when the scope genuinely has none.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat: ask for a GitHub star where it can't annoy anyone
Three surfaces, all passive or once-per-setup:
- `bdrive init` prints one line after a successful setup, TTY-only. init
runs about once per project per machine, and the guard keeps it out of
CI logs and any output a script parses — putting a star plea in
repeating output is what got postinstall ads banned from npm.
- The hub sidebar gets a dim "Star on GitHub" link above the account row.
A link that always sits there reads as social proof; a dismissible
banner would need dismissal state and would still have interrupted.
- README grows star/pkg.go.dev/docs badges — until now the only
user-facing link to the repo was the docs sidebar.
Covered by two checks: the CLI e2e asserts init stays silent about the
repo when stdout is piped, and a Playwright spec pins the sidebar link's
href/target and that nothing modal appears with it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FNgeJVcsXQ5sTfWLgCR4cv
* fix(webapp): the star link wears the GitHub mark, at 11px
A star glyph next to "Star on GitHub" said the same word twice; the mark
is what people scan for. lucide dropped its brand icons in v1, so the
path is inline rather than a second icon dependency for one glyph — and
it is filled, so it sits outside the `.ico` stroke sizing.
Text drops 12px → 11px to match the account email below it: this is the
dimmest thing in the sidebar on purpose.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FNgeJVcsXQ5sTfWLgCR4cv
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every server-rendered /auth form shipped a bare <button>. Browsers default
one inside a form to submit, so humans never noticed — but the conventional
automation selector matched nothing, and the e2e suite carried a "form
button" workaround at two call sites to compensate.
All five buttons (sign in, sign up, approve, send reset link, set password)
now carry an explicit type="submit", and both e2e call sites use the
standard selector. Every spec's login() routes through helpers.ts, so a
regression fails the whole run at the first sign-in.
Markup only: authlocal.go styles button by element, not by [type], so the
rendered pages are byte-identical before and after.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
A brand-new project's Dashboard rendered ~840px of empty bordered SVG
frames with the "hot + stale" / "hot + fresh" / "cold + stale" quadrant
labels floating over nothing: Treemap and Scatter had no empty guard, and
the quadrant labels come from the HOT_READS/STALE_DAYS constants rather
than from data. Only HotPath said anything, and what it said ("No reads in
the window yet") is the wrong claim — the project has no files at all.
The guard goes one level up in Insights, where the three panel headers and
the lens switcher also live, so those go away too instead of sitting over
nothing. Gated on "no files", never on "no reads": files-with-no-reads is
the other zero state and it already behaves correctly (Treemap pads every
file to reads + 1 so unread files keep a sliver).
Two new optional props. `installHref` puts a real <a> in the empty state,
routed through linkProps so it stays copyable and middle-clickable — the
dashboard route passes it, the project home doesn't, because ConnectGuide
directly above it IS the set-up-a-device guide. `loading` keeps the tree's
first frame from claiming a populated project has no files.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(history): page the API so old changes stop being unreachable (BEA-46)
GET /api/p/<id>/history capped at ?n= and said nothing about what it was
hiding, so every change older than the cap was unreachable — a project's
early history sat in the journals and blob store with no way to display it.
The display order used to come from two mechanisms: a stable time sort over
a slice built in reverse-journal order, so the tie-break was implicit in the
construction and a cursor could not re-derive it. histLess makes it one
function — newest wall-clock first, ties in reverse journal.Less — used for
both the sort and the skip-past-cursor step, so paging cannot disagree with
the feed. The cursor is server-minted and opaque because it has to be:
HistoryEntry.time is formatted to whole seconds and carries no lamport/seq,
so a client-computed cursor would be lossy across same-second ops.
?n= alone returns exactly the entries it always did (the tie-break IS
reverse-Less); it just gains a next_cursor key when more exist. A cursor is
a position in an ordering, not a snapshot: an offline device pushing
mid-scroll lands ops mid-feed by timestamp and the reader sees them on
refresh — pinning would mean server state for the life of a scroll.
BenchmarkHistoryPage over 5000 ops: page 1 14.4ms, page 20 15.8ms — every
page re-lists and re-parses the journals, so the ceiling is gone but the
per-page work is not. No cache needed at this scale.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(history): follow the cursor in the History view, with a Load more (BEA-46)
The view hard-coded n=200 and rendered whatever came back, so a project past
200 changes showed a list that simply stopped. useInfiniteQuery now follows
next_cursor at 100 a page, and the foot of the list says "Load more" while
older changes exist — a button, not an IntersectionObserver, so it is
keyboard-reachable and states out loud that there is more.
Pages accumulate into one array, which is what makes the rest free:
groupRuns already groups across the whole window (a run straddling a page
boundary becomes one card when its second page lands — verified live: 7
files on page 1, 12 after Load more) and prevBlob already returns undefined
past the end, so the oldest loaded row shows no diff base rather than
diffing against the wrong predecessor.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(templates): start a project from a structure, not an empty folder
A new project was an empty folder with a .bdriveignore in it, so every agent
session invented its own layout and the folder rotted into a pile. Both
surfaces now offer the same three starting points — from a template, from
scratch, from an existing folder (which is just a non-empty folder, and is
never restructured).
internal/templates holds the shipped set as literal go:embed'ed files: `docs`
(docs/, decisions/) and `para` (projects/, areas/, resources/, archives/).
cmd/bdrive is one binary for the CLI and the hub, so both read the identical
set — no gallery, no drift. The AGENTS.md in each is the deliverable: where a
new note goes, when something is archived, what a good filename looks like.
Every directory holds a real file, because BearDrive syncs paths and an empty
directory would never reach a teammate.
The hub seeds at creation through the existing Upload+Commit path, journaled
under its own device, and records the choice on the project record — so a user
who picked PARA in a browser sees PARA in the browser, and a later init cannot
seed a second copy. `bdrive init --template <name>` goes through the same
endpoint, with a local-seed fallback for a hub too old to know the field, and
seeds in place when re-run in an already-initialized folder (the agent's
post-init path). Seeding never overwrites an existing path, which is what makes
a double-seed a no-op rather than a divergence.
Refusals cost nothing: an unknown name and --template with --only are both
rejected before any network call or write.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(cli): joining a project that already has a template is refused by name
The one acceptance case with no test behind it: connecting to an existing
project with --template must say what the project was actually created from,
and must not write the other skeleton on the way out.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(templates): name the docs template in plain English, not an acronym
"Plain docs + ADRs" was the recommended, first, preselected-adjacent option in
a picker that non-engineers see — and it's the label people accept without
reading further, so half of it not parsing is the worst place for jargon. The
title also disagreed with its own blurb: "ADRs" over "docs/, decisions/", two
words for the same folder one line apart.
Now "Docs + decision records", which says the same thing to everyone and
matches the folder names. The term itself moves into
decisions/0001-record-decisions.md, where the reader is already inside the
structure and the file can teach it in passing.
One line in the registry drives both the web dialog and the CLI menu; the rest
is prose echoing it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(templates): add the LLM wiki template
The third starting point from the issue title, unblocked: the spec parked it
because shipping an approximation under someone's name needed a source, and
there is now one — Karpathy's LLM Wiki gist. Worth noting the issue's own
one-line description of it ("few large, append-heavy topic pages") does not
match the source, which is the opposite: many interlinked pages, where a single
ingest touches 10-15 of them.
The pattern is three layers and three operations, not a folder shape. sources/
is yours and immutable; wiki/ is the agent's and it owns every page; AGENTS.md
is the schema layer — which is exactly the file this template system already
treats as the deliverable, so the fit is direct. index.md and log.md ship as
the two navigation files the pattern turns on.
Three of the things the gist tells you to go set up, BearDrive already is:
version history and collaboration (per-file history, bdrive log), an Obsidian-
style reader for [[wikilinks]] (the hub viewer), and a surface for the lint
pass (the dashboard is literally reads x staleness).
Two rules in the AGENTS.md are load-bearing and deliberate. A page write that
has not updated the index is an incomplete write — a stale index is worse than
a missing page, because it is read first and believed. And with no sources yet,
build nothing: the structure grows out of the material rather than ahead of it.
Shipped second, not first: docs stays the recommendation because a default is
the option chosen by people not reading closely, and this pattern degrades
badly when half-followed. Promoting it later is one line in the registry.
The shipped-template test now checks the "what happens when something stops
being true" question through a set of alternatives — PARA archives, a wiki
supersedes and revises — since the vocabulary honestly differs by structure.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(web): "I already have a folder" as a starting point
The third way to start from the spec — adopt what you already have — had no
presence in the browser. Templates and "empty" were the only visible answers,
so someone with a folder of notes either hesitated or picked a template and got
four directories merged into their material.
The constraint that shapes it: the browser cannot reach your disk, so this
cannot change what is created. It creates the same empty project "Empty
project" does; what it changes is the next screen. Create therefore stays
enabled — disabling it would leave the dialog a dead end AND produce no project
id, which is the one thing the paste prompt actually needs.
Landing on the project home with the intent, three things differ: the guide
says "in the folder you already have", a note states plainly that connecting
never moves, renames or overwrites anything, and the paste prompt tells the
agent a folder already exists. That last one is the part that isn't cosmetic —
without it an agent reads an empty project and proposes creating shared/, the
one recommendation that is wrong here. It still asks which folder: that is the
runbook's hard gate and nothing here weakens it.
The intent rides in the URL (?connect=existing) rather than onto the project
record, the same way ?v= pins a file version. It belongs to whoever is
connecting right now — a teammate who connects next week has their own answer
and would be told the wrong thing by a persisted flag.
Five rows made the dialog tall enough to push Create off a short viewport, so
.modal scrolls internally. A hairline divider between the seeding and
non-seeding rows was tried and removed: --border is 7% white, which at 1px in a
gap renders as literally nothing. The gap is the cue that reads.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(web): with no projects, open the create dialog and give the page a way in
A signed-in account with no projects landed on a page whose only path forward
was pasting a prompt into a coding agent. Now the create dialog opens itself —
with nothing to browse there is nothing else on that page to do — and the page
behind it leads with "Start a project" and a button, so closing the dialog is
not a dead end.
The dialog moves up to HubApp because three things ask for it now: the
sidebar's +, the empty state's button, and the auto-open. ProjectNav keeps only
an onNew callback; one owner beats three copies of the create handler.
Two guards on the auto-open. It fires once per mount, keyed off a ref rather
than the empty state, or closing it would immediately reopen it. And it never
fires on a read-only hub, which refuses creation server-side with a 403 —
opening a dialog that cannot succeed is worse than the page it covers.
The agent paste-prompt stays, demoted to "Or let your agent do it": it is still
the right path for someone who wants the folder connected in the same breath,
and it is the only path on a hub where this account cannot create.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* refactor(auth): one CLI sign-in flow for every provider (webapp.CLIAuth)
The `bdrive login` surface — /auth/cli, /auth/device/<token>, the approval
page both show, /api/auth/exchange and /api/auth/device/{start,poll} — moves
out of BuiltinAuth into its own type. A provider supplies the two things
that actually differ: who the browser session is, and how a device token is
minted.
Nothing changes for a self-hosted hub; this is the same code behind the same
paths. It moves because the managed hub's provider carries its own copy, and
the copy drifted: months after the OSS flow moved to a single approval link
naming the device, that hub was still printing a four-byte code to retype
into a text box. Sharing the implementation is the only fix that stays fixed.
BuiltinAuth's own grant map now holds just what it should: verification and
password-reset links.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
* fix(architecture): make the webapp-server diagram parse again
Two mermaid syntax errors, so GitHub rendered the first block as an error
box instead of a diagram:
- the CLIAuth class listed its routes as bare lines, and the `{` in
/auth/device/{token} opens a struct inside a class body — the routes are a
note now, where prose belongs;
- `note for` strings escaped quotes as \" (mermaid has no backslash escapes,
so the string ended early). Pre-existing, in the DirectUploader and
Project notes; both use " now, like the </> already in there.
Checked by parsing every block in architecture/*.md with mermaid 11.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(history): stop reporting the device IP on every change (BEA-43)
The history API embedded the whole DeviceInfo, so every project member
read a teammate's server-observed IP (plus user/last_seen) next to every
change on a page whose job is "who changed this file". Project a
three-field historyDevice instead — id/name/os — mirroring heatByDevice.
The device registry is unchanged: Observe/requestIP and both MetaStore
backends keep recording the IP. This is a response projection, not a
change in what gets collected.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs: history shows device name and OS, not the IP (BEA-43)
README, SKILL.md and the docs site all promised the History view would
show the connecting IP. It no longer does — the registry still records it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(dashboard): make the treemap honest on a young project
Every cell was the same green (staleColor spreads 0-300d, so <=3d-old
content lands in one stop), half the cells were anonymous with no hover
fallback, and read counts existed only inside file <title>s. The map said
nothing the file tree didn't.
- group rects get a <title> (folder, reads/30d, file count), so a group
whose files are all too small to label is still identifiable
- read counts appended to file and group labels; the count is part of the
string the fit is measured against, so it can never overflow, and when it
doesn't fit the label degrades to the bare truncated name as before
- a freshness legend under the map: the gradient plus the age span actually
observed in this scope+lens, and when that span is under a week it says
the colour channel carries no signal instead of implying one
- group read totals come from a true sum, not the padded layout value
No relative colour scale: normalising to the observed range would paint a
3-day-old file the red that means hot-and-stale everywhere else on the page.
Sizing (reads + 1), scatter, hot path and coverage are untouched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs(architecture): heat.ts joins the frontend lib
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The run card header counted ops, so a path rewritten five times inflated
the one number a reader uses to size a run: 14 ops across 10 paths read
"14 files". It now counts distinct paths and keeps the word "files";
every op is still a row inside the card.
The same count decided whether a run got a card at all, so a run that hit
one path five times drew a card claiming "5 files". Counting by file
demotes it to bare rows, each still showing its session note.
groupRuns and the Run/Item types move to src/lib/runs.ts (pure, no React)
so node's test runner can import them — a .tsx with JSX can't be. The
grouping key, ordering, time span, who and device are unchanged, and
run.idx still addresses the flat feed so diffs and restore shas are
unaffected. The key's NUL separator moves across as an explicit "\0" —
it was a raw NUL byte in the source, which is also why git saw the old
HistoryView.tsx as binary.
Deviation from the reviewed plan: flipping the threshold in place would
have dropped rows. Only a run's first entry was ever pushed to the output
list, so a demoted 5-op run would have rendered one row, not five.
groupRuns now builds runs first and emits items in a second pass over the
feed, which keeps demoted rows at their own newest-first positions.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(dashboard): the hot-path bar paints share reads as share, not as people
The bar computed one fraction (agent/total) and painted the whole remainder
in the human colour, so a file read only through a share link rendered as if
a person had browsed the hub — and the legend named only two readers, while
the file header has been breaking out all three all along.
Each reader now gets its own segment from its own count. hotPathSplit() does
that arithmetic, and it lives with the rest of the heat helpers in the new
lib/heat.ts (pure, no React) so one unit test over one fixture can pin the
invariant the report doubted: the file header and the Dashboard read the same
total from the same helper. useBrowse.ts re-exports them, so no import site
moved.
No server change — /heat already returns share and stays identity-free.
* docs(architecture): lib gains heat.ts, the one read-count arithmetic
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(webapp): viewer credits the signed-in account, like History does
RemoteSource.Files built each FileInfo from op.Author alone and dropped
op.User/op.UserName, so the file viewer header showed a git/OS identity
the user never signed in with while History rows for the same op showed
the account. Carry both fields through FileInfo -> Node -> /render and
render them with the whoChanged() helper History already uses, so there
is one attribution rule in the frontend rather than two.
The guard on the meta line stays on the raw fields: whoChanged() answers
"unknown" rather than "", and plain-folder (DirSource) mode has no
identity at all and must keep printing nothing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(webapp): plain-folder render carries no empty identity fields
Locks the other half of BEA-37: DirSource has no account behind a file,
and sending empty user fields would make whoChanged() print "unknown"
where plain-folder mode has always printed nothing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
History could restore an edit or a deletion, but a file a run CREATED was
the one thing it couldn't reverse — the ADDED row said so in copy and
offered no button. The missing capability was a hub-written delete op:
restore.go only ever journaled puts.
POST /api/p/<id>/remove journals exactly one journal.KindDelete op under
the hub's own device identity, behind restore's gates (gateUpload,
PermWrite, cleanUploadPath, quota CheckWrite/RecordUsage) plus a volume-
snapshot existence check so the API 404s on what the tree doesn't show.
Commit's journal-append tail moves into RemoteSource.appendOp, which both
writes now share — one writer per journal, unchanged.
The ADDED-in-a-run row gets an "undo — remove file" control that confirms
first (it reaches every synced device), and the DELETED row it leaves
behind restores the file with its original bytes.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Opening Share on a file that already had a live public link showed the URL
twice, each copy with its own Revoke: the transient dialog on top of the
persistent ShareBanner. Both hit the same link, so nothing was at risk, but a
destructive control shown twice is a control nobody wants to click.
The dialog loses Revoke and keeps its job — confirm what happened, hand over
the URL (Copy link / Open / Done) and set an expiry. The banner keeps Revoke:
it is what BEA-16 added so the undo outlives the dialog, and it is still there
on every later visit to the file.
Deviation from the reviewed plan: it also had this delete the `token`
derivation and the `api` import, which existed only for the Revoke handler.
BEA-29 (#74) landed first and its expiry PATCH now uses both, so they stay —
the plan assumed this PR would go in ahead of it.
The e2e that revoked through the modal now revokes through the banner, and
asserts `.modal .ai-del` has count 0 right after Share — the assertion that
stops the duplication coming back.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(auth): ask before signing a terminal in as whoever the browser is
`bdrive login` opened /auth/cli and the browser bounced straight back with a
code. Whoever the browser happened to be signed in as is who the terminal
became — silently. That is frequently not the account the user meant: a
personal login left open in the default browser, a teammate's session on a
shared machine. The mistake surfaces much later, as a synced folder full of
commits authored by the wrong person, which is far more work to undo than one
click would have been.
The device flow already got this right in #83 — it names the account, offers
to switch, and says what approving grants. The browser flow said nothing at
all, for the same outcome: a token that acts as you.
So /auth/cli now confirms first. GET renders the page (who you would be
signing in as, a Switch account link that comes back to this same pending
sign-in, what is asking, and where it is waiting); POST is what mints the
code and redirects to the loopback listener. A GET therefore grants nothing,
so a link someone else got you to open can no longer mint a code on your
behalf.
whoBlock loses its pendingGrant parameter and renders only the identity half.
What is asking differs per flow — a device has a name and an OS, a CLI on this
computer has a loopback port — so each page now renders its own rows through a
small helper instead of whoBlock pretending to a shape neither quite fits.
The CLI's own wording follows: "waiting for you to approve the sign-in in your
browser", since being signed in already is no longer the whole story.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
* docs: the browser sign-in confirms first, and says whose account it grants
README, the CLI reference, and the self-hosting auth page all described the
old behaviour — sign in and the page bounces a code straight to the terminal.
They also read as though only `--device` had an approval step. Both flows now
confirm; say so, and say why it matters (the browser session is often not the
account the user meant).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
* feat(auth): one web step for a first sign-in, not two
The confirmation page fixed the wrong-account problem and created a smaller
one: a user with no browser session now saw two pages on their first
`bdrive init` — sign in, then approve — where the sign-in had already settled
the only question the second page asks.
So authenticating *for* a pending CLI sign-in now counts as approving it. The
login and signup pages carry a line saying a terminal is waiting and that the
account used here is the one it will act as, which is where that consent is
made informed; reaching the callback then needs no second click.
The marker is server-side, bound to the exact pending sign-in, single use, and
two minutes long, so it can only ever skip the page it was granted for and only
once. It cannot be forged: setting it requires authenticating as that account,
and anyone who could do that could click Approve anyway.
An existing session still gets the page — that is the case where the browser
may be signed in as someone the user did not intend, which is the whole reason
it exists. Net effect: exactly one web interaction either way.
The device flow keeps its explicit approval. Its page names a machine that
isn't this one, along with the OS and address it came from — information no
login form can convey, about a grant to somewhere else.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
* fix(auth): keep `bdrive login` on one line in the approval hint
It wrapped mid-phrase into two separate code boxes, which reads as two
commands rather than one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
* refactor(auth): one approval page for both sign-in flows
The two flows ask the same question — shall this thing act as you? — and had
two copies of the page asking it, differing in three strings. They had already
started drifting: a wrapping fix went into the CLI copy only, leaving the
device page able to break `bdrive login --device` across two code boxes. A
page whose whole purpose is consistent disclosure is a bad place to keep two
of everything.
So pageAuth owns the shape (session check, redirect to login, whoBlock, rows,
the Approve form, the note) and each flow supplies an authRequest describing
what differs: how the request is identified, what is asking, and what
approving does.
Two asymmetries are now explicit rather than accidental. freshAuthSkips is
true only for the local flow — signing in and approving are the same act when
the terminal is on this machine, and are not when the token goes to another
one. live() reports whether the request still exists, because the device
flow's link expires while the CLI flow carries its whole request in the URL
and has nothing to expire.
detail is a function, not a slice: the device rows come off the pending grant,
which only exists after live() has found it.
No test changed. The pages render byte-identically — same sha256 for all three
CLI screenshots before and after — and the device flow was driven end to end
against a real hub, approving a real `bdrive login --device`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
* feat(auth): both sign-in flows always ask you to approve
Consistency between the two flows is worth more than the click it saves.
Letting a sign-in count as its own approval made the local flow one step and
the device flow two, so the same product asked for consent in two different
shapes depending on which machine you were on — and the shape that skipped it
was the one where the page had something to tell you.
So the fresh-auth marker is gone: sign in, then approve, on both flows. That
drops a map, two methods, a descriptor field, and a branch in pageAuth — the
unified handler now has exactly one path through it.
A first `bdrive init` on a fresh machine is two web pages again. That is the
deliberate trade: the approval page is where a user sees which account a
machine is about to act as, and nothing shortcuts it.
The sign-in page keeps the line saying a terminal is waiting. It no longer
carries the consent — the next page does — so it is there to explain why a
password prompt appeared at all.
TestBothFlowsAlwaysAskToApprove replaces the one-step test and runs the same
assertions over both flows as subtests: no session sends you to sign in
carrying the request, signing in returns to the request without granting, the
approval page is there every time, and only the POST grants.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The viewer decided what to preview from a filename regex, so every
extensionless file an agent writes — Dockerfile, LICENSE, .bdriveignore —
and every unlisted extension (main.tf, schema.graphql) hit a dead "No
preview for this file type." card. That bites hardest in the core use
case: an agent writes an artifact, a teammate opens the hub to read it,
and the hub refuses.
The unmatched path now fetches once and decides on the bytes. The logic
already existed for the history diff — 1 MB cap, Content-Length cheap-out,
8 KB NUL scan, fatal UTF-8 decode — so the pure half moves to
lib/sniff.ts (importable by node --test, no React Query) and both
DiffView and FileView call it. Exactly one sniffer, per the spec.
.pdf gets the browser's own viewer in an iframe, in the wide page column
(768px is unreadable for a PDF page). No sandbox attribute, deliberately:
the PDF viewer is not this page's JS realm, so it cannot reach the hub
API or its cookies, and sandboxing without allow-same-origin breaks
Firefox's pdf.js.
Files that already previewed (md/html/img/.txt) are untouched and issue
no extra fetch.
BEA-44
* fix(daemon): let the daemon own its pidfile, so stop can stop it
`bdrive stop` could fail with "no such process" and leave sync running.
Liveness became the flock in #88, and Run already announces the child's own
pid only after it holds that lock. But Start still wrote daemon.pid from the
parent, right after fork, with the pid of a child that had not earned
anything yet. Two starts inside that window — `bdrive init` followed by the
login agent's `bdrive resume`, or two resumes close together — race: Running
still reads false, a second child spawns, it loses hold(), and it exits
without ever being the daemon. Its pid is already in the file.
Everything downstream trusts that file. Stop signals the loser and gets
ESRCH, so it reports failure while the winner keeps syncing — the one command
whose job is "stop sending my files" silently does not. status prints the
phantom pid, or "pid 0" when the loser's cleanup removed the file the winner
wrote.
So the parent no longer writes it: the pidfile belongs to whoever holds the
lock. Start now waits for the lock to be taken instead of assuming the spawn
worked, which also means a caller that gets a pid back can trust a daemon
owns it — `bdrive resume` used to print "started (pid N)" for a child that
had already died.
The regression test needs the real binary (Start execs os.Executable), so it
lives with the CLI e2e rather than in internal/daemon, whose tests synthesize
locks. It is deterministic on Linux and roughly one run in five on macOS,
where the window is tighter; `sandbox/run.sh daemon-linux` is the reliable
reproducer.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
* docs(architecture): the daemon owns its pidfile, Start only waits for the lock
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The paste prompt now carries the project's name so an agent recommends a
folder of that name; with no project at all the recommendation is `shared/`
(and `bdrive init shared` names the new project after the folder), replacing
the old `wiki/` default.
New project ids are UUIDs instead of `p-` + 8 hex chars. The route validator
still accepts the legacy shape — ids are permanent — and the client-side URL
parsers (remote/http.go, bdrive share) now only check the shape of a URL
segment, leaving the hub as the single authority on which ids are valid.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(daemon): bring sync back after a reboot, and stop trusting the pidfile
A reboot killed every daemon and nothing restarted them. Agent hooks still
synced per turn, which is what made it easy to miss: a folder looked fine
while an agent worked in it and went stale the moment one didn't. `bdrive
init` now registers a login item (macOS: a user LaunchAgent) that runs the
new `bdrive resume` — one registration per machine, which starts a daemon for
every enrolled, unpaused mount, so adding a project later needs no
re-registration and `bdrive stop` still means stay stopped. `--no-autostart`
opts out, `bdrive autostart install|uninstall` manages it.
Writing the plist is the whole job: no `launchctl` shell-out. launchd loads
agents at login anyway, the caller has just started the daemon for this
session, and shelling out would let a test or a packaging script register a
real login item as a side effect.
The recovery path was also broken, which is why this is one change. Liveness
came from `kill(pid, 0)` on daemon.pid — but that file lives in
$BDRIVE_HOME and survives the reboot that killed its process, so any
same-user process recycling the pid read as a live daemon. `bdrive status`
said "running", and worse `daemon.Start` returned early, so the one
documented recovery (`bdrive init`) reported success and started nothing.
Liveness is now an flock held for the daemon's lifetime: the kernel drops it
at death or reboot, and it makes two daemons on one mount impossible. The pid
stays for display and for signalling.
internal/autostart is darwin-only today; autostart_other.go returns
ErrUnsupported and every caller already treats that as "nothing to do", so
Linux (systemd user unit) and Windows are one file each.
Tests: internal/daemon gets its first ones — a recycled pid must not read as
running (the exact regression), the lock decides liveness, a second holder is
refused. internal/autostart covers write/idempotency/stale-path-rewrite/
uninstall with HOME redirected, and lints the plist with plutil so launchd
can actually parse it. The CLI e2e asserts init registers the agent, that it
runs `resume`, that resume finds the live daemon instead of starting a
second, and that --no-autostart is silent.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016aYntCWwdUhpzUfEk3ddyJ
* feat(autostart): Linux support — a systemd user unit alongside the launchd agent
Same three functions, same discipline. Linux writes
$XDG_CONFIG_HOME/systemd/user/beardrive.service (Type=oneshot, no Restart= —
`bdrive resume` exits by design) plus the default.target.wants symlink that
`systemctl --user enable` would create, because systemd ignores a unit
nothing wants. No `systemctl` shell-out, for the same reasons as launchctl:
the file is the registration, it only matters at the next login, and a
container or ssh session has no session bus to talk to.
Install declines with ErrUnsupported unless systemd is actually the init
system (/run/systemd/system, i.e. sd_booted) — on Alpine, WSL1 or a slim
container a unit file is inert decoration, and reporting "registered" would
be a lie. Installed() likewise requires the enable symlink, not just the
unit: a unit nothing wants never starts.
os.UserConfigDir honors XDG_CONFIG_HOME, so relocated config dirs work.
Windows is now the only gap; autostart_other.go is !darwin && !linux, and the
shared writeIfDifferent/selfPath moved into the tag-free autostart.go (darwin
now uses them too).
Tests run on Linux, not just compiled for it: cross-compiled test binaries
executed in a container, both with /run/systemd/system present (unit written,
enabled, idempotent, stale ExecStart rewritten, broken symlink repaired,
XDG honored, uninstall removes both) and without it (Install declines and
writes nothing). The daemon flock tests were run there too, since flock
semantics are per-OS.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016aYntCWwdUhpzUfEk3ddyJ
* feat(autostart): Windows support — a per-user Run entry
Third platform, same three functions. Windows has no user service manager in
the launchd/systemd sense, so the registration is a HKCU\...\Run value via
golang.org/x/sys/windows/registry (already in the module graph; go mod tidy
just promotes it to direct).
Chosen over the alternatives for the same reason the other two write files:
no admin rights, no COM (a Startup-folder .lnk needs it), no schtasks
shell-out. It is also honestly discoverable — the entry appears in Task
Manager's Startup tab, where someone can disable it without knowing bdrive
exists. The executable is quoted because Explorer parses the value as a
command line and Program Files has a space in it.
Two things a reader should not have to discover for themselves:
- The tests here have NEVER RUN. They are written and compile-checked
(GOOS=windows go test -c) from macOS; there is no Windows host or usable
container on an arm64 mac. They execute the first time the suite runs on
Windows. They also cannot use a temp HOME the way the macOS and Linux tests
do — HKCU is real — so each one snapshots and restores the previous value.
- `GOOS=windows go build ./...` still does not pass, and this package is not
why: internal/store's Lock uses syscall.Flock and internal/daemon uses
syscall.Kill and Setsid, all unix-only (true before this branch too). A
Windows port means LockFileEx plus a stop story for a platform with no
SIGTERM — a separate change, against the sync invariants, and untestable
from here. So this code is correct and currently unreachable.
autostart_other.go is now !darwin && !linux && !windows (the BSDs).
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>
Two front doors for the same setup, and one of them was a second copy of
everything. The plugin shipped the skill the CLI already installs (to four
platforms, not one), hooks that ran the identical commands `bdrive hooks
install` writes machine-level, and an install flow duplicating
INSTALL_FOR_AGENTS.md. Nothing deduped, so a machine with both pulled twice
per turn — two blocking syncs — and showed two identical `beardrive` skills
in the picker.
What remains is `internal/agenthooks` plus the runbook: init registers a
blocking pull (which also injects the gated-link convention as
additionalContext), an async push on Write/Edit, and read-log for the
heatmap, in each platform's user config, once per machine. That is the whole
integration, and it is the part that was never optional.
Removed: plugin/, .claude-plugin/marketplace.json, internal/agentskills,
`bdrive skill`, `bdrive hook-approve` (its PreToolUse auto-approve only ever
helped when a plugin pre-installed it; the substitute is a `Bash(bdrive:*)`
permission entry, which is user-owned config and needs no code).
The e2e now asserts the absence: no SKILL.md in any platform's skills dir
after init, and no `skill` subcommand. login_test keeps the "no revoke
surface" wording check on logoutNote alone.
Claude-Session: https://claude.ai/code/session_016aYntCWwdUhpzUfEk3ddyJ
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
The headless flow printed a short code to retype into a bare "Approve"
form. Now `bdrive login --device` prints a single link — the token lives in
the path (/auth/device/<token>), so there is nothing to read off one screen
and type into another.
The page it opens is a consent page rather than a text field: it names the
account the device would act as, offers Switch account (logout now honors
?next, so you land back here), and shows the device name, OS, and the
address the server observed. That matters because this flow's weakness is a
stranger sending you their pending link; an anonymous "Approve" gives you
nothing to notice with. Approval is still a POST from the page, so a link
alone cannot grant, and SameSite=Lax keeps a cross-site form out.
Also aligns the /auth/* pages with the app's tokens, which had drifted:
card #0c0e10 vs --color-card #15171b, 8px controls vs --radius-ctl 7px,
hand-picked #ff9b91/#6fd699 vs --color-del/--color-add. The style block now
declares the tw.css tokens by name and every rule uses them.
Older CLIs still print /auth/device?code=…, so that shape 303s to the path
form; a pre-0.13 hub returning no verify_url still gets the old
type-the-code instruction from the CLI.
Claude-Session: https://claude.ai/code/session_016aYntCWwdUhpzUfEk3ddyJ
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Adds Server.Analytics (webapp.AnalyticsConfig) and emits it as /api/config
`analytics` when a key is set. The frontend loads posthog-js from the CDN at
runtime rather than as a dependency, so an unconfigured hub ships no tracker
and makes no third-party request — the OSS bundle grows 1.1KB (the loader),
not 230KB.
Product events come from one table in api/http.ts keyed on method+path.
Every mutating call in the app already goes through api()/postJSON(), so a
new write is measured or it isn't, instead of depending on someone
remembering a capture() call. Share creation is the one raw fetch and fires
its own.
Session replay masks every text node: in this product nearly all of it is
customer file names and document bodies. Replays are layout, clicks and
navigation only.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* 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>
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>
* 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>
* 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>
* 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>
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.