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>
This commit is contained in:
Snow Lee (Sungwon)
2026-08-18 22:04:27 -07:00
committed by GitHub
co-authored by Claude Opus 5
parent 3fbe1252db
commit 432eba1b49
4 changed files with 95 additions and 11 deletions
+1 -1
View File
@@ -256,7 +256,7 @@ hub's own storage, never something a syncing client points at directly:
| `bdrive forget <path>...` | Stop syncing a path *and* remove it from the hub — adds the rule to `.bdriveignore` (which syncs) and prunes in one step. Local files are never touched, here or on teammates' devices |
| `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. `--prune` also removes from the hub what `.bdriveignore` now excludes (files stay on disk everywhere). `--hook <label>` is agent-hook plumbing: event JSON on stdin, sync + note, gated-link formula (Claude Code hook JSON) on stdout |
| `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. A plain `bdrive sync` with no `--note` clears it, so a hand edit is never stamped with the last agent session's note. `--prune` also removes from the hub what `.bdriveignore` now excludes (files stay on disk everywhere). `--hook <label>` is agent-hook plumbing: event JSON on stdin, sync + note, gated-link formula (Claude Code hook JSON) on stdout |
| `bdrive hooks [install\|uninstall]` | Register turn-boundary sync hooks in each agent platform's user config (Claude Code, Codex, Gemini CLI, Hermes) — pull each turn, push after edits, session-note stamping, agent-read tracking. Once per machine, covering every session; run automatically by `bdrive init`; idempotent (`--agent` overrides detection) |
| `bdrive read-log [folder]` | Hook plumbing: queue agent file reads from a hook event (JSON on stdin) for the hub's read heatmap — native reads, grep matches, and files named in shell commands; drained on the next sync. Registered by `bdrive hooks install` |
| `bdrive status [folder]` | Projects, daemon state, two separate change counts — `pending` (journalled, not yet pushed) and `local` (on disk, not yet scanned — what a stopped daemon leaves invisible) — and any synced files that looked like they held credentials when they last changed. Pure local read: no ops, no journal writes, no network |
+15 -9
View File
@@ -74,16 +74,22 @@ list in .bdrive/config.json is never pruned against either.`,
return err
}
defer closeSession(sess)
if cmd.Flags().Changed("note") {
// Persist the note so the daemon's own scans stamp it too —
// history then links every change from this working session
// to its context, not just the ones this invocation catches.
// An explicit empty --note clears it. Expires after --note-ttl.
if err := sess.Store.SaveNote(note, noteTTL); err != nil {
return err
}
sess.Note = note
// Persist the note so the daemon's own scans stamp it too —
// history then links every change from this working session
// to its context, not just the ones this invocation catches.
// Expires after --note-ttl. Unconditional because an explicit
// `bdrive sync` is a human act: whatever note the last agent
// session left stops applying here, and empty text clears
// (SaveNote -> ClearNote), so one call both sets and clears.
// Clearing the *store* is what leaves this cycle unstamped —
// scan falls back to LoadNote whenever Session.Note is empty.
// The --hook branch below has its own SaveNote and keeps the
// TTL, which is what the TTL is for: the daemon's own scans
// inside an agent session.
if err := sess.Store.SaveNote(note, noteTTL); err != nil {
return err
}
sess.Note = note
sess.Prune = prune
sess.OnProgress = progressReporter()
res, err := sess.Cycle(cmd.Context())
+73
View File
@@ -2,6 +2,7 @@ package main
import (
"bytes"
"context"
"os"
"path/filepath"
"strings"
@@ -115,3 +116,75 @@ func TestSyncNoteCannotForgeASession(t *testing.T) {
}
}
}
// An explicit `bdrive sync` is a human act, so it must not inherit the note
// the last agent session left behind — otherwise a hand edit lands in history
// (and in that session's group, and in its rollback) attributed to the agent.
// The daemon's own scans still inherit it: that is what the TTL is for.
func TestPlainSyncClearsThePreviousSessionNote(t *testing.T) {
folder, proj := stampFixture(t)
dev := thisDevice(t)
runSync := func(args ...string) {
t.Helper()
c := syncCmd()
c.SetOut(&bytes.Buffer{})
c.SetArgs(append([]string{folder}, args...))
if err := c.Execute(); err != nil {
t.Fatalf("sync %v: %v", args, err)
}
}
noteFor := func(path string) string {
t.Helper()
for _, op := range journalOps(t, proj.ID, dev) {
if op.Path == path {
return op.Note
}
}
t.Fatalf("no op for %q", path)
return ""
}
// An agent session stamps its note.
runSync("--note", "s1")
if got := noteFor("a.md"); got != "s1" {
t.Fatalf("a.md Note = %q, want s1", got)
}
// A hand edit committed by a plain `bdrive sync` is unattributed, and the
// stored note is gone — clearing the store is what makes it unstamped,
// since scan falls back to LoadNote whenever Session.Note is empty.
if err := os.WriteFile(filepath.Join(folder, "b.md"), []byte("by hand\n"), 0o644); err != nil {
t.Fatal(err)
}
runSync()
if got := noteFor("b.md"); got != "" {
t.Errorf("plain sync stamped the previous session's note: b.md Note = %q", got)
}
sess, _, err := openSession(context.Background(), folder, true)
if err != nil {
t.Fatal(err)
}
if got := sess.Store.LoadNote(); got != "" {
t.Errorf("stored note = %q after a plain sync, want it cleared", got)
}
closeSession(sess)
// The daemon's path (Session.Cycle direct, no CLI) still inherits a live
// note inside its TTL.
runSync("--note", "s2")
if err := os.WriteFile(filepath.Join(folder, "c.md"), []byte("daemon tick\n"), 0o644); err != nil {
t.Fatal(err)
}
sess, _, err = openSession(context.Background(), folder, true)
if err != nil {
t.Fatal(err)
}
if _, err := sess.Cycle(context.Background()); err != nil {
t.Fatal(err)
}
closeSession(sess)
if got := noteFor("c.md"); got != "s2" {
t.Errorf("daemon-committed change lost the live note: c.md Note = %q, want s2", got)
}
}
+6 -1
View File
@@ -22,7 +22,7 @@ One binary, `bdrive` — the CLI, the sync daemon, and the web server.
| `bdrive forget <path>...` | Stop syncing a path and remove it from the hub. Adds the rule to `.bdriveignore` (which syncs) and prunes in one step. Local files are never touched, here or on teammates' devices |
| `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` (the hub's Share dialog can also set an expiry on an existing link). Refuses a file whose first 1 MiB holds credential-shaped strings — `--force` shares it anyway |
| `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. `--prune` also removes from the hub what `.bdriveignore` now excludes (files stay on disk everywhere). `--hook <label>` is agent-hook plumbing: it also reports the files teammates changed since the agent's last turn |
| `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, and a plain `bdrive sync` with no `--note` clears it. `--prune` also removes from the hub what `.bdriveignore` now excludes (files stay on disk everywhere). `--hook <label>` is agent-hook plumbing: it also reports the files teammates changed since the agent's last turn |
| `bdrive hooks [install\|uninstall]` | Register turn-boundary sync hooks in each detected agent platform's user config — once per machine, covering every folder. Run automatically by `bdrive init`; idempotent; `--agent` overrides detection. `uninstall` removes only BearDrive's own hook entries |
| `bdrive read-log [folder]` | Hook plumbing: queue agent file reads for the hub's read heatmap. Registered by `bdrive hooks install` |
| `bdrive status [folder]` | Projects, daemon state, pending changes, and any synced files that looked like they held credentials when they last changed |
@@ -95,6 +95,11 @@ Stamps session context — an agent session id, say — onto changes. It shows u
`bdrive log` and hub history, and keeps applying to daemon-committed changes
until `--note-ttl` expires.
A plain `bdrive sync` with no `--note` **clears** the note: an explicit sync is a
human act, so the edit you just made by hand is not filed under the last agent
session. Hook- and daemon-driven syncs are unaffected and keep the note until the
TTL expires — that is what the TTL is for.
### `bdrive restore` — undoing a change
An agent rewrote a file you liked. Put the old bytes back: