Commit Graph
316 Commits
Author SHA1 Message Date
b0029f914c fix(webapp): a background refresh never moves the reader (BEA-155) (#188)
* fix(webapp): a background refresh never moves the reader (BEA-155)

Reading a file was interrupted every so often by the viewport jumping back
to the top. Nothing polls the document — the 60s read-count poll does it:
`heatMap` sat in MarkdownView's meta-effect deps and that effect ended by
calling `onRendered()`, which the scroll restorer reads as "content landed"
and answers with `scrollTo(top: 0)`. Capped at 3 attempts per route, hence
"from time to time". `onScroll` then memoized 0, so Back also returned to
the top.

Two changes. The second is the reported bug; the first is why it can't come
back wearing a different hat:

  - The scroll goal retires itself the moment the reader scrolls, so no
    onRendered caller — present or future, honest or not — can move a reader
    who has taken over. Two clauses are load-bearing and each has its own
    test: scrollTo fires a scroll event of its own (so "moved" is measured
    against the goal, never against zero), and a page shorter than the last
    one makes the browser CLAMP the carried-over offset to the bottom, which
    is either the old page's offset arriving or a goal that doesn't fit yet
    — the exact case the retries exist for.
  - MarkdownView's effect splits in two: onMeta keeps its heatMap
    dependency, onRendered fires on [html, diagrams]. A metadata refresh is
    not a render. A mermaid diagram landing used to call onRendered never,
    so Back into a diagram-heavy file never got its late re-apply; now it
    does, which is what the retry budget was written for.

The state machine moves to src/lib/scroll.ts because the frontend suite is
`node --test` over pure TS with no jsdom — that is the only way this gets a
regression test at all. Browser.tsx keeps the DOM bits.

The other four onRendered callers are audited and deliberately left alone:
FolderListing's "Recent changes" feed, HistoryView, and SniffView/TextView
on ["text", …] all fire on content that genuinely changes the page height,
which is what the retries are for. MarkdownView was the only one whose
trigger was pure metadata.

No poll interval is touched, and every programmatic scroll keeps
behavior:"instant" — #content carries scroll-behavior:smooth, and an
animated restore would fire intermediate scroll events that the new guard
would read as the reader.

* fix(webapp): apply the scroll goal when it is armed, not only later (BEA-155)

Back landed at the top of the file instead of the offset it remembered —
already true on main, and the same restorer this branch is repairing, so it
lands here rather than as its own issue.

React runs CHILD effects before the parent's. A view calls onRendered from
its own effect, so by the time Browser's route effect arms the goal for the
new route, that call has already happened — against the goal of the route
just left, where the key check discarded it. The goal then sat armed and
nobody applied it: on PUSH the container's carried-over offset was clamped
by the shorter page and looked close enough to right, but on POP the
remembered offset was simply never restored.

Arm and apply in the same pass. Later onRendered calls still cover content
that grows after first paint, which is what the remaining budget is for.

* docs(architecture): lib/scroll.ts, the scroll goal Browser now delegates to (BEA-155)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 13:06:38 -07:00
4f04b78c71 feat(webapp): frontmatter moves to a collapsible side panel (BEA-154) (#187)
A doc's YAML frontmatter rendered as a table pinned to the top of the
reading column, so on anything with more than two or three keys the
document itself started below the fold. It is a panel beside the prose
now — a sticky rail on a wide window, a closed disclosure above the body
on anything narrower — and the reading column starts with the document.

The table was built on the server and handed to the client inside one HTML
string, so this is not a CSS change: markdown.go splits the parse
(frontmatterPairs) from the markup, /api/render gains an ordered
`frontmatter` field, and the viewer switches to RenderMarkdownPairs.

RenderMarkdown keeps its exact output — it is the public share page, and
every /s/ link ever minted serves it. shares_test now pins that, because
nothing else would have failed if a later cleanup moved shares.go onto the
pairs path.

Values cross the wire as literal text plus a `code` flag rather than
pre-escaped HTML, so the panel is ordinary React text nodes and never
touches dangerouslySetInnerHTML: "a value containing markup renders as
text" holds by construction.

The rail's breakpoint is 1400px, not the 1180px the plan named — 768 of
prose + 28 + 240 of rail needs 1036px of column, and at 1280 the reading
measure lost 110px, which is the squeeze the panel exists to avoid.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 12:55:29 -07:00
b5bd8dddff fix(webapp): a run card keeps the reads of files the project deleted (BEA-152) (#186)
The History run card carried a footnote saying reads are shown only for
files the project still has. Nothing implements that: SessionPaths does a
ListBySession and consults no tree, so a read outlives the file it read —
which is why the Dashboard's hot path listed `scratch.md · 4` while the run
card showed nothing. Two surfaces, one ledger, and the only explanation on
offer was wrong about the mechanism.

The card is narrower than the project totals because it is one session on
one device, not because deleted files are filtered. Say that instead, and
label a vanished path the way the Dashboard already labels it — same words,
same `.in-hp-gone` class. Reads of deleted files count, on both surfaces.

The seeded run now reads `scratch.md` too (the fixture's deleted-but-read
file), so the label has something to render against; the Go test pins the
policy the copy now states out loud.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 12:43:12 -07:00
ae14d11ac3 fix(webapp): three readers on one network are three share opens (BEA-151) (#185)
The share actor key was token+"/"+IP, so every browser behind one NAT was
the same reader and the 10-minute visit debounce folded a whole office into
a single open — three personas each measured "1 open" for three readers, and
the panel's own copy promised the opposite.

The key gains a truncated hash of the User-Agent. ShareOpens already sums
across actor buckets and takes the max Last, so opens: 3 and an advancing
last_opened fall out with no aggregator change, no new field, and no change
to readDebounce. The UA is hashed because Record persists the actor through
ReadRepo into storage; token+"/"+IP stays the prefix so the existing leak
assertions keep covering the wider key.

The copy now states the rule the code implements, including its residual:
two people on one network in the same browser still count as one.

Deviation from the plan, deliberate: TestSec_Share_VisitorCannotInflateOrRedirectTheLedger
pinned "a visitor cannot split its own visits by varying the User-Agent".
That is now intended behavior, so the two UA rows move out of the
must-collapse set into an explicit assertion that they count separately.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 12:32:53 -07:00
eef37fa89b fix(webapp): the hub 404s a root file it does not have (BEA-148) (#184)
GET /llms.txt answered 200 with the sign-in page. So did /robots.txt,
/sitemap.xml and every mistyped root file: the SPA fallback treated only
api/, auth/ and s/ as genuine 404s, so a crawler probing any conventional
path got a success status and a chunk of login HTML.

In hub mode a first path segment is a project id (UUID or p-xxxxxxxx) or a
reserved word, none of which contain a dot, so a single dotted segment with
no embedded asset can only be a file that is not there. The check runs after
the asset lookup, so a real root asset (share-mermaid.js today, favicon.ico
whenever the build emits one) is unaffected with no allowlist to maintain.

Gated on hub mode: the plain-folder viewer shares this handler and there
/README.md IS the route for a file, so ungating this would make every
top-level file in every `bdrive serve <dir>` unreachable. There is a test
that fails if the gate is removed.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 12:21:09 -07:00
20f59352ca fix(webapp): badge the credential the share gate already found (BEA-147) (#183)
* fix(webapp): badge the credential the share gate already found (BEA-147)

The hub could identify an AWS access key on line 3 well enough to refuse to
publish the file, and rendered that same key to every member as ordinary body
text. scanSecrets had exactly one caller — share minting — so the strongest
protection in the product sat on the rarest path and was absent from the path
every file takes.

The render response now carries the same finding, omitted when the file is
clean, and the markdown file view shows an advisory strip above the content.
Advisory only: nothing is blocked and nothing is redacted, because a member
who can open the file could already read the key.

The label vocabulary moves out of Browser.tsx into lib/secrets.ts, shared by
the badge and the share dialog, so the two surfaces cannot drift apart on the
wording of the same finding.

The ?sha= history render is scanned too — two lines, and it stops the badge
vanishing the moment you click into history on the file it was warning about.

Rule ids and line numbers only. The matched text reaches no response body and
no log line, pinned by a test on the new caller the way shares_test.go pins
the old one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(architecture): the credential scan gains a render-path caller (BEA-147)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 12:11:00 -07:00
039e350d79 fix(webapp): the manual install block stops promising one command (BEA-142) (#182)
The 'Or run it yourself' section said 'One command: init signs this device
in, registers the sync hooks and starts syncing' directly above a block of
three, one of which is the bdrive login it claimed init did for you.

The commands are correct and unchanged — bdrive login <origin> is what
points the device at this hub; without it init aims at the remembered
server or beardrive.ai, wrong for every self-hosted reader this block
serves. So only the sentence moves, now describing the three steps it
actually sits above.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 12:00:33 -07:00
3e13f0bfa5 fix(webapp): Settings → About says how to take your files elsewhere (BEA-141) (#181)
bdrive export moves a whole project between hubs with full fidelity, and
the web UI mentioned it nowhere — the answer to "why trust a hub with my
files" only existed in a file a user had no reason to open.

Adds one paragraph to the About card: what the archive holds (every
device's journal and every blob, so full history and authorship), that
bdrive import restores it into any hub, that export warns on unpushed
changes, and a link to the migration docs. Copy only — no server route,
no download button, no palette action. The card already sits outside
every mayEdit guard, so every project member sees it; the member-role
settings e2e asserts that.

One correction to the reviewed plan: bdrive export takes a folder, not a
project id, so the copy says to run it in the synced folder rather than
leaning on the project id rendered above it.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 11:49:32 -07:00
52156f1b21 fix(webapp): a project URL by name opens the project (BEA-140) (#180)
Two personas guessed /wiki independently — the project name is what the
sidebar shows them, and the id (4c400e3f-…) never appears in the UI as
something to copy. Both got a page that argued with itself: the correct
project's chrome, breadcrumb and full file tree, with a body reading
"Project not found. This project doesn't exist, or you're no longer a
member." One of them owned the project.

Both halves land in the same place, and the net effect is one conditional
removed. `projectMissing` becomes an early return:

  - A first segment that names exactly one of your projects, matched
    case-insensitively on the DECODED segment (route.project is the
    still-encoded slice, so a name with a space would never have
    matched), redirects to /<id> with the rest of the URL — path, view,
    target, filters, version — carried along. Exactly one: ProjectDB
    names are scoped per organization, so a viewer in two orgs can hold
    two projects called "wiki", and the not-found page is the honest
    answer there.

  - Anything else renders the not-found body in a shell with NO tree —
    the same shape the `!current` branch already uses — so no other
    project's files sit beside a body denying the one that was asked
    for. The copy names the segment and drops the "no longer a member"
    claim, which was told to readers who may never have been members.

The `current` fallback chain is untouched: "Back to <project>" still
points at it. With the early return in place, none of the four redirects
below can be reached on a missing project, so the `if (!projectMissing)`
wrapper is gone and its reason now lives in the return.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 11:40:16 -07:00
1868228d29 fix(webapp): history shows it is loading instead of reading as empty (BEA-131) (#179)
The history filters are part of the react-query key, so changing one drops
`data` back to undefined. The feed rendered the filter bar and nothing else,
which is pixel-identical to the resolved "No changes match these filters."
state — a reader concluded twice that a file had no history when it had
three entries.

Render the shell unconditionally and put the in-repo `.empty` loading row
inside it, so a pending request and an empty result look different. Not
while `error`: failures already report through onMeta, and a permanent
spinner would hide them.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 11:29:19 -07:00
b3695d666d fix(webapp): hand the public link back from the table that manages it (BEA-130) (#178)
The Public links table listed path, owner and expiry with a Revoke button —
and no URL, not in a column, not in the DOM. The only way to re-send a link
you had already minted was to remember the file, navigate to it and read the
URL off its banner.

Each active row now carries an icon copy control beside Revoke, using the
`url` the server already composes through requestBaseURL (never rebuilt
client-side out of origin + token, which would diverge behind a proxy). It
renders for read-only members too: ShareBanner already prints the whole
/s/<token> URL as text to anyone with read (BEA-69), so gating the clipboard
shortcut would only make Settings stricter than the file page.

The actions track grows 110px -> 150px to hold it — not back to the 186px
that once starved the detail cell, and that cell wraps now anyway.

The e2e config grants clipboard-write: headless Chromium withholds the
permission a real browser gives the focused tab, so without it every copy
control reports failure and a spec measures the fallback path.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 11:19:47 -07:00
d4fd7eb372 feat(webapp): say what a conflict copy is, where a reader meets it (BEA-128) (#177)
A concurrent edit is preserved as `<name>.bdrive-conflict-<device>-<utc>`
— the guarantee the whole shared-folder promise rests on. Until now that
promise appeared in the README, the docs and syncer.go, and nowhere in
the hub: a conflict copy was an ordinary row with an alarming name, and a
user could only learn what it was by reading the README.

conflictName is a pure function of the path, so the frontend recovers the
device and the moment from the string alone — no server route, no journal
field, no request. lib/conflict.ts holds the parser (anchored suffix, a
strictly narrower match of the Go convention; anything malformed is null,
never a throw), the listing marks the row, and ConflictBanner explains the
file and links the version that kept the original name.

History and the Dashboard stay out, per the spec's stated cut.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 11:09:43 -07:00
6f8eb99606 fix(palette): the project you are in answers to its own name (#170)
The palette's placeholder and empty state both promise project search, but
the switcher loop rightly excludes the project you are already inside — so
typing `wiki` from inside `wiki` matched nothing at all. The destination was
there the whole time, as the unconditional row labelled "Go to project root"
and tagged ACTION, which no project name will ever match.

Relabel that one row with the project's name and tag it `project`. One row,
not two; still first and unconditional, so the BEA-52 dead-route escape
hatch is literally the same row it always was.

Closes BEA-105

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 10:58:33 -07:00
a3408bb198 fix(shares): a folder is not a sync problem (#169)
bdrive share <folder> answered "not synced to this project yet" on a fully
synced folder, so users went looking for a sync fault that wasn't there.
snap.files maps FILES, so a folder always missed the existence check and
landed in the same 404 as a path that doesn't exist — and the CLI bolted
"wait a few seconds for the daemon" onto it.

Tell the two apart in the handler (any key under p+"/" — never bare p, or
notes-archive/x.md makes "notes" a folder): 400 with "share links are
per-file" naming the lexicographically smallest file inside, 404 with
today's wording otherwise. The CLI's daemon hint was already 404-only, so
it stops appearing on its own; the only CLI change is printing a 400 body
without httpBodyError's status prefix. The web UI mints through the same
handler, so it gets the same distinction.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 10:41:28 -07:00
31db19b9c6 fix(webapp): carry the hot-and-stale warning to the file and folder views (BEA-119) (#175)
* fix(webapp): carry the hot-and-stale warning to the file and folder views (BEA-119)

The Dashboard flagged archive/retired-spec.md as read-a-lot and
unmaintained, then the file's own page served it with raw counts and a
raw date and left the reader to do the staleness arithmetic. The warning
existed on the one screen nobody opens before trusting a doc.

HOT_READS, STALE_DAYS and the danger predicate were module-private to
Insights.tsx, so no other surface could reach the verdict — even though
both inputs (heatMap, Node.time) were already in hand on both of them.
They move to lib/heat.ts, whose own header says every read-count surface
shares one arithmetic, and Insights.tsx imports them instead.

The predicate takes (reads, days) rather than a heat entry: only the
Dashboard has a reader lens, so it keeps passing its lens-filtered count
while the file and folder views pass heatTotal.

- file page: "⚠ stale · last changed 7 months ago", leading the meta line
  because #meta is nowrap + ellipsis and a trailing warning is the first
  thing a narrow window eats
- folder listing: ⚠ beside the heat dot, files only (a folder's heat is a
  subtree sum with no single mtime), with a real aria-label rather than a
  hover-only title
- no threshold change: the Dashboard flags the same set, pinned by both a
  unit test on the boundaries and an e2e test on all three surfaces

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(architecture): heat.ts now owns the hot-and-stale verdict (BEA-119)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 22:15:22 -07:00
Snow Lee (Sungwon)andGitHub 333d1fb8e5 fix(cli): bdrive log sorts by when a change arrived (BEA-112) (#174)
`mv a.md b.md` produced two rows minutes apart: the delete carried the
rename's time, the put carried the original file's mtime, so a file that
appeared seconds ago sorted below the fold of "what changed since
yesterday" — the question `bdrive log` exists to answer.

SortForDisplay now orders by CommitTime (when the change was journaled),
tie-breaking on DisplayTime so one scan still reads by the files' own
edit times. The write time is still shown, appended as `written <time>`
when it lags the commit by more than a minute — a rename, or an old
document added today — rather than silently replacing the column.

DisplayTime and both of its security clamps are untouched. CommitTime
carries the same clamp: an op stamped after this machine's clock cannot
date itself, so it sorts last rather than first.

One deviation from the plan: it assumed a scan shares one commit time,
but nextOp stamped time.Now() per op, so the tie-break never engaged and
one scan sorted in walk order. A scan is now one commit instant — order
inside the batch is already carried by Lamport and Seq, which
journal.Less reads first, so replay is unaffected.

journal.Less, Replay, LogEntries' causal order and the op format are
unchanged. The hub's History is a separate path and still orders a
rename by write time.
2026-08-18 22:10:02 -07:00
432eba1b49 fix(cli): a plain bdrive sync clears the last session's note (BEA-107) (#172)
An explicit `bdrive sync` never touched the stored session note, so a hand
edit made after `bdrive sync --note claude-session-abc` was still stamped
with that note under its 30-minute TTL — attributed to the agent in
`bdrive log`, and grouped inside that session in hub History (which also
feeds the agent-run rollback story).

The fix is a branch deleted, not added: `SaveNote("")` already routes to
`ClearNote`, so dropping the `Changed("note")` guard makes one unconditional
call both set and clear. Clearing the *store* is what leaves the cycle
unstamped — scan falls back to `LoadNote` whenever `Session.Note` is empty.

The --hook path has its own SaveNote and the daemon calls Cycle directly, so
agent- and daemon-driven syncs keep the note until the TTL expires.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 22:04:27 -07:00
3fbe1252db fix(webapp): say that a read count includes your own views (BEA-61) (#173)
* fix(webapp): say that a read count includes your own views (BEA-61)

"14 human reads" counted the author's own browsing, and no surface said so.
Snow's call was to keep counting them — so this is disclosure, not ingest:
recordRead and /heat are untouched.

One HEAT_DISCLOSURE constant beside heatText in lib/heat.ts, consumed by all
four surfaces that print a count. The file header carries it as hover text
plus an .sr-only span rather than visible text — #meta is nowrap + ellipsis,
so anything appended there is the first thing a narrow window truncates away.
The folder page says it once out loud, covering the summary and every row,
and the heat dot keeps it in title and aria-label for a row met on its own.
The Dashboard folds it into the caption already there, both scope branches.

A unit test pins the constant as the only copy of the sentence in src/ —
"defined once" is the acceptance criterion no browser test can see.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(architecture): note HEAT_DISCLOSURE in the frontend lib diagram (BEA-61)

The diagram enumerates heat.ts's exports, so a new one belongs in it — and
the note says why the constant sits beside the arithmetic instead of in the
four components that print it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 18:04:15 -07:00
c3b9fa5858 fix(cli): status looks at the folder, not just the cache (BEA-106) (#171)
`bdrive status` answered from the state cache and the journal and never
looked at the working folder. With the daemon stopped, an edit nobody has
scanned is in neither — so the one command that answers "is this folder in
sync?" reported `pending: 0` with the change sitting right there. A wrong
"you're clean" is worse than no answer.

syncer.Drift is a sibling of Explain/SyncedFiles with the same contract:
loadFilter + walkFolder + the scan's own size+mtime compare, and nothing
else. status prints it as a `local:` line, distinct from `pending` — they
are different states and a change can be in either or both.

The load-bearing property is that it stays a pure read. status is what
someone runs when sync is stuck; a version that scanned-and-committed would
change what it was asked to describe, and would write ops from a command
nobody expects to write. Pinned by a test hashing the device journal and the
state cache before and after.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 16:04:14 -07:00
495f72c43d feat(cli): bdrive stale — flag the docs your code has outgrown (#165)
Staleness today is age and only age: Insights.tsx's STALE_DAYS = 30 flags a
fresh doc nobody reads and misses the one that costs a team — a doc edited last
week describing code that moved yesterday.

`bdrive stale` measures the other thing. It scans synced markdown for
references to other synced files and reports every doc that links to something
written after the doc itself.

It is `bdrive grep` with a different per-file predicate, and keeps that
command's whole posture: LoadProject not ResolveMount (a read must not enroll
the device), a Stat-guarded store.Open (a read must not create a volume), no
session, no flock, no network, safeField on everything printed.

The one place it must not copy the obvious approach is dating a file.
materialize never calls os.Chtimes, so a peer's January edit carries this
device's mtime — on a freshly cloned machine every mtime is within seconds of
every other, and mtime comparison would report nothing on exactly the machine
that most needs the answer. Dates come from the journal: st.AllOps() folded to
the max syncer.DisplayTime per path, which also drops a forged year-9999 stamp
rather than dating that path to year 1 and flagging everything that links to it.

Resolution is the filter — a URL, a ../ escape, a made-up path is silently
ignored — so a .bdriveignore rule or a narrowed `bdrive scope` excludes a file
here exactly as it excludes it from sync.

Exit status is 0 whether or not anything is stale. grep's "1 means nothing
found" convention inverts here: it would fail on a clean project, and this is
advisory in the same sense the agent hook's context is.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 15:09:47 -07:00
Snow Lee (Sungwon)andGitHub a3dfa73fef Catch a credential when it syncs, not only when you share it (#162)
* refactor(secrets): lift the share-time credential rules into internal/secrets

The rules only ever ran on the rarest path a file takes. Moving them out of
internal/webapp is what lets internal/syncer run the same six rules on the
path every file takes, without inverting the dependency.

Pure move plus one addition: Label(), the six human strings that until now
lived only in the frontend's SECRET_LABELS — so 'bdrive share' stops printing
a bare rule id where the web dialog says 'an AWS access key'. Rule ids and the
rule/line JSON tags are unchanged: Browser.tsx keys off them, so they are a
wire contract.

* feat(sync): warn when a synced file looks like it holds a credential

The six share-time rules now run on the path every file takes. A file with an
AWS key in it used to ride a normal sync to the hub, to every teammate's disk
and into every future agent's context with no badge and no warning — while the
Share dialog one click later blocked that exact file.

Warn, never block: the op is journaled and pushed exactly as before. A hold arm
would mean a false positive silently parks someone's changes, and it would
break the cycle's degrade-to-offline posture.

- scan() reads the blob PutBlobFile just wrote (the bytes that were actually
  journaled), only on the branches that wrote one — an unchanged file is still
  never re-read.
- Findings persist per path in secrets-<mount>.json, merged rather than
  replaced: nearly every cycle scans zero files, and a whole-set rewrite would
  erase the warning seconds after it appeared. Fixing the file clears it.
- bdrive status grows a secrets block; the agent hook appends one advisory
  sentence. Rule ids and line numbers only, never the matched bytes.
- SaveSecrets failing logs and continues: advisory telemetry never gets a veto
  over convergence.

* docs: the credential check now runs on sync, not only on share

README, the CLI reference and project-files get the new bdrive status block
and the warn-never-block posture, with the three limits stated (checked when
it changes, first 1 MiB, writing device only). Diagrams: internal/secrets is a
package of its own in the overview, secretLog joins the sync engine, and the
share-gate class notes that it no longer owns the rules.

* test(sync): assert an unchanged file is never re-read for credentials

The check must ride the branch that already reads the file. Clearing the record
by hand and cycling proves it: a scan that re-read unchanged files would put the
finding back, and the daemon's 3-second tick would pay for it on every file.
2026-08-18 15:08:44 -07:00
398f30d64b fix(webapp): a wikilink is a real link, not a wiki: string (BEA-136) (#151)
[[guide]] rendered as href="wiki:guide" — a pseudo-scheme no browser can
resolve. The delegated click handler rescued a plain left-click, so the
feature looked fine until someone copied the link, middle-clicked it, or
opened it in a new tab and got a dead string.

Resolution moves from click time to transform time: transformHTML (the
pass that already rewrites this HTML before the mount) matches the target
against flatFiles and writes the real urlForPath() URL, plus a data-wiki
marker. A wikilink matching no file loses its href entirely and renders as
.wiki-missing, so no "wiki:" survives into the DOM either way.

The matching rules didn't change — they moved into a pure resolveWiki() in
util.ts, where node --test covers the whole matrix without a browser.

The consequence to get right is the click: real hrefs mean a plain click
must be intercepted (or it does a full document load) and every modified
click must be let through (or the fix buys nothing) — the same rule
nav.ts:linkProps applies everywhere else. The guard sits above both
branches, so cmd-clicking a relative markdown link now opens a tab too
instead of SPA-navigating the current one.

markdown.go is unchanged: wiki: stays the marker the server leaves behind
because RenderMarkdown has no file tree. /s/<token> share pages keep their
dead wikilinks by the spec's decision — the target isn't part of the share.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 14:48:34 -07:00
fd392aa9b0 fix(webapp): a broken mermaid fence says which line, and why (BEA-135) (#150)
* fix(webapp): a broken mermaid fence says which line, and why (BEA-135)

A fence that doesn't parse showed "Couldn't render this diagram." and
nothing else — no line, no parser output, no way to fix it. The
diagnostic already existed: mermaid throws a parse error carrying the
line number, the offending source, a caret column and the expected
tokens, and the catch discarded it one line from where it was needed.

Bind it and print it under the existing note, in a sibling element so
.mermaid-err's text stays exactly what it was. textContent, never
innerHTML: the message quotes the author's source verbatim and what
renderMermaid returns is mounted through dangerouslySetInnerHTML. The
seeded broken fence now carries a complete <img onerror=x> tag so the
e2e proves that — short on purpose, since the parser's window is 20
characters of past input and a tag it truncated would leave no start tag
for an innerHTML bug to mount.

The line number is the diagram's, not the file's: the helper only ever
sees rendered HTML, never the .md around it.

Both surfaces, one helper: the hub viewer reads style.css, the share page
reads the inline shell in shares.go and never loads the app's stylesheet.
`white-space: pre` is load-bearing (the caret only lines up in a
monospace, non-wrapping box) and max-height + overflow keeps a
pathological message inside its own box.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(webapp): rebuild static/ for the mermaid diagnostics

go:embed needs the built output in the module. Split from the source
commit because rollup re-hashes the whole mermaid chunk graph when its
importer changes: 118 of these files are renames with byte-identical
content, only their import filenames differ.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 14:30:38 -07:00
eb01953729 feat(webapp): undo a whole agent run from the run card (#156)
* feat(webapp): undo a whole agent run from the run card

The run card was grouped for this and stopped one button short: every row
inside it carried an action, the header carried none, so reverting a bad run
meant clicking file by file and hoping you got them all.

POST /api/p/<id>/undo-run works out, for every path the run touched, the op
that puts it back — a put at the pre-run blob, or a delete for a file the run
created — and writes them all in ONE journal append. That is the atomicity
argument, not an optimization: one Put of one object either lands or it does
not, so there is no half-undone run to report. appendOps is the batch write
every path in the package now goes through; appendOp is its single-op call.

Selection is by the journal an op was READ FROM, never op.Device — that field
is arbitrary JSON any member with write access can put in their own journal,
and the card attributes rows the same way. The note form additionally requires
an empty Session, because runs.ts can never file a session-carrying op under a
note-keyed card.

Append-only throughout: the run's own ops are never edited or removed, so
one-writer-per-journal and deterministic replay both survive. The undo's ops
carry a note naming the run, so the undo is itself a run card you can undo.

The confirm asks the server for the file list rather than deriving it from the
loaded feed (paged and filterable, so a client-computed list is wrong exactly
when the run is old), lists every path with its action, and names the one thing
that can burn someone: a file a teammate changed after the run is reverted too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(webapp): the undo confirm names the paths it will not write

planUndo already refuses a path the hub's own upload door would refuse — a
peer can push one under .bdrive/ or with a control character in it — but the
dialog listed only what the undo WOULD do, which reads as "all of it".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 14:04:35 -07:00
6f0f474903 feat(hub): count file changes and headless users server-side (#164)
The frontend's PostHog tracker sees everything a person clicks, but a
device syncing through /store/* never loads a page — so an agent editing
files all day was invisible, and "number of file changes" and "daily
active users" both undercounted by however much of the product runs
headless.

One event, files_changed, from every write door: sync, upload (relay and
direct commit), remove, restore. Its distinct_id is the same email
analytics.ts identifies with, so a person on a laptop and a browser is
one user, and its puts/deletes properties sum to the change count.

The count comes from ops the hub has not stored before, not from the
request body: a device PUTs its WHOLE journal every cycle, so counting
the body would re-report the device's entire history every ten seconds
and the metric would climb while nobody edited anything.
journalKeepsItsOps already parsed the stored journal for the append-only
check and threw the sequence away; it returns storedMax now, so this
costs no extra read. Blob PUTs are deliberately not change events —
content-addressed storage skips a blob it already holds, so blob writes
undercount edits while ops are exact.

No SDK: posthog-go would ship a tracker inside every self-hoster's
binary, which is the exact thing the frontend avoids by loading
posthog-js from a CDN only when a key is configured. Capture is one JSON
POST, on its own goroutine, that does nothing when Analytics.Key is
empty — an OSS hub still contacts nobody.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 14:50:25 -07:00
Snow Lee (Sungwon)andGitHub 3de8590b6b feat(sync): gzip the sync wire, without touching what a hash means (#160)
Nothing on the /store/* wire was compressed, while the corpus it carries is
markdown and source. Compression lands as a pure transport concern: content
addressing, the storage layout and the journal format all stay over the
uncompressed bytes.

The two legs are not symmetric. Pull needs no negotiation — net/http already
sends Accept-Encoding: gzip and inflates transparently — so devices built
before this get it the day the hub ships; a real pre-compression binary
receives 19,958 bytes for a 148 KB corpus (7.4x) with no client change. Push
is negotiated through sign()'s accept_encoding, because a gzip body posted to
an old hub would be stored under the sha256 of its plaintext.

The hub inflates ABOVE spool — the sha a key promises, the ops a journal
carries and the size that gets billed are all plaintext properties — and the
inflate is bounded at 256 MiB, because Content-Encoding severs the
one-wire-byte-one-disk-byte relationship that made spool safe unbounded. The
presigned direct-to-storage leg stays raw and is asserted to.

Known deployment caveat: a compressed push clears ContentLength, so it goes
out chunked where every push was sized before. A reverse proxy that buffers or
rejects chunked request bodies would fail pushes (degrading to Offline and
retrying, not losing data).
2026-08-13 12:20:36 -07:00
edfe46c0aa post_sync: run a local command when teammates' changes land (#163)
Inbound sync was invisible to the machine it landed on — a local index,
cache or notifier had to poll. A `post_sync` command in the folder's own
.bdrive/config.json now runs once per cycle that applied peer changes,
with the batch as JSON on stdin.

The batch rides out on a new Result.Inbound rather than the inbound
spool: DrainInbound is destructive and `bdrive sync --hook` is its only
consumer, so a second drainer would silently empty the agent's
"teammates changed X" context. Both are kept, and both comments now say
why.

Cycle becomes a thin wrapper over cycleLocked so the hook is spawned
after the volume flock drops — a property of the code shape, not a rule
each of the seven call sites has to remember.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 11:04:23 -07:00
0dd474baab Delta sync: large files move as content-defined chunks (#161)
* feat(sync): delta sync — large files move as content-defined chunks

Files over 4 MiB push as chunks/<sha256> pieces plus a manifests/<sha256>
chunk list keyed by the whole file's hash, so Op.Blob alone locates it and
the journal format is byte-identical. A 1-byte edit to a 20 MiB file now
transfers ~2 MB instead of ~21 MB, both directions; chunk boundaries come
from a rolling hash (restic/chunker), so front insertions stay cheap.

The hub reassembles whole blobs on demand (spool, verify, backfill, serve),
which is the entire backward-compatibility story: old clients ask for
blobs/<sha> and never learn anything changed. Proven by e2e tests that build
the real pre-change binary from the pinned merge-base commit.

The push skip-proof is one Exists per chunk — three cheaper proxies (local
basis, manifest existence, stored manifest content) each proved false or
forgeable across four CTO review rounds and are recorded in the code
comment. Hub-side, manifests are write-once and must name only chunks the
store holds; reassembly is bounded at 256 MiB against amplified manifests.

Also: per-file sync ceiling 32 -> 100 MiB; import refuses archives whose
journals reference content they do not hold (--allow-incomplete overrides).

Deploy hubs before clients: old hubs refuse chunk keys (push degrades to
offline-retry), and old clients cap reads at 32 MiB so 32-100 MiB files
report "blob corrupt on remote" until the client upgrades.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R6nqxi5a9qcENmJvrgBJF7

* fix(test): fetch the pinned pre-delta commit on shallow CI clones

buildOldBinary archives the pinned merge-base sha, which a fetch-depth-1
actions/checkout does not have — all three old-binary e2e tests failed in
CI with exit 128 while passing on any full local clone. On archive failure,
fetch just that commit (--depth=1, one object; actions/checkout persists
credentials so the in-job fetch works) and retry. Verified against a real
GitHub shallow clone: archive fails, the single-sha fetch succeeds, archive
then yields the pre-delta tree.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R6nqxi5a9qcENmJvrgBJF7

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 07:18:37 -07:00
33ca0caeab fix(sync): connecting a folder adopts the project's files instead of forking them (#159)
A device's first cycle on a volume was treated as a concurrent edit for every
path the project already held. Whichever side's clock happened to sort higher
won -- so a joiner's seeded .bdriveignore or agent-written AGENTS.md could
replace the team's -- and the loser landed beside it as a
.bdrive-conflict-<device>-<time> file.

A first cycle is a join, not an edit. Cycle step 1b holds the scan's ops back
over the pull and demotes any whose path the project already holds to lamport
0, which sorts under every op a project can carry (scan's clock starts at 1).
The project's version then wins deterministically on every device, and
conflictCopies skips those ops the way it already skips re-asserted ones. The
local content is still journaled and pushed, so it stays in History and
`bdrive restore --list <path>` can bring it back. Reported as `adopted: N`.

Concurrent edits after the join are untouched.


Claude-Session: https://claude.ai/code/session_01GLCyEWP2XZhYjBssvdCDQm

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 16:10:10 -07:00
7eabb3baf4 docs: the starting paste is one sentence, not a template to fill in (#158)
The setup page opened with a prompt carrying three placeholders — project id,
hub URL, project name — none of which a first-time reader has. It was the
teammate-joining paste doing double duty as the getting-started paste, and
it asked someone with nothing to substitute values into a two-line string
before they could begin.

The start is now:

    Follow beardrive.ai/setup to set up BearDrive. Ask me which folder to sync.

Same destination — beardrive.ai/setup 302s to INSTALL_FOR_AGENTS.md — and the
same wording the landing page hands out, so the two surfaces stop disagreeing
about how this begins.

The join case keeps its placeholders and moves to where it belongs: the
project's own home page in the hub already renders that paste pre-filled, and
the page now says to use it rather than hand-assemble one. Getting this wrong
means a teammate creating a second project beside yours, so it is stated as
the consequence rather than a preference.

Also says why "Ask me which folder to sync" is in the sentence at all. It is
not politeness: without it agents read the rest as permission to decide, and
the guess is the whole folder — the one answer INSTALL_FOR_AGENTS.md tells
them never to recommend (#157).

README carries the same paste and is updated with it, per the sync rule in
CLAUDE.md.


Claude-Session: https://claude.ai/code/session_01CmKrcXaXxamwcPqR4DcU7S

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 14:49:58 -07:00
fa898f141e docs: "in this folder" is not an answer, and the template offer becomes a step (#157)
Two failures from one real run: an agent given "Set up BearDrive in this
folder" mounted the whole folder without asking, and never offered a
starting structure.

Both were already specified — the ask is a hard gate in step 3 and the
template offer was the last paragraph of it. Neither survived contact.

- A location phrase names where the session runs, not what syncs. "in this
  folder" / "here" / "this project" are the start of the conversation, not
  the end of it; the question is answered only when the user picks between
  the recommendation and the alternatives, in a message of their own.
- The template offer is now step 4, not a trailing paragraph — same lesson
  as #155. A decision the user makes is not a footnote to the command
  above it.
- "Empty" is defined: init has just written .bdrive/ and seeded
  .bdriveignore, so the folder is never literally empty when the agent
  looks. Those, .git/ and other dotfiles do not count as content.

Verified with the onboarding-e2e skill against a seeded hub, using the
failing wording verbatim: turn 1 asks and recommends shared/ with the
folder still untouched, turn 2 offers the four templates reasoning "only
init's own .bdrive/ + .bdriveignore", and the docs template lands and
reaches the hub.


Claude-Session: https://claude.ai/code/session_01CmKrcXaXxamwcPqR4DcU7S

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 14:24:40 -07:00
a3c949d242 docs: the repo-root pointer becomes a step agents actually run (#155)
The "point your repo's agents at the synced folder" instruction sat in a
trailing "Optional:" section outside the numbered flow, so installing agents
skipped it — a real setup wired up init/hooks/sync/autostart and left the repo
root with no pointer at all, meaning agents starting there never found the
folder's AGENTS.md.

Promote it to numbered Step 6 (ask-first, like step 3), with the paste-ready
pointer block, the <mount> substitution, "pointer, not copy", and a
no-duplicate-block guard so re-running is safe.

Fixes BEA-138


Claude-Session: https://claude.ai/code/session_01CmKrcXaXxamwcPqR4DcU7S

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 23:43:35 -07:00
f70d54192a feat(web): a Beta pill beside the BearDrive wordmark (#154)
BearDrive launches free with a beta label, so the hub says so next to its
own name. Subtle honey pill at low alpha, not the solid accent: the accent
is already spent on the mark two elements to its left, and this sits there
until it doesn't.

It keys on what the lockup actually SAYS (`brand === "BearDrive"`), not on
whether `brand` was configured. A hub that set its own brand is labelling
somebody else's product and "Acme Docs Beta" is a claim we have no business
making for them — but the managed hub and every default self-host reach here
with the string "BearDrive", and the obvious `!config.brand` test would have
missed them: CLOUD_BRAND defaults to "BearDrive", so a BuiltinAuth hub sends
the name rather than an empty field, and the pill would have shown only on
the PropelAuth path.


Claude-Session: https://claude.ai/code/session_019vWP4DbsWCeD6zWN5gaqsV

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 20:27:45 -07:00
8931004b4a docs: use cases move to the marketing site, and the logo goes home (#153)
The seven use-case pages were the one part of docs.beardrive.ai that wasn't
documentation — job-shaped routing pages, aimed at someone deciding rather
than someone building. beardrive.ai already publishes six of them, same
titles, same slugs, so the docs copy was a fork of marketing copy that no one
was keeping in step.

They're deleted here, and every URL redirects to its counterpart over there
rather than to an index: those pages were live and indexed for a month, and
sending all seven to /use-cases/ would throw away which one someone asked
for. shared-skills is the exception — the landing site has no page for it
yet, so that one lands on the index.

The logo now points at beardrive.ai. Starlight always aims it at the docs
root and has no config for it, so this is a SiteTitle component override:
its own markup, one changed href, minus the two-logo branch this site never
takes (one `src`, so the light/dark alternate can't render).

Three off-site links — Use cases, Blog, GitHub — go at the BOTTOM of the
sidebar. The sidebar order is the recommended path, so a link that leaves
the docs must not sit above the docs.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 19:02:50 -07:00
3d2acf658c ci(docs): docs.beardrive.ai deploys itself again (#152)
docs.beardrive.ai was serving a build from around 2026-07-19 — three weeks
and 38 docs commits stale. /manual/hooks/ 404s while /manual/skills-and-hooks/,
deleted in #85, still serves; the sitemap has no <lastmod> and robots.txt is
Cloudflare's managed content-signals file with no Sitemap: line, so #140
plainly never shipped.

The cause: the Pages project (beardrive-docs, docs.beardrive.ai) is a
direct-upload project with no Git provider. Someone ran `wrangler pages
deploy` by hand, then stopped, and nothing anywhere noticed — every check
this repo has runs during a deploy that was no longer happening.

docs.yml builds web/docs on PRs that touch it and deploys to Pages on pushes
to main. Not the Pages Git integration, deliberately: it clones shallow, and
astro.config.mjs reads each page's <lastmod> from the commit date behind it,
so a depth-1 checkout drops all 27 of them — hence fetch-depth: 0. It also
would rebuild the docs for every commit in a repo that is mostly Go.

The path filter includes internal/webapp/frontend/src/tw.css: the palette is
generated from that file, so it is a docs input even though it lives outside
web/docs (which is also why the checkout can't be sparse).

The post-deploy check:sitemap run is continue-on-error. Half of what it
checks — a Cloudflare-managed robots.txt shadowing ours, cache propagation
right after upload — isn't this repo's call, and a good deploy shouldn't go
red over it.

Needs CLOUDFLARE_API_TOKEN (Pages: Edit) and CLOUDFLARE_ACCOUNT_ID.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 18:28:32 -07:00
37bd466bb7 docs: three claims that no longer match the code (#148)
- start/setup says "Three are shipped" above four templates. Four ship
  (internal/templates/files: docs, para, skills, wiki).
- guides/shared-agent-memory tells the reader to run `/beardrive:install`.
  There is no such command — no plugin, no skill; the integration is
  internal/agenthooks and setup is the paste on start/setup.
- index lists SSO as part of the managed service. No SAML/OIDC exists
  anywhere, and the cloud plan has it unstarted.


Claude-Session: https://claude.ai/code/session_01UmhLKHq3QTHjkDi2wCDhNB

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 08:35:32 -07:00
94091a8795 fix(hub): device binding is a provider contract, not a BuiltinAuth field (#147)
ownJournal refuses a journal write unless the device id is bound to the
caller's account, for EVERY provider — it asks only whether s.Devices is
nil. The only thing that creates that binding is DeviceRegistry.Bind, whose
only caller is BuiltinAuth.finishLogin, and that hook was wired behind
`if a, ok := s.Auth.(*BuiltinAuth); ok`.

So a hub running a managed AuthProvider — the deployment the seam exists
for — bound nothing, ever, and refused every journal push from every device
forever. Everything around it read healthy: /api/auth/me answered, project
permissions said write, and blobs (content-addressed, so ownerless) uploaded
fine. Only the journal PUT died. Signing in again could not help, because
signing in was the step that was supposed to bind.

UseDeviceBinder moves the hook onto the AuthProvider interface — a breaking
change for an out-of-tree provider, deliberately, so one that ignores a
precondition of a gate the hub enforces for it does not compile. The hub
cannot bind on the provider's behalf: a bind must be reachable only from a
completed authentication, and Authenticate reports who a request is, never
which credential class it presented. A device token still cannot reach a
bind; no new door was added, and every /store/* door still creates nothing.

Bind also reported success for a row the store had refused — observeLocked
logged the write failure and swallowed it — so a login could hand back a
token whose every push was then denied, with nothing in the hub explaining
why. It now propagates and the login fails honestly.

And a hub that refuses a journal write while holding no binding at all logs
that its provider is not calling the binder — the sentence that would have
ended this investigation on day one instead of day two.

Tested by driving the real binary against a hub with a managed provider
(cli_provider_e2e_test.go), which is the configuration no test in this repo
covered and the reason this shipped: every existing test used BuiltinAuth,
where the wiring happened to work. Both directions are pinned — a provider
that binds pushes, one that ignores the binder reproduces the reported
symptom exactly.


Claude-Session: https://claude.ai/code/session_01GSHsQU4pBCzKkPyPeXSwTm

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 08:23:34 -07:00
Snow LeeandClaude Opus 5 dd35453c33 docs: changelog for v0.15.0
Leads with the upgrade reason: a CLI older than the hub's journal ownership
gate signs in without binding its device, so pushes 403 forever and
re-logging in does not help.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GSHsQU4pBCzKkPyPeXSwTm
v0.15.0
2026-08-10 20:37:23 -07:00
922886b949 fix(sync): a refused push stays refused, and says why (BEA-403) (#146)
A device whose journal push the hub 403s reported healthy sync between
every pair of remote passes, and never showed the hub's reason for the
refusal — so a user whose device was not registered to their account
re-ran `bdrive login` (which the message told them to), re-checked their
project permissions (write), and had nowhere left to look.

Two causes, both local to the client:

- Cycle recomputed st.Access from scratch at the end of every pass,
  including the daemon's cheap local-only ticks that never reach the hub.
  Three of those run between remote passes, so the daemon alternated
  "read-only on this project" / "access restored; syncing normally" every
  few seconds and `bdrive status` reported OK moments after a refused
  push. Now each leg records its own verdict — pull clears no-access, push
  records read-only or clears it — and a cycle that asked nothing leaves
  the last answer standing.
- The hub's own sentence was summarized into "read-only (pull only)",
  which describes the STATUS CODE. It is the only thing that tells a
  device-registration refusal from a project the user really is a reader
  on. It now rides in SyncState.AccessReason and Result.Reason(), printed
  by `bdrive sync`, `bdrive status` and the daemon log, and dropped unless
  it passes journal.SafeText — hub text reaching a terminal.

The hub's refusal also now names the upgrade: the binding is made by the
login request naming its device, which a CLI older than the gate does not
do, so "run `bdrive login`" alone sent that user in a circle. Hub and CLI
deploy separately, so the skew is the expected state right after the gate
ships.


Claude-Session: https://claude.ai/code/session_01GSHsQU4pBCzKkPyPeXSwTm

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 20:33:26 -07:00
Snow Lee (Sungwon)andGitHub 709c40e76c fix(webapp): restore asks before it syncs to every device (BEA-129) (#145)
Clicking `restore` in History posted straight to the hub — no dialog —
while the two other web actions that leave the browser (revoke a share
link, remove a file) both confirm first. The gated action was the
reversible one and the effectively-irreversible one wasn't.

The dialog's second line is case-aware, because the two cases really do
differ: swapping a live file's content is walk-backable from History,
but bringing a deleted file back is not — the run card's
"undo — remove file" is the only delete control in the whole web UI, and
a restore produces no run card. So that branch says so instead of
promising an undo the UI can't keep.

The flag needs no new computation: headBlob's "" already marks a path
whose newest op is a delete, so HistoryView passes `recreates` down
beside `restoreSha` (both feed shapes — standalone rows and rows inside
a run card). A row can't decide this itself: an older EDIT row of a
deleted path re-creates the file just as much as the DELETED row does,
and only the feed knows that.

Confirm styling is non-danger on purpose: restore adds content, it takes
none away, so it doesn't wear Remove's red.

Building the missing undo for the ADDED row a restore creates is
deliberately out of scope (Snow scoped this to "add confirmation").
2026-08-10 14:40:56 -07:00
5623113ff7 feat(sync): agents hear what teammates changed before they overwrite it (BEA-127) (#144)
A teammate's agent rewrites a file and yours never hears about it — the only
trace is a `.bdrive-conflict-*` nobody opens. Now the turn-start hook names
the paths that arrived since the last turn: "re-read before editing".

The record lives in a spool (`internal/store/inbound.go`), not in Cycle's
Result, because the daemon usually materializes a peer's change seconds
before the turn starts — so the hook's own cycle sees nothing. materialize
appends every path it writes or removes, `bdrive sync --hook` drains it after
its cycle and renders it under each mount's own prefix (stripping the session
subpath when the run is inside a mount, dropping paths outside it).

Advisory only: nothing blocks, nothing prompts, no per-Write remote call. The
spool is capped, 0600, in the volume dir, and best-effort everywhere — a
spool failure never fails a cycle or a turn.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 14:33:20 -07:00
Snow Lee (Sungwon)andGitHub 2875e033be feat(webapp): mermaid fences render as diagrams, in the viewer and on share pages (BEA-91) (#143)
A ```mermaid fence rendered as a wall of `graph TD` source on both surfaces.
It now renders as an SVG in the hub file viewer and on public /s/<token>
markdown share pages.

Mermaid ships inside the binary (no CDN, so air-gapped self-hosters keep
working) and is imported lazily: a document with no fence downloads none of
it. A fence that doesn't parse — the common case for hand-written wiki
diagrams — keeps today's <pre><code> plus a small note, and one bad fence
never stops the good ones beside it. A blocked or offline chunk lands in the
same place.

The share page is the harder half: it is server-rendered Go HTML with no
JavaScript, and its `sandbox allow-scripts` CSP makes the origin opaque, so a
module script and every import() it makes arrive with `Origin: null`. The CSP
is unchanged and gains no allow-same-origin; instead the real-asset branch of
frontend() now sets Access-Control-Allow-Origin, which only ever touches files
that are already public and cookie-less. The script tag itself is injected
only when the rendered document actually contains a fence.

Also fixes an embed bug this change surfaced: `//go:embed static` silently
skips names beginning with `_`, and Vite's first shared chunk is
`_commonjsHelpers-<hash>.js`. The build passed, the commit looked right, and
the served app was blank. It is `all:static` now, with a test that every file
on disk is in the binary.
2026-08-10 14:18:18 -07:00
4031495c81 feat(cli,docs): say that agent skills sync, and refuse ~/.claude as a mount root (BEA-117) (#138)
`.claude/skills/**` has always synced — deliberately, per the reservation
rule's own comment — but the only sentence saying so sits under the heading
"What beardrive does not sync". Nobody knows.

Track B, the one real bug: `bdrive init ~/.claude` was accepted. The
reserved-path rule matches ".claude/settings.json" on its directory segment,
so at that mount root the file is bare "settings.json" — reserved by nothing —
along with .credentials.json and every saved session under projects/. New
exported config.AgentConfigDir folds the keys of agentHookConfigs the way
ReservedDir folds (case, trailing dots), and init refuses before any network
call or file write. Only that direction leaks: a mount CONTAINING ~/.claude
still sees .claude/settings.json, reserved at any depth.

Track A, the content job: a README Features bullet stating the positive claim,
a 7th use-case page (plus its astro.config.mjs sidebar entry, without which it
is invisible), and a `skills` template appended last to the registry so `docs`
keeps the RECOMMENDED badge. The embed directive becomes `//go:embed all:files`
— a plain pattern drops dot-prefixed paths silently, so the template whose
whole payload is .claude/skills/<name>/SKILL.md would have shipped empty.

templates_test.go's every-directory-holds-a-file rule now marks ancestors, not
just the direct parent: skills is the first template more than one level deep,
and the rule was stricter than its own stated reason (an intermediate
directory on the way to a file is not empty).

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 05:04:12 +09:00
d3d92bf904 feat(webapp): refuse to share a file that looks like it holds credentials (BEA-111) (#137)
Minting a share link ran zero content checks: a file holding an AWS-shaped
key became a public URL on one click, and the CLI printed nothing but the
link. handleShareCreate now reads the first 1 MiB and runs six anchored
rules between the synced-path check and Shares.Create, answering 409 with
rule ids and line numbers unless the request carries confirm: true.

The matched text never leaves scanSecrets — not into the body, not into a
log line. TestShareSecretNeverEchoed greps both for the planted string,
because a 409 body is the easiest place in this codebase to leak it.

Both callers carry the override, since the gate alone would turn any false
positive into a hard block with no way out: `bdrive share --force`, and the
browser's Share-anyway dialog on modalConfirm (no new component). A path
that already has a live link skips the scan — its content is public
already, so withholding the URL protects nobody — but alreadyPublic drops
links whose creator left the org, since those 404 at /s/ and would
otherwise wave a secrets file straight through.

A failed blob read is 503, not a silent pass: the repo's "degrade rather
than fail" posture is for sync cycles, and a check that skips itself on a
storage hiccup is the false confidence this exists to remove.

Every user-facing string says the file was checked at the moment you shared
it. A link serves the file's LATEST content forever, so a key written into
an already-shared file is never caught — that open loop stays open, and the
copy is the only thing stopping v1 from claiming otherwise.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 04:33:05 +09:00
594a027c15 feat(cli): bdrive grep — search the text inside the files a project syncs (BEA-99) (#136)
The ⌘K palette searches file names, projects and actions; nothing in the
product searched file contents. Three personas independently typed a phrase
that lives inside a synced file and got "No matches".

`bdrive grep <pattern> [folder]` searches the working folder — RE2 or -F
literal, -i, -l, -n (default 200, 0 = all), output `path:line: text`, exit 0
on match and 1 on none.

It searches exactly what the project syncs, via a new syncer.SyncedFiles that
wraps the existing walkFolder: the one copy of the sync predicate, so an
ignore rule or a narrowed scope excludes a file from search the same way it
excludes it from sync, and .bdrive/ state can never surface. Not Explain,
which countFiles every pruned dir — a grep in a repo with node_modules/ would
walk it in full for a count it discards.

A read stays a read: LoadProject, not ResolveMount (no registry self-heal, no
device enrollment), no session, no flock, and the volume store is opened for
IgnoreAccepted only when it already exists, so a search creates nothing.
Both the path and the matched line go through safeField — a matched line is a
teammate's file content, the widest version of the surface that function
exists for.

The hub-side content index stays deliberately unbuilt; the issue records its
cost. ROADMAP's "Search across the hub" line is reworded rather than removed.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 04:27:20 +09:00
Snow Lee (Sungwon)andGitHub 5f1ac98dae feat(hub): see what each agent session read, not just what it changed (BEA-98) (#135)
History showed what an agent run CHANGED. What it read lived in a daily
aggregate with no session dimension, so the two could not be joined and
nobody could answer "when my agent answered, what did it look at — and was
it the fresh version or archive/retired-spec.md?".

The join is one string carried through four places: hook -> spool -> hub ->
run card. A run card now marks each change the run also read, lists the
files it read and never touched, and says on screen why a read can be
missing.

The three landmines the issue asks be named here:

1. Op.Note is USER-SETTABLE (`bdrive sync --note`), so joining reads to
   writes on the note string would let any member with write access forge a
   note that collides with a teammate's run card and hang their reads off
   it. Fixed by adding journal.Op.Session — set only by `bdrive sync
   --hook`, never by --note — and joining on that. The note stays settable
   and stays untrusted; the join simply never reads it. Op.Session is
   additive JSONL and, like Mtime, is never an input to Less or Replay, so
   replay determinism is untouched and older ops carry "".

   The read half has the same hole one step further on: POST /reads takes
   the session id from the CLIENT, so a member could report reads under a
   teammate's session and paint files onto their card. Every session row is
   therefore pinned to the ownsDevice-validated device, and the query
   requires ?session= AND ?device= together — a forged row can only be found
   under the forger's own device, which MayActAs guarantees is never
   somebody else's.

2. BUCKET CARDINALITY. Putting the session in the read_stats key would take
   a 2k-file project from ~2k to ~100k rows/day, into a table ReadLedger
   loads whole at boot and full-scans on every heat request, hub-wide — so
   it would slow the Dashboard for projects that never ran an agent. This
   is the escape hatch the spec itself names, taken up front: session rows
   live in their own read_sessions repo, outside ReadLedger.byKey. No
   read_stats PK migration, no change to the resident-row count, ?by=device
   byte-identical. They get their own retention (session_retention_days,
   default 30) which DELETES rather than folds — no heat total was ever
   derived from them.

3. READS ARE RECORDED ONLY FOR PATHS IN THE CURRENT REPLAY, so a session
   that read a file it then deleted shows a change with no read. That is by
   design, and the run card says so in its footer rather than leaving it to
   read as a bug.

Privacy ruling, written into internal/webapp/reads.go before anything
serves it: a session id appears only in History responses on the op that
carries it, and as a ?session= filter INPUT. It is never enumerated — no
listing endpoint, no session column in /heat output, nothing new in
?by=device.

Also: PendingReads now dedupes on (path, session), not path alone. Two
agent sessions on one device between syncs used to collapse into one event
carrying whichever session flushed last — one session's reads silently
credited to another.

Tests: journal round-trip + Less-ignores-Session; the forge test (`sync
--note "claude-code session <someone-else's>"` leaves Session empty); a
multi-device syncer test carrying the session through convergence; spool
per-session dedup; hub round-trip, cross-device forge, query contract and
non-enumeration; db_conformance on file, sqlite AND postgres; runs.ts
grouping incl. legacy fallback; a Playwright spec on the seeded run card.
2026-08-11 04:18:53 +09:00
831d5cda31 fix(webapp): shared-page inline code reads as code in dark mode (BEA-90) (#134)
BEA-71 fixed the ordering bug that made the dark block lose to the light
`code` rule, but it stopped at the background: a dark chip still inherits
the body's #eef0f3, so `bdrive init` mid-sentence looks exactly like the
prose around it. That was the reporter's actual ask — "make the text more
distinct".

Inline code now carries the hub viewer's warm tint (#e4d9c4) plus an edge.
The edge is an inset box-shadow, not a border, so the chip's box metrics
don't move between modes and light mode stays byte-identical — verified by
screenshotting the same share page in both schemes before and after.

`pre code` resets both, so a fenced block stays one dark slab instead of a
row of bordered chips.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 03:15:07 +09:00
8fce9d2c46 perf(webapp): cache parsed journals so history stops re-downloading everything (BEA-85) (#133)
History folded every journal of a project on every request — 8-10s on a real
project, and paging made it worse rather than better, since each "load more"
re-downloaded and re-parsed the lot. There is no DB in this path, so no index
was ever going to help.

Journals only GROW: a device appends only to its own (the one-writer
invariant) and the hub's appendOp rewrites its key with strictly more bytes.
So the (Size, Modified) that List already reports proves a parse is still
current — no staleness window, no expiry, no new Backend method. A warm
request now pays one List and nothing else.

The cold path can never be helped by a cache, so the Get loop is concurrent
too (errgroup, limit 8) instead of one serial round trip per device.

Files() reads the same funnel, so folder listings get it for free.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 03:08:47 +09:00
32a099997e fix(webapp): a bogus project deep link says so instead of swapping projects (BEA-83) (#131)
/no-such-project-xyz/some/file.md used to redirect to whichever project the
fallback chain picked, dropping the path — nothing on screen distinguished
"that link is wrong" from "you opened your project."

An unknown route.project now renders a "Project not found" panel at the URL
as typed, mirroring the org-not-found page two blocks up: shell, sidebar and
account bar stay mounted, and "Back to <name>" points at the same project the
fallback picks today. The fallback chain itself is unchanged, and "/" still
redirects to the remembered project (BEA-75).

All four redirects below the flag rewrite the address bar off current.id, so
they are gated together — /bad-id/insights and /bad-id/notes/ would otherwise
undo the fix on their own.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 03:05:23 +09:00
e19fec0534 feat(webapp): old URLs follow a moved file, and share links follow the file (BEA-81) (#130)
There is no rename in beardrive: the scanner emits a put at the new path and
a delete at the old, same device, same blob, one cycle. Everything keyed on a
path therefore broke the moment a file moved — the viewer 404'd, history lost
the file's own past versions, restore refused them, and a share link either
404'd or silently served whatever unrelated file later took its address.

internal/webapp/moves.go derives the pairing from the ops the replay already
walks, cached with the snapshot. Deliberately not a rename op: journal.Less
and Replay are what every device converges to, and every already-shipped
journal would still need the heuristic to read its own history.

The two rules point in opposite directions on purpose. A viewer URL is an
address, so a LIVE path always wins and only an empty one redirects. A share
token is a promise about one file, so it follows the file even when a new one
takes the old address — and 404s forever once the file is deleted.

Nothing here writes an op or touches sync.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 02:52:20 +09:00