fix(cli): agent hooks never sync folders this device didn't opt into (#44)

* fix(cli): agent hooks never sync folders this device didn't opt into

The turn hooks decided "this folder is managed" from the mere presence of
.bdrive/config.json — a file designed to travel with the folder. Two holes:

- A config.json arriving via git clone / copied dir made one hook firing
  silently mint a device identity, register the mount, create a volume
  store, journal the whole folder, and inject the hub-link formula — on a
  device that never ran init or login.
- `bdrive stop` only killed the daemon: the next agent turn's
  `bdrive sync --hook` resumed a full sync cycle and kept injecting links,
  and `stop --forget` was undone within one turn by registry self-heal.

Fix: one gate (`syncBlocked`) in the paths all hooks route through —
sync/sync --hook/read-log now require the mount to already be enrolled in
this device's mounts.json (read without ResolveMount's enrolling
self-heal) and not paused. Hook mode exits silently; plain `bdrive sync`
errors with a `bdrive init` pointer. New per-device paused marker in the
volume dir: set by `bdrive stop`, cleared by `bdrive init` (startSync).
Only init enrolls or resumes; folder moves still self-heal since
enrollment is keyed by mount id, not path.

Docs updated (README, SKILL.md, docs cli reference, CHANGELOG). Tests:
hook/read-log no-op + no-enrollment on unenrolled and paused mounts,
plain-sync refusals, stop→pause→forget regression, paused marker contract.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: architecture-diagram PRs must show before/after excerpts of changed classes

The "Architecture changes" PR section now names exactly what changed and
shows Before and After mermaid excerpts scoped to the affected classes and
their immediate relationships — never the full diagram (Before = merge
base). Convention updated in CLAUDE.md, architecture/README.md, and the
pre-PR hook's reminder text.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: add cli-sync architecture diagram; widen diagram convention to the CLI

architecture/cli-sync.md draws the CLI and sync engine (cmd/bdrive +
internal/{syncer,store,journal,config,daemon,agenthooks}): the Session
cycle over Store/journal/remote, and the command layer with the new
syncBlocked opt-in gate, paused marker, and enrollment ownership. The
pre-PR hook and CLAUDE.md now watch these packages too, so CLI-side
structural changes trigger the before/after-excerpt convention the same
way server changes do.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: full-coverage architecture diagrams — overview, frontend, agentskills

