fix(hooks): every mount gets its own hub link, so agents stop mixing projects up (#105)

A session whose root holds two connected folders got exactly one project's
base URL in its turn context: `bdrive sync --hook` emitted for the first
mount and stopped. Agents then hung the other project's paths on that URL,
producing links to project B carrying project A's path — confidently wrong,
and 404 on arrival.

The context now carries every mount as a `prefix → URL` pair, where the
prefix is the mount's path as the agent sees it from the session's folder.
A session started INSIDE a mount has no prefix to strip, so its own subpath
is baked into the base URL instead — the other half of the same bug, which
made every link from a subdirectory session miss its leading segments.

The formula also now states outright that the URL path is folder-relative
and that a path matching no listed folder is not synced and must not be
linked at all. Encoding is per-segment, `/` left literal (encodePathSegments).

Two more fell out along the way:

- `runHookSync` returns nil on every path by design ("a hook must never
  fail the turn"), so the caller's `err == nil` guard could never be false.
  A first mount that emitted nothing — non-hub remote, session open error —
  still consumed the one emission, and NO mount got a link. It now reports
  whether it produced a URL.
- stdin was read once per mount, so with several mounts only the first got
  its session note stamped. The event JSON is read once per run now.

Single-mount output is unchanged apart from the folder-relative sentence.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Snow Lee (Sungwon)
2026-07-31 15:15:02 +09:00
committed by GitHub
co-authored by Claude Opus 5
parent 1c703c95f8
commit a84fe444f5
5 changed files with 238 additions and 34 deletions
+7 -1
View File
@@ -78,7 +78,13 @@ onboarding runbook every agent follows. `bdrive init` registers the hooks in
each platform's USER config (`~/.claude/settings.json` and friends), once per
machine: a blocking pull at UserPromptSubmit — which, via `bdrive sync
--hook`, also injects the project's gated-link formula as additionalContext
so agents append `path` [🔗](hub link) to every synced path they mention — an
so agents append `path` [🔗](hub link) to every synced path they mention. One
run can cover several mounts (`syncTargets`) and the hook's stdout contract is
a single JSON object, so the formula carries **every** mount as a `prefix →
URL` pair — the prefix being the mount's path as the agent sees it from the
session's folder, or an empty prefix with the session's own subpath baked into
the URL when the session runs inside the mount; emitting only the first mount
hung one project's paths on another project's base URL. Then an
async push on PostToolUse Write/Edit, and `bdrive read-log` on
Read/Grep/Bash for the read heatmap. The inline hook commands
`internal/agenthooks` writes must stay a fast no-op outside BearDrive folders
+9 -6
View File
@@ -92,19 +92,22 @@ list in .bdrive/config.json is never pruned against either.`,
if hookLabel != "" {
// Agent-hook mode: event JSON on stdin, silent best-effort
// sync, link-formula context on stdout. Never fails. Only the
// first mount emits context — the JSON contract is one object,
// so a repo with several mounts links through the first.
emit := true
// sync, link-formula context on stdout. Never fails. Every
// mount contributes its own prefix→URL pair; the JSON
// contract is one object, so they are emitted together after
// the loop.
sessionID := hookSessionID(cmd)
var links []hookLink
for _, target := range targets {
proj, ok, err := config.LoadProject(target)
if err != nil || !ok || syncBlocked(proj) != "" {
continue
}
if err := runHookSync(cmd, target, hookLabel, emit); err == nil && emit {
emit = false
if base, ok := runHookSync(cmd, target, sessionID, hookLabel); ok {
links = append(links, hookLinkFor(folder, target, base))
}
}
emitHookContext(cmd, links)
return nil
}
if len(targets) == 0 {
+100 -26
View File
@@ -4,6 +4,8 @@ import (
"encoding/json"
"fmt"
"io"
"path/filepath"
"strings"
"time"
"github.com/spf13/cobra"
@@ -17,6 +19,12 @@ import (
// project's gated-link formula as additionalContext, so the agent can
// append a hub link to any synced file path it mentions.
//
// One run can cover several mounts (a repo root whose wiki/ and docs/ are
// separate projects, see syncTargets), and the hook's stdout contract is a
// single JSON object — so every mount's link goes into one context, keyed
// by the path prefix an agent sees. Emitting only the first mount's URL
// made agents hang one project's base URL on another project's paths.
//
// Everything is best-effort: a hook must never fail the turn, so every
// error path is a silent, successful exit.
@@ -24,25 +32,35 @@ import (
// scans keep stamping this session's changes for a while.
const hookNoteTTL = 30 * time.Minute
// emitContext is false for every mount after the first in one hook run: the
// hook's stdout contract is a single JSON object.
func runHookSync(cmd *cobra.Command, folder, label string, emitContext bool) error {
// The platform pipes its event JSON on stdin; the session id is all we
// need from it here.
// hookLink pairs the path prefix an agent writes with the hub URL that
// prefix maps to.
type hookLink struct {
prefix string // "wiki/", or "" when the hook ran at or inside the mount
base string // https://hub/<project-id>[/<the run folder's subpath>]
}
// hookSessionID reads the platform's event JSON from stdin — once per run,
// since stdin can only be consumed once and the sync loop may cover several
// mounts.
func hookSessionID(cmd *cobra.Command) string {
data, _ := io.ReadAll(io.LimitReader(cmd.InOrStdin(), 1<<20))
var event struct {
SessionID string `json:"session_id"`
}
_ = json.Unmarshal(data, &event) // malformed input: just sync
return event.SessionID
}
sess, proj, err := openSession(cmd.Context(), folder, true)
// runHookSync syncs one mount and reports its hub base URL, if it has one.
func runHookSync(cmd *cobra.Command, target, sessionID, label string) (string, bool) {
sess, proj, err := openSession(cmd.Context(), target, true)
if err != nil {
return nil // not a mount / no session: fast no-op
return "", false // not a mount / no session: fast no-op
}
defer closeSession(sess)
if event.SessionID != "" {
note := label + " session " + event.SessionID
if sessionID != "" {
note := label + " session " + sessionID
if err := sess.Store.SaveNote(note, hookNoteTTL); err == nil {
sess.Note = note
}
@@ -51,34 +69,90 @@ func runHookSync(cmd *cobra.Command, folder, label string, emitContext bool) err
// The pull. Offline is fine — the link formula below is still valid
// for teammates who are online.
if _, err := sess.Cycle(cmd.Context()); err != nil {
return nil // never break the turn
}
if !emitContext {
return nil
return "", false // never break the turn
}
server, projectID, err := splitHubRemote(proj.Remote)
if err != nil {
return nil // non-hub remote: nothing to link to
return "", false // non-hub remote: nothing to link to
}
return server + "/" + projectID, true
}
// hookLinkFor places one mount relative to the folder the hook ran in.
// Agents write paths as they see them from that folder, so the mount's
// position there is what turns a path into a URL: a mount BELOW the folder
// contributes a prefix to strip, while a run INSIDE a mount contributes a
// subpath that belongs in the base instead (there is no prefix to strip —
// every path the agent writes is already inside the mount).
func hookLinkFor(folder, target, base string) hookLink {
// resolvePath: registry paths and the run folder can name the same
// directory through different symlinks (macOS /tmp).
rel, err := filepath.Rel(resolvePath(folder), resolvePath(target))
if err != nil {
return hookLink{base: base}
}
rel = filepath.ToSlash(rel)
switch {
case rel == ".":
return hookLink{base: base}
case rel == ".." || strings.HasPrefix(rel, "../"):
sub, err := filepath.Rel(resolvePath(target), resolvePath(folder))
if err != nil {
return hookLink{base: base}
}
return hookLink{base: base + "/" + encodePathSegments(filepath.ToSlash(sub))}
default:
return hookLink{prefix: rel + "/", base: base}
}
}
// emitHookContext writes the turn's additionalContext — one JSON object, no
// matter how many mounts the run covered.
func emitHookContext(cmd *cobra.Command, links []hookLink) {
if len(links) == 0 {
return
}
// Shared tail: what the links mean and when NOT to use them.
const tail = "These links require hub sign-in + project membership, so they are safe to paste anywhere internal. " +
"Only link files that actually sync (inside the shared scope, not ignored); keep paths inside code blocks or commands plain; give a raw URL only when the user needs to paste it outside this conversation. " +
"`bdrive share <file>` mints PUBLIC no-account links — use it only when the user explicitly asks for a public link."
var context string
if len(links) == 1 && links[0].prefix == "" {
// The common case: one project, paths already relative to its root.
// Kept as short as possible — this is paid on every turn.
b := links[0].base
context = fmt.Sprintf(
"beardrive: this folder syncs to %s (the project's hub page; files are at %s/<url-encoded path>). "+
"Link convention: whenever you mention a synced file's path in prose, append its gated hub link on an emoji, formatted exactly as: `<path>` [🔗](%s/<url-encoded path>) — the path stays plain text, the hyperlink goes on the emoji only. "+
"The URL path is the file's path relative to this folder, with each segment percent-encoded and the `/` separators left literal. "+
tail, b, b, b)
} else {
parts := make([]string, len(links))
for i, l := range links {
p := l.prefix
if p == "" {
p = "./"
}
parts[i] = fmt.Sprintf("`%s` → %s", p, l.base)
}
context = fmt.Sprintf(
"beardrive: hub URLs for the synced folders here — %s. "+
"Link convention: whenever you mention a synced file's path in prose, append its gated hub link on an emoji, formatted exactly as: `<path>` [🔗](<that folder's URL>/<url-encoded path within it>) — the path stays plain text, the hyperlink goes on the emoji only. "+
"Pick the folder whose prefix above matches the path longest, strip that prefix, then percent-encode each remaining segment and leave the `/` separators literal. A path matching none of these folders is not synced — do not link it, and never hang one folder's path on another folder's URL. "+
tail, strings.Join(parts, ", "))
}
base := server + "/" + projectID
out := map[string]any{
"hookSpecificOutput": map[string]any{
"hookEventName": "UserPromptSubmit",
"additionalContext": fmt.Sprintf(
"beardrive: this folder syncs to %s (the project's hub page; files are at %s/<url-encoded path>). "+
"Link convention: whenever you mention a synced file's path in prose, append its gated hub link on an emoji, formatted exactly as: `<path>` [🔗](%s/<url-encoded path>) — the path stays plain text, the hyperlink goes on the emoji only. "+
"These links require hub sign-in + project membership, so they are safe to paste anywhere internal. "+
"Only link files that actually sync (inside the shared scope, not ignored); keep paths inside code blocks or commands plain; give a raw URL only when the user needs to paste it outside this conversation. "+
"`bdrive share <file>` mints PUBLIC no-account links — use it only when the user explicitly asks for a public link.",
base, base, base),
"hookEventName": "UserPromptSubmit",
"additionalContext": context,
},
}
enc, err := json.Marshal(out)
if err != nil {
return nil
return
}
fmt.Fprintln(cmd.OutOrStdout(), string(enc))
return nil
}
+119
View File
@@ -2,6 +2,7 @@ package main
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
@@ -150,6 +151,124 @@ func TestSyncHookModeNoOps(t *testing.T) {
}
}
// mountAt creates a project folder under parent and enrolls it on this
// device, as `bdrive init` would.
func mountAt(t *testing.T, parent, name, remote string) config.Project {
t.Helper()
dir := filepath.Join(parent, name)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
proj, err := config.SaveProject(dir, config.Project{Volume: name, Remote: remote})
if err != nil {
t.Fatal(err)
}
if _, _, err := config.ResolveMount(dir); err != nil {
t.Fatal(err)
}
return proj
}
func runHook(t *testing.T, folder string) string {
t.Helper()
c := syncCmd()
var out bytes.Buffer
c.SetOut(&out)
c.SetIn(strings.NewReader(`{"session_id":"sess-42"}`))
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()
}
// A session at a root whose subfolders are separate projects must get EVERY
// project's URL, each keyed by the prefix the agent sees — emitting only the
// first mount's base made agents hang one project's paths on another
// project's URL.
func TestSyncHookModeMultipleMounts(t *testing.T) {
t.Setenv("BDRIVE_HOME", t.TempDir())
root := t.TempDir()
root, _ = filepath.EvalSymlinks(root)
a := mountAt(t, root, "projA", "https://hub.example.com/p/p-aaaaaaaa")
b := mountAt(t, root, "projB", "https://hub.example.com/p/p-bbbbbbbb")
got := runHook(t, root)
// One JSON object: the hook's stdout contract.
if n := strings.Count(strings.TrimSpace(got), "\n"); n != 0 {
t.Fatalf("hook emitted %d objects, want 1:\n%s", n+1, got)
}
for _, want := range []string{
"https://hub.example.com/p-aaaaaaaa",
"https://hub.example.com/p-bbbbbbbb",
"`projA/`",
"`projB/`",
"matches the path longest", // how to pick between them
"do not link it", // a path in neither is not synced
} {
if !strings.Contains(got, want) {
t.Errorf("hook output missing %q:\n%s", want, got)
}
}
// stdin is consumed once, so the note must still reach every mount.
for _, proj := range []config.Project{a, b} {
vdir, err := config.VolumeDir(proj.ID)
if err != nil {
t.Fatal(err)
}
st, err := store.Open(vdir)
if err != nil {
t.Fatal(err)
}
if note := st.LoadNote(); note != "claude-code session sess-42" {
t.Errorf("%s: note = %q, want the stamped session", proj.Volume, note)
}
}
}
// A mount that has no hub URL must not swallow the context for the mounts
// that do — the old "first mount emits" guard could never detect a mount
// that emitted nothing, because hook mode never returns an error.
func TestSyncHookModeSkipsNonHubMount(t *testing.T) {
t.Setenv("BDRIVE_HOME", t.TempDir())
root := t.TempDir()
root, _ = filepath.EvalSymlinks(root)
mountAt(t, root, "a-plain", "file://"+t.TempDir()) // sorts first, no hub
mountAt(t, root, "b-hub", "https://hub.example.com/p/p-bbbbbbbb")
got := runHook(t, root)
if !strings.Contains(got, "https://hub.example.com/p-bbbbbbbb") {
t.Errorf("hub mount lost its link behind a non-hub mount:\n%s", got)
}
if !strings.Contains(got, "`b-hub/`") {
t.Errorf("hub mount missing its prefix:\n%s", got)
}
if strings.Contains(got, "a-plain") {
t.Errorf("non-hub mount has no URL and must not be listed:\n%s", got)
}
}
// A session started inside a mount writes paths relative to its own
// directory, so that subpath belongs in the base URL — there is no prefix
// for the agent to strip.
func TestSyncHookModeInsideMount(t *testing.T) {
t.Setenv("BDRIVE_HOME", t.TempDir())
root := t.TempDir()
root, _ = filepath.EvalSymlinks(root)
mountAt(t, root, "wiki", "https://hub.example.com/p/p-12345678")
sub := filepath.Join(root, "wiki", "docs", "notes")
if err := os.MkdirAll(sub, 0o755); err != nil {
t.Fatal(err)
}
got := runHook(t, sub)
if !strings.Contains(got, "https://hub.example.com/p-12345678/docs/notes") {
t.Errorf("base URL missing the session's subpath (and `/` must stay literal):\n%s", got)
}
}
// 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.
@@ -31,7 +31,9 @@ With no argument, `bdrive url` gives the project home.
:::tip[Agents do this automatically]
The sync hook `bdrive init` registers injects the project's
gated-link formula into the agent's context, so a connected agent appends
`path` [🔗](link) to every synced path it mentions — without being asked. See
`path` [🔗](link) to every synced path it mentions — without being asked. If a
session's root holds several connected folders, each one's URL goes in, so a
path is always linked to the project it actually lives in. See
[Set up with your agent](/start/setup/).
:::