Every application package is now drawn somewhere: overview.md (system
diagram — package map, device↔hub↔storage flow, agent surfaces, and the
private cloud/ repo as an external seam consumer), webapp-frontend.md (the
hub SPA's modules: App/HubApp/VolumeApp/Browser, the in-repo nav/router,
api layer, hooks, components), and agentskills added to cli-sync.md. The
pre-PR hook now watches all of cmd/, internal Go code, and frontend/src
(generated static/ excluded); CLAUDE.md and architecture/README.md state
the coverage rule: every code change lands in exactly one detail diagram's
scope, web/docs and cloud/ deliberately excluded.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: PR bodies start with a TL;DR — max 5 informal one-liners

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Snow W. Lee (Sungwon)
2026-07-24 15:53:23 +09:00
committed by GitHub
co-authored by Claude Fable 5
parent 4f5f517a6f
commit 6628718f1d
19 changed files with 725 additions and 26 deletions
+11 -5
View File
@@ -12,17 +12,23 @@ case "$cmd" in
*skip-diagram-check*) exit 0 ;;
esac
base=$(git merge-base origin/main HEAD 2>/dev/null || git merge-base main HEAD 2>/dev/null) || exit 0
code=$(git diff --name-only "$base" HEAD -- 'internal/webapp/*.go' 'internal/remote/*.go')
# Every application package is drawn in architecture/ (see its README);
# frontend static/ is generated output, excluded via the src-only pathspec.
code=$(git diff --name-only "$base" HEAD -- 'cmd/' 'internal/*.go' \
'internal/webapp/frontend/src/')
diag=$(git diff --name-only "$base" HEAD -- architecture/)
if [ -n "$code" ] && [ -z "$diag" ]; then
cat >&2 <<EOF
This branch changes server code but architecture/ is untouched:
This branch changes code covered by architecture/ diagrams but architecture/ is untouched:
$code
Before creating the PR: if any of these change types or relationships drawn
in architecture/, update the affected diagram, commit it, and embed the
changed diagram(s) in the PR body. If nothing structural changed, re-run the
same command with '# skip-diagram-check' appended.
in architecture/, update the affected diagram, commit it, and add an
"Architecture changes" section to the PR body: say what changed, then show
Before and After mermaid excerpts of ONLY the affected classes and their
immediate relationships (Before = diagram at the merge base), never the full
diagram. If nothing structural changed, re-run the same command with
'# skip-diagram-check' appended.
EOF
exit 2
fi
+10
View File
@@ -6,6 +6,16 @@ minor versions may ship breaking changes (see [SemVer §4](https://semver.org/#s
## Unreleased
- **Fix: agent hooks no longer sync (or inject hub links into) folders this
device never opted into.** `bdrive sync`/`sync --hook`/`read-log` now
require the mount to be enrolled here — a `.bdrive/config.json` that
merely arrived with a folder (git clone, copied dir) is inert until
`bdrive init`; previously one hook firing silently minted a device
identity, registered the mount, and journaled the whole folder. And
`bdrive stop` now truly pauses: it sets a per-device paused marker that
gates the hooks and `bdrive sync` (which previously resumed a stopped
project every agent turn and re-registered even after `stop --forget`);
only `bdrive init` resumes.
- **`bdrive skill install`** — the binary now carries the `beardrive`
skill and installs it into any agent that reads `SKILL.md`
(`~/.claude|.codex|.gemini|.hermes/skills/beardrive/`), idempotently;
+5 -1
View File
@@ -73,9 +73,13 @@ The real coverage is the integration tests in `internal/syncer/syncer_test.go`:
`web/docs/` is the public documentation at docs.beardrive.ai — Astro 7 + [Starlight](https://starlight.astro.build) (Starlight requires Astro ^7; the cloud landing is still on Astro 5 — separate projects, so they upgrade independently), static output, Pagefind search, `llms.txt` via `starlight-llms-txt`. Unlike the hub frontend (`internal/webapp/static`) and the cloud landing page (`cloud/internal/landing/dist`) it is **not** `go:embed`ed: docs change far more often than the binary, and a search index has no business shipping in every self-hoster's install. It deploys on its own from `dist/`, so `go build` never touches it. Sidebar order is explicit in `astro.config.mjs` — a new page under `src/content/docs/` is invisible until listed there, and every page needs a `description` (meta description, search snippet, and `llms.txt` line). **The sidebar order is the recommended path, and that path is agent-first**: `Start here` (what it is → set up with your agent → your first hour) never mentions installing a binary; the CLI route lives in `Manual setup (optional)` (install the CLI, set up by hand, skills and hooks in detail) — same destination, one click away, never on the critical path. New onboarding content belongs in `Start here` and should say what to ask an agent, not what to type. `Use cases` (after `Manual setup`) holds job-shaped pages — "Share work across your team's agents", "Turn a personal brain into a company brain" — with the persona named in the first line and the `description`, not in the title; they route into the guides and never re-teach a feature. Moved URLs keep `redirects` entries in `astro.config.mjs` (static builds emit meta-refresh; real 301s live in the host config, see `web/docs/README.md`). **Guides are agent-workflow docs, not CLI tutorials** (`Working with agents`: shared agent memory, artifacts/links, read heat, scoping); command-by-command detail belongs in `reference/cli.md`. Design tokens are **generated, never copied**: `scripts/tokens.mjs` reads the `@theme` block in `internal/webapp/frontend/src/tw.css` and emits the gitignored `src/styles/tokens.gen.css`, which `src/styles/custom.css` maps onto Starlight's `--sl-color-*` — so the palette cannot drift and there is no checker to maintain (contrast the cloud landing, which keeps a copy policed by its own `check-tokens.mjs`). Because the build reads that file *outside* `web/docs`, a deploy host must check out the whole repo, not the subdirectory. Note `llms.txt` convention wants the root domain, so `beardrive.ai/llms.txt` should point at the docs subdomain — that redirect lives in the cloud landing and is the one cross-repo coordination point.
## PR descriptions
Every PR body starts with a `## TL;DR` section: at most 5 bullet points, one short informal sentence each — fewer is better. Lead with the user-visible symptom/outcome, not the mechanism; include a known-gap bullet if the PR knowingly leaves one. Detail sections follow after.
## Architecture diagrams in PRs
`architecture/` holds mermaid class diagrams of the server (`webapp-server.md`). Before `gh pr create`: if the branch changes types or relationships drawn there (new/removed types, new seams, changed fields/implements/ownership in `internal/webapp` or `internal/remote`), update the affected diagram and commit it on the branch, then include ONLY the changed diagrams' mermaid blocks in the PR description under an "Architecture changes" section (GitHub renders them). No structural change → no section, and append `# skip-diagram-check` to the `gh pr create` command to satisfy the pre-PR hook.
`architecture/` holds mermaid diagrams covering every application package in the repo: `overview.md` (system diagram — the package map and how the pieces connect), `cli-sync.md` (the CLI and sync engine — `cmd/bdrive` + `internal/{syncer,store,journal,config,daemon,agenthooks,agentskills}`), `webapp-server.md` (the `bdrive web` server — `internal/webapp` + `internal/remote`), and `webapp-frontend.md` (the hub's React SPA — `internal/webapp/frontend/src`). Every code change lands in exactly one detail diagram's scope; `overview.md` changes only when packages appear/disappear or the cross-piece wiring changes (`web/docs` and the private `cloud/` repo are deliberately out of scope). Before `gh pr create`: if the branch changes types or relationships drawn in any of them (new/removed types, new seams, changed fields/implements/ownership in those packages), update the affected diagram and commit it on the branch, then add an "Architecture changes" section to the PR description with, per changed diagram: (1) a sentence naming exactly which types/relationships changed and how, (2) a **Before** mermaid block and an **After** mermaid block (GitHub renders them), each an *excerpt* containing only the affected classes and their immediate relationships — never paste the full diagram. The committed diagram file stays the full current state; the before/after excerpts live only in the PR description (take Before from the diagram at the merge base). No structural change → no section, and append `# skip-diagram-check` to the `gh pr create` command to satisfy the pre-PR hook.
## Docs to keep in sync
+3 -2
View File
@@ -108,7 +108,8 @@ bdrive log
# Check sync state and the daemon
bdrive status
# Stop syncing (files stay on disk; bdrive init resumes any time)
# Stop syncing — pauses everything, including agent turn hooks
# (files stay on disk; bdrive init resumes any time)
bdrive stop
```
@@ -138,7 +139,7 @@ hub's own storage, never something a syncing client points at directly:
| `bdrive login [server-url]` | Sign this device in (browser flow; `--device` forces the code flow, and shells without a TTY fall back to it automatically; default server beardrive.ai — the managed cloud, free personal workspace on signup; pass your hub URL to self-host). Switch hubs with `bdrive login <new-url>` |
| `bdrive logout` | Sign this device out — clear the saved token/account (`--forget` also drops the remembered server) |
| `bdrive init [folder]` | Create/connect a project and start syncing — interactive on a TTY, flags (`--name/--project/--shared/--yes`) for scripts; re-run to resume |
| `bdrive stop [folder]` | Stop syncing (files stay; `bdrive init` resumes) |
| `bdrive stop [folder]` | Stop syncing, including agent sync hooks (files stay; `bdrive init` resumes) |
| `bdrive url [path]` | Internal hub link for a file/folder (sign-in + membership required; `--sync` pushes first; no arg = project home). Computed locally |
| `bdrive share <file>` | Public URL for a synced file (`--list`, `--revoke`, `--expires`) |
| `bdrive sync [folder]` | Run one sync cycle now. `--note <text>` stamps session context (e.g. an agent session id) onto changes — shown in `bdrive log` and hub history; keeps applying to daemon-committed changes until `--note-ttl` (default 30m) expires. `--hook <label>` is agent-hook plumbing: event JSON on stdin, sync + note, gated-link formula (Claude Code hook JSON) on stdout |
+22 -4
View File
@@ -5,9 +5,27 @@ can update them alongside the change.
**Convention:** when a PR changes the structure drawn here (new/removed types,
new seams, changed relationships), update the affected diagram in the same PR
and embed the changed diagrams' mermaid blocks in the PR description under an
"Architecture changes" section, so reviewers see the structural delta. A
pre-PR hook (`.claude/hooks/check-arch-diagrams.sh`) reminds Claude Code
sessions when server code changed but no diagram did.
and add an "Architecture changes" section to the PR description that, per
changed diagram:
1. names exactly which types/relationships changed and how (one sentence);
2. shows a **Before** and an **After** mermaid block — each an *excerpt* of
only the affected classes and their immediate relationships, never the
full diagram (Before comes from the diagram at the merge base).
The committed diagram file stays the full current state; the before/after
excerpts exist only in the PR description so reviewers see the structural
delta at a glance. A pre-PR hook (`.claude/hooks/check-arch-diagrams.sh`)
reminds Claude Code sessions when server code changed but no diagram did.
Together these cover every application package in the repo — every code
change lands inside exactly one detail diagram's scope (plus the overview
when the package map or cross-piece wiring changes):
- [overview.md](overview.md) — system diagram: every package and surface on one page, and how they connect
- [cli-sync.md](cli-sync.md) — class diagram of the CLI and sync engine (`cmd/bdrive` + `internal/{syncer,store,journal,config,daemon,agenthooks,agentskills}`)
- [webapp-server.md](webapp-server.md) — class diagram of the `bdrive web` server (`internal/webapp` + its `internal/remote` seam)
- [webapp-frontend.md](webapp-frontend.md) — module diagram of the hub's React SPA (`internal/webapp/frontend/src`)
Not covered on purpose: `web/docs` (content site, no application code) and
`cloud/` (private nested repo — its architecture lives there).
+164
View File
@@ -0,0 +1,164 @@
# `bdrive` CLI & sync engine — class diagram
Source of truth: `cmd/bdrive` (commands, gates) and `internal/{syncer,store,
journal,config,daemon,agenthooks,agentskills}`; the `internal/remote` seam is drawn in
[webapp-server.md](webapp-server.md). Reflects the code as of this commit;
update this file in any PR that changes these types or their relationships.
## Sync engine — one cycle
```mermaid
classDiagram
direction LR
class Session {
+Folder string
+MountID string
+Store *store.Store
+Device config.Device
+Account config.Settings
+Backend remote.Backend
+Note string
+OnProgress func
+Cycle(ctx) Result
}
note for Session "internal/syncer — scan → commit local ops → pull peer journals → preserve conflicts → materialize → push blobs then own journal"
class Result {
+LocalOps +PulledOps
+Conflicts +Materialized
+Pushed +Offline +OfflineErr
}
class Filter {
+Skip(rel) bool
+PruneDir(rel) bool
}
note for Filter "ignore.go — .bdriveignore rules + .bdrive include list, applied symmetrically in scan and materialize"
class Store {
-dir volume dir
+PutBlob / OpenBlob / HasBlob
+AppendOps / DeviceOps / AllOps
+LoadCache / SaveCache mountID
+LoadSync / SaveSync
+SaveNote / LoadNote
+PendingReads read spool
+Lock() flock
}
note for Store "internal/store — ~/.bdrive/volumes/mount-id: content-addressed blobs, per-device journal copies, state cache, paused marker (free funcs Paused/SetPaused, no flock)"
class Op {
+Seq +Lamport +Time +Device
+Author +User +UserName
+Kind put or delete
+Path +Blob +Size +Mode +Note
}
note for Op "internal/journal — Less orders by (lamport, time, device, seq); Replay folds to LWW-per-path state; each device writes only its own journal"
class Backend {
<<interface>>
+Put +Get +List +Exists +Close
}
note for Backend "internal/remote — client devices use the https:// hub backend (token from BDRIVE_TOKEN / settings.json)"
class daemon {
+Run(folder, scan, remote)
+Start / Stop / Running
}
note for daemon "per-mount detached loop; re-reads .bdrive/config.json each tick, exits without deletes if it vanishes"
Session --> Store : volume state
Session --> Backend : pull and push
Session --> Filter : scan and materialize
Session ..> Op : commits, replays
Session --> Result
Store o-- Op : journal files
daemon --> Session : one Cycle per tick
```
## CLI commands, device state, and the opt-in gate
```mermaid
classDiagram
direction LR
class Commands {
init login logout
sync stop status log
url share export import
web daemon hooks read-log skill
}
note for Commands "cmd/bdrive — thin cobra layer; init is the front door, stop pauses"
class syncBlocked {
<<gate>>
enrolled in mounts.json?
volume not paused?
}
note for syncBlocked "cmd/bdrive/helpers.go — sync, sync --hook, and read-log must pass it; reads the registry WITHOUT ResolveMount's enrolling self-heal. Hook mode fails silent; plain sync errors with a bdrive init pointer"
class openSession {
mustProject → ResolveMount
store.Open + remote.Open
}
class startSync {
enroll + clear paused
initial Cycle
daemon.Start
}
note for startSync "cmd/bdrive/sync_run.go — init's engine; the ONLY enroller and the only thing that resumes a pause"
class Project {
+ID stable mount id
+Volume +Remote +Include
}
note for Project ".bdrive/config.json — travels with the folder (git clone, copy); presence alone is NOT consent to sync"
class MountRegistry {
mounts.json
id → Path Volume Remote
}
class Device {
device.json
}
class Settings {
settings.json
server + token + account
}
note for MountRegistry "internal/config — per-device state under BDRIVE_HOME; ResolveMount self-heals the path for enrolled mounts (renames/moves stay free)"
class AgentHooks {
Detect / Install / Registered
turn-start: sync --hook
post-edit: sync --note
post-read: read-log
}
note for AgentHooks "internal/agenthooks — registers per-platform hook commands (claude, codex, gemini, hermes); they fire in every folder, every turn"
class PausedMarker {
volumes/id/paused
}
note for PausedMarker "set by bdrive stop, cleared only by bdrive init (startSync)"
class AgentSkills {
Detect / Install
embedded SKILL.md
}
note for AgentSkills "internal/agentskills — installs the beardrive skill user-level (per-platform skills dir) from the binary's embedded copy; idempotent, refreshed on upgrade"
Commands --> AgentSkills : skill install
AgentHooks --> Commands : runs sync and read-log
Commands --> syncBlocked : sync and read-log gate first
syncBlocked --> MountRegistry : reads only, never enrolls
syncBlocked --> PausedMarker : Paused check
Commands --> openSession : after the gate
openSession --> MountRegistry : path self-heal (enrolled only)
Commands --> startSync : init
startSync --> MountRegistry : enrolls
startSync --> PausedMarker : clears
Commands --> PausedMarker : stop sets
openSession ..> Project : loads
openSession ..> Device : identity
openSession ..> Settings : account and token
```
+54
View File
@@ -0,0 +1,54 @@
# BearDrive — system overview
The whole repo on one page: every package and surface, and which detail
diagram drills into it. Reflects the code as of this commit; update this
file in any PR that adds/removes a package or changes how the pieces
connect. Detail diagrams: [cli-sync.md](cli-sync.md),
[webapp-server.md](webapp-server.md),
[webapp-frontend.md](webapp-frontend.md).
```mermaid
flowchart LR
subgraph device["User device"]
wf["working folder<br/>(real files + .bdrive/config.json)"]
cli["cmd/bdrive<br/>CLI commands"]
dmn["internal/daemon<br/>background loop"]
eng["internal/syncer Session.Cycle<br/>internal/journal ops + replay"]
vs["volume store ~/.bdrive/volumes/id<br/>internal/store: blobs, journals,<br/>state, paused marker"]
cfg["internal/config<br/>device.json, settings.json, mounts.json"]
end
subgraph agents["Agent platforms (claude / codex / gemini / hermes)"]
hooks["internal/agenthooks<br/>turn-boundary sync hooks"]
skills["internal/agentskills + plugin/<br/>SKILL.md, commands, hook scripts"]
end
subgraph hub["bdrive web hub"]
srv["internal/webapp Server<br/>auth, orgs, projects, shares,<br/>history, read heat, store proxy"]
fe["webapp/frontend React SPA<br/>committed dist go:embed'ed at webapp/static"]
meta["MetaStore: file JSON (default)<br/>or sqlite / postgres (db_sql)"]
end
store["object store (hub-owned)<br/>internal/remote: file:// s3:// gs://<br/>blobs + per-device journals"]
docs["web/docs — docs.beardrive.ai<br/>Astro/Starlight, deploys separately"]
cloud["cloud/ (PRIVATE nested repo, gitignored)<br/>managed beardrive.ai: swaps AuthProvider,<br/>QuotaProvider, MetaStore seams"]
wf <-->|scan / materialize| eng
cli --> eng
dmn --> eng
cli --> cfg
eng --> vs
eng <-->|"https:// backend (internal/remote/http.go)<br/>device token, /api/p/id/store/*"| srv
hooks -->|"bdrive sync --hook / --note, read-log<br/>gated: enrolled + not paused"| cli
skills -.->|teach agents the CLI| agents
srv --> store
srv --> meta
fe -->|/api/config, /api/projects, viewer APIs| srv
cloud -.->|imports OSS packages,<br/>replaces providers| srv
docs -.->|documents| cli
```
Not drawn in any detail diagram (deliberately): `web/docs` (content site, no
Go/TS application code) and `cloud/` (private repo — its architecture lives
there; here it only consumes the provider seams drawn in webapp-server.md).
+76
View File
@@ -0,0 +1,76 @@
# Hub frontend (React SPA) — module diagram
Source of truth: `internal/webapp/frontend/src`. The built output is
committed at `internal/webapp/static` (the `go:embed` target), so `go build`
never needs Node. Reflects the code as of this commit; update this file in
any PR that changes these modules or their relationships.
```mermaid
classDiagram
direction LR
class App {
mode from /api/config
}
note for App "App.tsx — picks HubApp (multi-project) or VolumeApp (single volume) from server config; frontend learns everything from the API, never sees storage or credentials"
class HubApp {
project list, org walls
admin panels, invites
}
class VolumeApp {
thin wrapper: one volume
}
class Browser {
folder listing, file view
per-view routes
}
class router {
+VIEW_ROUTES insights history install settings
+parseRoute(pathname, mode) Route
+urlForPath / urlForView
+encodePath / decodePath
}
class nav {
+navigate(url)
+useLocationPath()
+linkProps(href)
+Redirect
}
note for nav "nav.ts + router.ts — deliberately NOT a router library (react-router v7 startTransition left stale views); History-API path routing, slashes literal, every user-facing page owns a URL path"
class api {
+getJSON / postJSON / api
types.ts server contracts
}
note for api "api/http.ts — all URLs root-absolute so deep paths never break relative resolution"
class hooks {
+useConfig
+useHub
+useBrowse
}
note for hooks "TanStack Query wrappers over the viewer APIs"
class components {
FileView FolderListing FileTree
HistoryView Insights ShareDialog
OrgAdmin HubSettings ProjectSettings
Palette shell AccountBar ...
}
note for components "components/ui — shadcn/ui primitives (Radix, copied in), themed from BearDrive tokens in tw.css; rendered markdown is transformed as a string before mounting, link clicks delegated on the container — never patch the dangerouslySetInnerHTML subtree"
App --> HubApp
App --> VolumeApp
HubApp --> Browser
VolumeApp --> Browser
HubApp --> router
Browser --> router
Browser --> components
HubApp --> components
components --> nav : linkProps navigate
hooks --> api
Browser --> hooks
HubApp --> hooks
```
+19
View File
@@ -25,11 +25,30 @@ func syncCmd() *cobra.Command {
if err != nil {
return err
}
// Gate before openSession: hooks fire in every folder on every
// turn, and must never enroll this device or resume a paused
// project — that is `bdrive init`'s job alone.
proj, ok, err := config.LoadProject(folder)
if hookLabel != "" {
// Agent-hook mode: event JSON on stdin, silent best-effort
// sync, link-formula context on stdout. Never fails.
if err != nil || !ok || syncBlocked(proj) != "" {
return nil
}
return runHookSync(cmd, folder, hookLabel)
}
if err != nil {
return err
}
if !ok {
return fmt.Errorf("%s is not a beardrive project (run `bdrive init` there first)", folder)
}
switch syncBlocked(proj) {
case "init":
return fmt.Errorf("%s is not synced on this device yet (run `bdrive init` there to connect it)", folder)
case "paused":
return fmt.Errorf("syncing is paused for %s (run `bdrive init` there to resume)", folder)
}
sess, proj, err := openSession(cmd.Context(), folder, true)
if err != nil {
return err
+20
View File
@@ -44,6 +44,26 @@ func mustProject(folder string) (config.Project, error) {
return proj, nil
}
// syncBlocked reports why syncing must not run for a project on this device:
// "init" when the mount was never enrolled here (.bdrive/config.json travels
// with the folder — e.g. arrives in a git clone — so its presence alone is
// not consent to sync; only `bdrive init` enrolls a device), "paused" after
// `bdrive stop`, "" to proceed. Deliberately reads the registry without
// ResolveMount's self-heal, which would enroll as a side effect.
func syncBlocked(proj config.Project) string {
mounts, err := config.LoadMounts()
if err != nil {
return "init"
}
if _, enrolled := mounts[proj.ID]; !enrolled {
return "init"
}
if vdir, err := config.VolumeDir(proj.ID); err == nil && store.Paused(vdir) {
return "paused"
}
return ""
}
// openSession builds a syncer session for a project folder. When withRemote
// is set and the remote is unreachable, it degrades to offline with a warning
// rather than failing.
+94 -5
View File
@@ -24,6 +24,9 @@ func TestSyncHookMode(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if _, _, err := config.ResolveMount(folder); err != nil { // enroll, as `bdrive init` would
t.Fatal(err)
}
c := syncCmd()
var out bytes.Buffer
@@ -38,7 +41,7 @@ func TestSyncHookMode(t *testing.T) {
`"hookSpecificOutput"`,
`"hookEventName":"UserPromptSubmit"`,
"https://hub.example.com/p-12345678", // base URL: remote minus /p
"[🔗](", // the emoji-link convention
"[🔗](", // the emoji-link convention
"code blocks", // paths in code blocks stay plain
"PUBLIC", // bdrive share stays opt-in
} {
@@ -77,23 +80,109 @@ func TestSyncHookModeNoOps(t *testing.T) {
t.Fatalf("non-mount emitted output: %s", out.String())
}
// Garbage stdin: still sync, still emit, never fail.
// A config.json that arrived with the folder (git clone, copied dir)
// but was never enrolled on this device via `bdrive init`: silent no-op,
// and — crucially — no device enrollment as a side effect.
folder := t.TempDir()
folder, _ = filepath.EvalSymlinks(folder)
if _, err := config.SaveProject(folder, config.Project{
proj, err := config.SaveProject(folder, config.Project{
Volume: "wiki", Remote: "https://hub.example.com/p/p-12345678",
}); err != nil {
})
if err != nil {
t.Fatal(err)
}
c2 := syncCmd()
out.Reset()
c2.SetOut(&out)
c2.SetIn(strings.NewReader("not json at all"))
c2.SetIn(strings.NewReader(`{"session_id":"x"}`))
c2.SetArgs([]string{folder, "--hook", "claude-code"})
if err := c2.Execute(); err != nil {
t.Fatalf("unenrolled mount must be a silent no-op: %v", err)
}
if out.Len() != 0 {
t.Fatalf("unenrolled mount emitted output: %s", out.String())
}
mounts, err := config.LoadMounts()
if err != nil {
t.Fatal(err)
}
if _, enrolled := mounts[proj.ID]; enrolled {
t.Fatal("hook auto-enrolled the mount; only `bdrive init` may do that")
}
// Enrolled but paused by `bdrive stop`: silent no-op too.
if _, _, err := config.ResolveMount(folder); err != nil {
t.Fatal(err)
}
vdir, err := config.VolumeDir(proj.ID)
if err != nil {
t.Fatal(err)
}
if err := store.SetPaused(vdir, true); err != nil {
t.Fatal(err)
}
c3 := syncCmd()
out.Reset()
c3.SetOut(&out)
c3.SetIn(strings.NewReader(`{"session_id":"x"}`))
c3.SetArgs([]string{folder, "--hook", "claude-code"})
if err := c3.Execute(); err != nil {
t.Fatalf("paused mount must be a silent no-op: %v", err)
}
if out.Len() != 0 {
t.Fatalf("paused mount emitted output: %s", out.String())
}
// Garbage stdin on a live mount: still sync, still emit, never fail.
if err := store.SetPaused(vdir, false); err != nil {
t.Fatal(err)
}
c4 := syncCmd()
out.Reset()
c4.SetOut(&out)
c4.SetIn(strings.NewReader("not json at all"))
c4.SetArgs([]string{folder, "--hook", "claude-code"})
if err := c4.Execute(); err != nil {
t.Fatalf("garbage stdin must not fail: %v", err)
}
if !strings.Contains(out.String(), `"hookSpecificOutput"`) {
t.Fatalf("formula not emitted on garbage stdin: %s", out.String())
}
}
// Plain `bdrive sync` (the push hook's form, and what users type) refuses
// unenrolled and paused mounts with instructions instead of silently
// enrolling or resuming.
func TestSyncRefusesUnenrolledAndPaused(t *testing.T) {
t.Setenv("BDRIVE_HOME", t.TempDir())
folder := t.TempDir()
folder, _ = filepath.EvalSymlinks(folder)
proj, err := config.SaveProject(folder, config.Project{Volume: "wiki"})
if err != nil {
t.Fatal(err)
}
c := syncCmd()
c.SetArgs([]string{folder})
err = c.Execute()
if err == nil || !strings.Contains(err.Error(), "bdrive init") {
t.Fatalf("unenrolled sync error = %v, want a `bdrive init` pointer", err)
}
if _, _, err := config.ResolveMount(folder); err != nil {
t.Fatal(err)
}
vdir, err := config.VolumeDir(proj.ID)
if err != nil {
t.Fatal(err)
}
if err := store.SetPaused(vdir, true); err != nil {
t.Fatal(err)
}
c2 := syncCmd()
c2.SetArgs([]string{folder})
err = c2.Execute()
if err == nil || !strings.Contains(err.Error(), "paused") {
t.Fatalf("paused sync error = %v, want a paused message", err)
}
}
+6 -3
View File
@@ -40,9 +40,12 @@ to run it by hand.`,
if err != nil {
return nil
}
proj, found, err := config.ResolveMount(folder)
if err != nil || !found {
return nil // not a beardrive project: fast no-op
// LoadProject, not ResolveMount: a hook must never enroll this
// device (registry self-heal) — and syncBlocked keeps a paused
// or never-inited project's spool from even being created.
proj, found, err := config.LoadProject(folder)
if err != nil || !found || syncBlocked(proj) != "" {
return nil // not an actively synced project: fast no-op
}
data, _ := io.ReadAll(io.LimitReader(cmd.InOrStdin(), 1<<20))
paths := extractEventPaths(data, folder)
+63
View File
@@ -107,6 +107,9 @@ func TestReadLogCommand(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if _, _, err := config.ResolveMount(folder); err != nil { // enroll, as `bdrive init` would
t.Fatal(err)
}
c := readLogCmd()
c.SetIn(bytes.NewReader([]byte(`{"session_id":"abc","tool_name":"Read",
@@ -133,3 +136,63 @@ func TestReadLogCommand(t *testing.T) {
t.Fatalf("spool = %+v, want just the in-project read, mount-relative", evs)
}
}
// read-log fires on every agent tool call in every folder, so it must be
// inert where this device never opted in: an unenrolled config.json (one
// that arrived with a git clone / copied folder) spools nothing, enrolls
// nothing, and creates no volume store; a paused project spools nothing.
func TestReadLogGated(t *testing.T) {
t.Setenv("BDRIVE_HOME", t.TempDir())
folder := t.TempDir()
folder, _ = filepath.EvalSymlinks(folder)
proj, err := config.SaveProject(folder, config.Project{Volume: "wiki"})
if err != nil {
t.Fatal(err)
}
vdir, err := config.VolumeDir(proj.ID)
if err != nil {
t.Fatal(err)
}
event := `{"tool_name":"Read","tool_input":{"file_path":"` + folder + `/wiki/a.md"}}`
run := func() {
c := readLogCmd()
c.SetIn(bytes.NewReader([]byte(event)))
c.SetArgs([]string{folder})
if err := c.Execute(); err != nil {
t.Fatalf("read-log must never fail: %v", err)
}
}
// Unenrolled: no spool, no store, no registry entry.
run()
if _, err := os.Stat(vdir); !os.IsNotExist(err) {
t.Fatal("unenrolled read-log created the volume store")
}
mounts, err := config.LoadMounts()
if err != nil {
t.Fatal(err)
}
if _, enrolled := mounts[proj.ID]; enrolled {
t.Fatal("read-log enrolled the mount; only `bdrive init` may do that")
}
// Enrolled but paused by `bdrive stop`: still nothing spooled.
if _, _, err := config.ResolveMount(folder); err != nil {
t.Fatal(err)
}
if err := store.SetPaused(vdir, true); err != nil {
t.Fatal(err)
}
run()
st, err := store.Open(vdir)
if err != nil {
t.Fatal(err)
}
evs, err := st.PendingReads()
if err != nil {
t.Fatal(err)
}
if len(evs) != 0 {
t.Fatalf("paused read-log spooled %+v, want nothing", evs)
}
}
+13 -2
View File
@@ -26,6 +26,11 @@ func startSync(ctx context.Context, folder string, proj config.Project, foregrou
if _, err := store.Open(vdir); err != nil {
return err
}
// init is the one gesture that (re)consents to syncing: clear any pause
// left by `bdrive stop` so the daemon and the agent hooks run again.
if err := store.SetPaused(vdir, false); err != nil {
return err
}
// Initial cycle: import existing files, pull remote state.
sess, _, err := openSession(ctx, folder, true)
@@ -82,10 +87,16 @@ folder's .bdrive settings and the local volume data are kept).`,
if err != nil {
return err
}
// The pause must outlive the daemon: agent turn hooks run
// `bdrive sync` in this folder on every turn and would silently
// resume without it. Cleared by `bdrive init`.
if err := store.SetPaused(vdir, true); err != nil {
return err
}
if stopped {
fmt.Printf("stopped syncing %s\n", folder)
fmt.Printf("stopped syncing %s (run `bdrive init` to resume)\n", folder)
} else {
fmt.Printf("no sync daemon running for %s\n", folder)
fmt.Printf("no sync daemon running for %s; syncing paused (run `bdrive init` to resume)\n", folder)
}
if forget {
mounts, err := config.LoadMounts()
+82
View File
@@ -0,0 +1,82 @@
package main
import (
"bytes"
"path/filepath"
"strings"
"testing"
"github.com/runbear-io/beardrive/internal/config"
"github.com/runbear-io/beardrive/internal/store"
)
// `bdrive stop` must actually stop: it pauses the volume so the agent turn
// hooks and `bdrive sync` no-op (they used to resume a stopped project on
// the very next turn), and after --forget nothing re-enrolls the mount
// behind the user's back.
func TestStopPausesHooksAndForgetSticks(t *testing.T) {
t.Setenv("BDRIVE_HOME", t.TempDir())
folder := t.TempDir()
folder, _ = filepath.EvalSymlinks(folder)
proj, err := config.SaveProject(folder, config.Project{
Volume: "wiki",
Remote: "https://hub.example.com/p/p-12345678",
})
if err != nil {
t.Fatal(err)
}
if _, _, err := config.ResolveMount(folder); err != nil { // enroll, as `bdrive init` would
t.Fatal(err)
}
vdir, err := config.VolumeDir(proj.ID)
if err != nil {
t.Fatal(err)
}
runHook := func() string {
c := syncCmd()
var out bytes.Buffer
c.SetOut(&out)
c.SetIn(strings.NewReader(`{"session_id":"s"}`))
c.SetArgs([]string{folder, "--hook", "claude-code"})
if err := c.Execute(); err != nil {
t.Fatalf("hook mode must never fail: %v", err)
}
return out.String()
}
// Live project: hook syncs and emits the link formula.
if !strings.Contains(runHook(), `"hookSpecificOutput"`) {
t.Fatal("enrolled live project: hook did not emit the formula")
}
// stop (no daemon running is fine): pause marker set, hook goes quiet.
c := stopCmd()
c.SetArgs([]string{folder})
if err := c.Execute(); err != nil {
t.Fatal(err)
}
if !store.Paused(vdir) {
t.Fatal("stop did not set the paused marker")
}
if out := runHook(); out != "" {
t.Fatalf("hook after stop emitted %q, want silence", out)
}
// stop --forget: unregistered, and the hook must not self-heal it back.
c2 := stopCmd()
c2.SetArgs([]string{folder, "--forget"})
if err := c2.Execute(); err != nil {
t.Fatal(err)
}
if out := runHook(); out != "" {
t.Fatalf("hook after --forget emitted %q, want silence", out)
}
mounts, err := config.LoadMounts()
if err != nil {
t.Fatal(err)
}
if _, enrolled := mounts[proj.ID]; enrolled {
t.Fatal("hook re-enrolled a forgotten mount")
}
}
+36
View File
@@ -0,0 +1,36 @@
package store
import (
"os"
"path/filepath"
)
// The paused marker is per-device, per-volume state: `bdrive stop` sets it,
// `bdrive init` clears it. While set, nothing may sync the volume — not the
// daemon, not `bdrive sync`, and especially not the agent turn hooks, which
// would otherwise silently resume a project the user explicitly paused.
// Free functions on the volume dir so the gate is checked without opening
// (and flocking) the store.
func pausedPath(dir string) string { return filepath.Join(dir, "paused") }
// Paused reports whether syncing is paused for the volume dir.
func Paused(dir string) bool {
_, err := os.Stat(pausedPath(dir))
return err == nil
}
// SetPaused sets or clears the paused marker.
func SetPaused(dir string, on bool) error {
if !on {
err := os.Remove(pausedPath(dir))
if os.IsNotExist(err) {
return nil
}
return err
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
return WriteFileAtomic(pausedPath(dir), []byte("paused by `bdrive stop`; `bdrive init` resumes\n"), 0o644)
}
+41
View File
@@ -0,0 +1,41 @@
package store
import (
"path/filepath"
"testing"
)
// The paused marker gates every sync path (daemon, `bdrive sync`, agent
// hooks): set by `bdrive stop`, cleared by `bdrive init`, absent by default.
func TestPaused(t *testing.T) {
dir := t.TempDir()
if Paused(dir) {
t.Fatal("fresh volume dir must not be paused")
}
if err := SetPaused(dir, true); err != nil {
t.Fatal(err)
}
if !Paused(dir) {
t.Fatal("marker not set")
}
if err := SetPaused(dir, false); err != nil {
t.Fatal(err)
}
if Paused(dir) {
t.Fatal("marker not cleared")
}
// Clearing an already-clear volume is a no-op, not an error — init runs
// it unconditionally.
if err := SetPaused(dir, false); err != nil {
t.Fatalf("double clear: %v", err)
}
// Stop can run before the volume dir exists (e.g. after wiping
// ~/.bdrive/volumes by hand); the marker still sticks.
missing := filepath.Join(dir, "never-synced")
if err := SetPaused(missing, true); err != nil {
t.Fatal(err)
}
if !Paused(missing) {
t.Fatal("marker not set in fresh dir")
}
}
+4 -2
View File
@@ -15,7 +15,7 @@ Use this skill whenever the user is working with the `bdrive` CLI: initializing
|---|---|
| Start syncing a project (create/connect; the front door) | `bdrive init [<folder>]` — interactive on a TTY; flags `--name <x>` / `--project <id>` / `--shared <dir>` / `--yes` for scripts and agents (NEVER prompts without a TTY). Re-run to resume, including after the folder was renamed/moved. Runs the login flow first (against your hub URL) if the device has no session. |
| Run the daemon in the foreground | `bdrive init -f` |
| Stop syncing | `bdrive stop [<folder>]` (`--forget` also unregisters) |
| Stop syncing | `bdrive stop [<folder>]` — pauses daemon *and* agent hooks; `bdrive init` resumes (`--forget` also unregisters) |
| One sync cycle now | `bdrive sync [<folder>]``--note <text>` stamps session context; `--hook <label>` is the Claude turn-start hook's plumbing (event JSON in, sync + note, gated-link formula out) |
| Register agent sync hooks (Claude Code, Codex, Gemini CLI, Hermes) | `bdrive hooks install [<folder>]` — auto-detects the platforms in use and merges pull/push/session-note/read-tracking hooks into each one's own hook config, idempotently; bare `bdrive hooks` shows the status table |
| Install this skill on another agent (Codex, Gemini CLI, Hermes, Claude Code) | `bdrive skill install [<folder>]` — writes the binary's own copy of this skill to each detected platform's user-level skills dir (`~/.codex/skills/beardrive/SKILL.md` and friends), idempotently; bare `bdrive skill` shows the status table. Then the user asks that agent to set the folder up and it runs `init` + `hooks install` itself |
@@ -110,9 +110,11 @@ Renaming/moving a project folder is safe: the daemon notices its folder vanished
### Stop
- `bdrive stop <folder>` — stop the daemon. Files stay on disk; the local volume store under `~/.bdrive/volumes/<mount-id>/` is kept. `bdrive init` resumes any time.
- `bdrive stop <folder>` — stop the daemon and pause all syncing for the folder, **including the agent turn hooks** (`bdrive sync` refuses while paused, and the turn-start hook stops injecting the link formula). Files stay on disk; the local volume store under `~/.bdrive/volumes/<mount-id>/` is kept. Only `bdrive init` resumes.
- `bdrive stop <folder> --forget` — also drop from the mount registry. Local volume data is still preserved; `rm -rf ~/.bdrive/volumes/<mount-id>/` reclaims disk.
Syncing only ever runs where this device opted in: a `.bdrive/config.json` that merely arrived with the folder (git clone, copied dir) is inert — hooks and `bdrive sync` no-op until someone runs `bdrive init` there.
### On-demand sync
`bdrive sync [<folder>]` runs a single cycle (scan → upload blobs+journal → pull remote journals → materialize). Useful to:
+2 -2
View File
@@ -12,10 +12,10 @@ One binary, `bdrive` — the CLI, the sync daemon, and the web server.
| `bdrive login [server-url]` | Sign this device in. Browser flow; `--device` forces the code flow, and shells without a TTY (agents, CI, SSH) fall back to it automatically. Default server is beardrive.ai — the managed cloud, free personal workspace on signup; pass your hub URL to self-host. Switch hubs with `bdrive login <new-url>`. `--status` shows the current server and account |
| `bdrive logout` | Sign this device out — clear the saved token and account. `--forget` also drops the remembered server |
| `bdrive init [folder]` | Create or connect a project and start syncing. Interactive on a TTY; flags (`--name`, `--project`, `--shared`, `--yes`) for scripts. Re-run to resume |
| `bdrive stop [folder]` | Stop syncing. Files stay on disk; `bdrive init` resumes |
| `bdrive stop [folder]` | Stop syncing — daemon and agent sync hooks both pause. Files stay on disk; `bdrive init` resumes |
| `bdrive url [path]` | Internal hub link for a file or folder — sign-in and membership required. `--sync` pushes first; no argument gives the project home. Computed locally |
| `bdrive share <file>` | Public URL for a synced file. `--list`, `--revoke`, `--expires` |
| `bdrive sync [folder]` | Run one sync cycle now. `--note <text>` stamps session context onto changes; `--note-ttl` (default 30m) bounds it. `--hook <label>` is agent-hook plumbing |
| `bdrive sync [folder]` | Run one sync cycle now. Refuses folders this device never `init`ed and folders paused by `bdrive stop`. `--note <text>` stamps session context onto changes; `--note-ttl` (default 30m) bounds it. `--hook <label>` is agent-hook plumbing |
| `bdrive hooks [install]` | Register turn-boundary sync hooks with detected agent platforms. Idempotent; `--agent` overrides detection |
| `bdrive skill [install]` | Install the `beardrive` skill into detected agent platforms so the agent can do setup itself. Idempotent; `--agent` overrides detection |
| `bdrive read-log [folder]` | Hook plumbing: queue agent file reads for the hub's read heatmap. Registered by `bdrive hooks install` |