From 06bedabfd60dec7be74a1acb926df63474cfd048 Mon Sep 17 00:00:00 2001 From: Snow Lee Date: Sat, 11 Jul 2026 10:56:39 -0700 Subject: [PATCH 1/3] feat(web): selectable folders with listing + per-folder change feed Folders in the file tree are now selectable: the row opens the folder (chevron still folds) and the main pane lists its contents, with clickable breadcrumbs, folder URLs/deep links, palette entries, and uploads landing inside the selected folder. In hub mode the listing includes a Recent changes feed for the subtree; the history API now classifies puts as add|edit (replayed over all ops before filtering) so entries are badged added/edited/deleted, and op notes render under entries with URLs linkified. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01P5cxPQdSGJnjXCYY9GeWXt --- internal/webapp/history.go | 22 ++- internal/webapp/history_test.go | 8 + internal/webapp/static/app.js | 244 +++++++++++++++++++++++++----- internal/webapp/static/index.html | 1 + internal/webapp/static/style.css | 35 ++++- 5 files changed, 268 insertions(+), 42 deletions(-) diff --git a/internal/webapp/history.go b/internal/webapp/history.go index 04f84c8..1350460 100644 --- a/internal/webapp/history.go +++ b/internal/webapp/history.go @@ -20,7 +20,7 @@ import ( // HistoryEntry is one change as the history API reports it. type HistoryEntry struct { Time string `json:"time"` - Kind string `json:"kind"` // put | delete + Kind string `json:"kind"` // add | edit | delete Path string `json:"path"` Size int64 `json:"size,omitempty"` Blob string `json:"blob,omitempty"` // sha256; fetch via the blob endpoint @@ -59,6 +59,24 @@ func (s *Server) handleHistory(v *volume, w http.ResponseWriter, r *http.Request return } journal.Sort(all) + // A put is an "add" when the path didn't exist just before it (first + // version, or first after a delete), an "edit" otherwise. Existence is + // replayed over ALL ops in journal order, before any path/prefix filter, + // so a filtered view classifies the same as the full feed. + kinds := make([]string, len(all)) + exists := make(map[string]bool, len(all)) + for i, op := range all { + switch { + case op.Kind == journal.KindDelete: + kinds[i] = "delete" + exists[op.Path] = false + case exists[op.Path]: + kinds[i] = "edit" + default: + kinds[i] = "add" + exists[op.Path] = true + } + } entries := make([]HistoryEntry, 0, n) for i := len(all) - 1; i >= 0 && len(entries) < n; i-- { // newest first op := all[i] @@ -73,7 +91,7 @@ func (s *Server) handleHistory(v *volume, w http.ResponseWriter, r *http.Request dev = DeviceInfo{ID: op.Device, Name: op.DeviceName} } entries = append(entries, HistoryEntry{ - Time: op.Time.UTC().Format("2006-01-02T15:04:05Z"), Kind: op.Kind, + Time: op.Time.UTC().Format("2006-01-02T15:04:05Z"), Kind: kinds[i], Path: op.Path, Size: op.Size, Blob: op.Blob, User: op.User, UserName: op.UserName, Author: op.Author, Device: dev, Note: op.Note, diff --git a/internal/webapp/history_test.go b/internal/webapp/history_test.go index b52265e..570997d 100644 --- a/internal/webapp/history_test.go +++ b/internal/webapp/history_test.go @@ -72,6 +72,10 @@ func TestHistoryAPI(t *testing.T) { if newest.Size != int64(len("v2 longer")) || oldest.Size != int64(len("v1")) { t.Fatalf("order wrong: %+v", out.Entries) } + // puts are classified: first version = add, later versions = edit + if oldest.Kind != "add" || newest.Kind != "edit" { + t.Fatalf("kinds = %q, %q; want add, edit", oldest.Kind, newest.Kind) + } if newest.User != "alice@x.io" || newest.UserName != "Alice" { t.Fatalf("user = %+v", newest) } @@ -94,6 +98,10 @@ func TestHistoryAPI(t *testing.T) { if out.Entries[0].Kind != "delete" || out.Entries[0].Path != "notes/other.md" { t.Fatalf("newest notes/ entry = %+v, want the delete", out.Entries[0]) } + // the put that created other.md is an add, even in the filtered view + if out.Entries[1].Kind != "add" || out.Entries[1].Path != "notes/other.md" { + t.Fatalf("entry before the delete = %+v, want other.md's add", out.Entries[1]) + } // a device the registry never saw falls back to the op's own info if out.Entries[0].Device.Name != "dev2" { t.Fatalf("unknown device fallback = %+v", out.Entries[0].Device) diff --git a/internal/webapp/static/app.js b/internal/webapp/static/app.js index 3e3c1d7..58c9797 100644 --- a/internal/webapp/static/app.js +++ b/internal/webapp/static/app.js @@ -3,6 +3,7 @@ const $ = (id) => document.getElementById(id); let flatFiles = []; // [{path, name}] for wikilink resolution +let dirIndex = new Map(); // dir path → tree node, for folder listings let currentPath = null; let expanded = new Set(); // dir paths that are open (folders start closed) let treeFirstLoad = true; // apply the "lone root folder opens" rule once per project @@ -92,7 +93,7 @@ async function boot() { initUpload(); await refreshTree(); const { path } = parseRoute(); - if (path) openFile(path); + if (path) openPath(path); } setInterval(refreshTree, 15000); // pick up synced changes } @@ -161,7 +162,7 @@ function selectProject(p, path) { initUpload(); initHistory(); updateShareButton(); - refreshTree().then(() => { if (path) openFile(path); }); + refreshTree().then(() => { if (path) openPath(path); }); if (!path) pushURL("/" + p.id); } @@ -676,6 +677,7 @@ async function refreshTree() { root = await getJSON(apiBase + "tree"); } catch { return; } // keep the last good tree flatFiles = []; + dirIndex = new Map(); const kids = root.children || []; // First render of a project's tree: every folder starts closed, except a // lone root folder — opening it spares the user a single shut folder. @@ -688,6 +690,10 @@ async function refreshTree() { nav.innerHTML = ""; nav.appendChild(renderChildren(kids)); markActive(); + // A folder listing shows live tree data — keep it in step with the tree. + if (currentPath && dirIndex.has(currentPath) && $("content").querySelector(".dirlist")) { + renderFolderListing(currentPath); + } } function renderChildren(children) { @@ -719,6 +725,7 @@ function renderNode(n) { row.onkeydown = (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); row.click(); } }; li.appendChild(row); if (n.dir) { + dirIndex.set(n.path, n); if (serverConfig.mode === "hub") { const hist = document.createElement("span"); hist.className = "dir-history"; @@ -731,12 +738,21 @@ function renderNode(n) { const open = expanded.has(n.path); if (!open) li.classList.add("collapsed"); row.setAttribute("aria-expanded", String(open)); - row.onclick = () => { + const toggle = () => { li.classList.toggle("collapsed"); const isCollapsed = li.classList.contains("collapsed"); isCollapsed ? expanded.delete(n.path) : expanded.add(n.path); row.setAttribute("aria-expanded", String(!isCollapsed)); }; + // The chevron only folds; the row selects the folder (opens it in the + // tree and lists its contents in the main pane). Clicking the folder + // whose listing is already showing folds/unfolds it, like a plain tree; + // from any other view (file, history) it brings the listing back. + chev.onclick = (e) => { e.stopPropagation(); toggle(); }; + row.onclick = () => { + if (currentPath === n.path && $("content").querySelector(".dirlist")) { toggle(); return; } + openFolder(n.path); + }; } else { flatFiles.push({ path: n.path, name: n.name }); row.onclick = () => openFile(n.path); @@ -780,6 +796,122 @@ function revealInTree(p) { if (row) row.scrollIntoView({ block: "center" }); } +/* ---- breadcrumb ---- + Every ancestor segment is a link to that folder's listing; the last + segment is the current page. */ +function setCrumb(p) { + const c = $("crumb"); + c.innerHTML = ""; + const parts = p.split("/"); + let acc = ""; + parts.forEach((seg, i) => { + acc = acc ? acc + "/" + seg : seg; + if (i) el(c, "span", "crumb-sep", "/"); + const last = i === parts.length - 1; + const s = el(c, "span", last ? null : "crumb-seg", seg); + if (!last) { + const target = acc; + s.title = target; + s.onclick = () => { if (dirIndex.has(target)) openFolder(target); }; + } + }); +} + +/* ---- folder pane ---- */ + +/* Open a path of either kind — a route or link doesn't know which it is + until the tree has loaded. */ +function openPath(p) { + if (dirIndex.has(p)) openFolder(p); + else openFile(p); +} + +/* Selecting a folder: unfold it in the tree, highlight it, and list what's + inside it in the main pane. */ +function openFolder(p) { + if (!dirIndex.has(p)) return; + currentPath = p; + syncURL(p); + expandTo(p); + expanded.add(p); // the selected folder itself opens, not just its ancestors + applyTreeExpansion(); + markActive(); + const row = document.querySelector(`#tree .row[data-path="${CSS.escape(p)}"]`); + if (row) row.scrollIntoView({ block: "nearest" }); + closeSidebarOnMobile(); + setCrumb(p); + $("meta").textContent = ""; + $("download").hidden = true; + $("more-btn").hidden = !(serverConfig.mode === "hub" && currentProject); + updateShareButton(); + renderFolderListing(p); +} + +function renderFolderListing(p) { + const node = dirIndex.get(p); + if (!node) return; + const content = $("content"); + content.className = "view"; + content.innerHTML = ""; + const wrap = el(content, "div", "dirlist"); + const head = el(wrap, "h1", "dl-title"); + const hicon = el(head, "span", "dl-title-icon"); + hicon.innerHTML = svgIcon("folder"); + el(head, "span", null, node.name); + const kids = (node.children || []).slice() + .sort((a, b) => (b.dir - a.dir) || a.name.localeCompare(b.name)); + const dirs = kids.filter((c) => c.dir).length; + const files = kids.length - dirs; + const counts = []; + if (dirs) counts.push(dirs + (dirs === 1 ? " folder" : " folders")); + if (files) counts.push(files + (files === 1 ? " file" : " files")); + el(wrap, "p", "dl-sub", counts.join(" · ") || "Empty folder"); + if (!kids.length) { + el(wrap, "div", "dl-empty", "Nothing in this folder yet."); + renderFolderHistory(wrap, p); + return; + } + const list = el(wrap, "div", "dl-items"); + for (const c of kids) { + const row = el(list, "div", "dl-row"); + row.tabIndex = 0; + row.setAttribute("role", "button"); + row.title = c.path; + const icon = el(row, "span", "ticon"); + icon.innerHTML = svgIcon(c.dir ? "folder" : "doc"); + el(row, "span", "dl-name", c.name); + let meta = ""; + if (c.dir) { + const n = (c.children || []).length; + meta = n + (n === 1 ? " item" : " items"); + } else { + meta = [c.size ? humanSize(c.size) : "", c.time ? new Date(c.time).toLocaleDateString() : ""] + .filter(Boolean).join(" · "); + } + el(row, "span", "dl-meta", meta); + row.onclick = () => openPath(c.path); + row.onkeydown = (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); row.click(); } }; + } + renderFolderHistory(wrap, p); +} + +/* The folder's change feed, straight from the journals: files added, edited, + and deleted anywhere under it, newest first. Hub-only — a plain-folder + viewer has no journals to read. */ +function renderFolderHistory(wrap, p) { + if (!(serverConfig.mode === "hub" && currentProject)) return; + const sec = el(wrap, "div", "dl-history"); + getJSON(apiBase + "history?prefix=" + encodeURIComponent(p + "/") + "&n=20").then((out) => { + const entries = out.entries || []; + if (!entries.length) return sec.remove(); + el(sec, "h3", "dl-h3", "Recent changes"); + const list = el(sec, "div", "history dl-hlist"); + for (const e of entries) list.appendChild(historyEntryRow(e)); + const more = el(sec, "button", "ai-btn dl-more", "Full history"); + more.onclick = () => showHistory({ prefix: p + "/" }); + }).catch(() => sec.remove()); +} + /* ---- file pane ---- */ async function openFile(p) { currentPath = p; @@ -787,7 +919,7 @@ async function openFile(p) { markActive(); revealInTree(p); closeSidebarOnMobile(); - $("crumb").textContent = p.split("/").join(" / "); + setCrumb(p); updateShareButton(); const dl = $("download"); dl.href = apiBase + "download?path=" + encodeURIComponent(p); @@ -916,7 +1048,8 @@ function join(dir, rel) { (rendered, sandboxed), no account needed. Always the latest content. */ function updateShareButton() { const btn = $("share-btn"); - btn.hidden = !(serverConfig.mode === "hub" && currentProject && currentPath); + // Shares are per-file; a selected folder has nothing to mint. + btn.hidden = !(serverConfig.mode === "hub" && currentProject && currentPath && !dirIndex.has(currentPath)); btn.onclick = async () => { try { const r = await fetch(apiBase + "shares", { @@ -941,7 +1074,11 @@ function updateShareButton() { function initHistory() { const btn = $("history-btn"); btn.hidden = !(serverConfig.mode === "hub" && currentProjectOrNull()); - btn.onclick = () => showHistory(currentPath ? { path: currentPath } : { prefix: "" }); + btn.onclick = () => { + if (!currentPath) return showHistory({ prefix: "" }); + if (dirIndex.has(currentPath)) return showHistory({ prefix: currentPath + "/" }); + showHistory({ path: currentPath }); + }; } function currentProjectOrNull() { @@ -972,39 +1109,63 @@ async function showHistory(q) { if (!out.entries || out.entries.length === 0) { wrap.innerHTML = `
No history yet.
`; } - for (const e of out.entries || []) { - const row = document.createElement("div"); - row.className = "hentry " + e.kind; - const who = e.user_name ? `${e.user_name} <${e.user}>` : (e.user || e.author || "unknown"); - const dev = [e.device.name || e.device.id, e.device.os, e.device.ip].filter(Boolean).join(" · "); - const when = new Date(e.time).toLocaleString(); - row.innerHTML = - `
` + - `
`; - row.querySelector(".hkind").innerHTML = svgIcon(e.kind === "delete" ? "x" : "dot"); - row.querySelector(".hpath").textContent = e.path; - row.querySelector(".htime").textContent = when; - row.querySelector(".hwho").textContent = who; - row.querySelector(".hdev").textContent = dev; - row.querySelector(".hsize").textContent = e.size ? humanSize(e.size) : ""; - if (e.kind !== "delete" && e.blob) { - const view = document.createElement("a"); - view.textContent = "view"; - view.href = apiBase + "blob?sha=" + e.blob + "&name=" + encodeURIComponent(e.path); - view.target = "_blank"; - const dl = document.createElement("a"); - dl.textContent = "download"; - dl.href = view.href + "&download=1"; - dl.setAttribute("download", e.path.split("/").pop()); - row.querySelector(".hact").append(view, " ", dl); - } - const p = e.path; - row.querySelector(".hpath").onclick = () => showHistory({ path: p }); - wrap.appendChild(row); - } + for (const e of out.entries || []) wrap.appendChild(historyEntryRow(e)); content.appendChild(wrap); } +/* One change as a row: what happened (added / edited / deleted), to which + file, by whom, from where — with view/download of that exact version. */ +const KIND_ICON = { add: "plus", edit: "edit", delete: "x" }; +const KIND_LABEL = { add: "added", edit: "edited", delete: "deleted" }; +function historyEntryRow(e) { + const kind = e.kind === "put" ? "edit" : e.kind; // older servers report raw "put" ops + const row = document.createElement("div"); + row.className = "hentry " + kind; + const who = e.user_name ? `${e.user_name} <${e.user}>` : (e.user || e.author || "unknown"); + const dev = [e.device.name || e.device.id, e.device.os, e.device.ip].filter(Boolean).join(" · "); + const when = new Date(e.time).toLocaleString(); + row.innerHTML = + `
` + + `
`; + row.querySelector(".hkind").innerHTML = svgIcon(KIND_ICON[kind] || "dot"); + row.querySelector(".hpath").textContent = e.path; + row.querySelector(".htag").textContent = KIND_LABEL[kind] || kind; + row.querySelector(".htime").textContent = when; + row.querySelector(".hwho").textContent = who; + row.querySelector(".hdev").textContent = dev; + row.querySelector(".hsize").textContent = e.size ? humanSize(e.size) : ""; + if (kind !== "delete" && e.blob) { + const view = document.createElement("a"); + view.textContent = "view"; + view.href = apiBase + "blob?sha=" + e.blob + "&name=" + encodeURIComponent(e.path); + view.target = "_blank"; + const dl = document.createElement("a"); + dl.textContent = "download"; + dl.href = view.href + "&download=1"; + dl.setAttribute("download", e.path.split("/").pop()); + row.querySelector(".hact").append(view, " ", dl); + } + if (e.note) { + const note = document.createElement("div"); + note.className = "hnote"; + // Linkify http(s) URLs (e.g. a Claude session link); everything else + // stays plain text — notes are user/agent input, never markup. + for (const tok of e.note.split(/(https?:\/\/\S+)/)) { + if (/^https?:\/\//.test(tok)) { + const a = document.createElement("a"); + a.href = tok; a.textContent = tok; a.target = "_blank"; a.rel = "noopener"; + note.appendChild(a); + } else if (tok) { + note.append(tok); + } + } + row.appendChild(note); + } + const p = e.path; + row.querySelector(".hpath").onclick = () => showHistory({ path: p }); + return row; +} + function humanSize(n) { if (n < 1024) return n + " B"; const units = ["KB", "MB", "GB", "TB"]; @@ -1029,8 +1190,10 @@ function initUpload() { const file = input.files[0]; input.value = ""; if (!file) return; - const dir = currentPath && currentPath.includes("/") - ? currentPath.slice(0, currentPath.lastIndexOf("/")) : ""; + // A selected folder receives the upload; a selected file means "next to it". + const dir = !currentPath ? "" + : dirIndex.has(currentPath) ? currentPath + : currentPath.includes("/") ? currentPath.slice(0, currentPath.lastIndexOf("/")) : ""; const dest = dir ? dir + "/" + file.name : file.name; const status = $("meta"); try { @@ -1204,6 +1367,9 @@ function paletteCandidates() { if (serverConfig.auth && serverConfig.auth.enabled) { add("power", "Sign out", "action", () => { location.href = "/auth/logout"; }); } + for (const d of dirIndex.keys()) { + add("folder", d, "folder", () => openFolder(d)); + } for (const f of flatFiles) { add("doc", f.path, "file", () => openFile(f.path)); } @@ -1343,7 +1509,7 @@ window.addEventListener("popstate", () => { const proj = projects.find((x) => x.id === project); if (proj) { selectProject(proj, path || null); return; } } - if (path && path !== currentPath) openFile(path); + if (path && path !== currentPath) openPath(path); }); boot(); diff --git a/internal/webapp/static/index.html b/internal/webapp/static/index.html index 74972d3..84b2736 100644 --- a/internal/webapp/static/index.html +++ b/internal/webapp/static/index.html @@ -33,6 +33,7 @@ + diff --git a/internal/webapp/static/style.css b/internal/webapp/static/style.css index 7bd48c1..5e7eee5 100644 --- a/internal/webapp/static/style.css +++ b/internal/webapp/static/style.css @@ -174,6 +174,9 @@ button, input, a.btn { font-family: inherit; } .icon-btn { display: none; width: 34px; height: 34px; border: none; background: transparent; color: var(--text-dim); cursor: pointer; border-radius: 7px; align-items: center; justify-content: center; } .icon-btn:hover { color: var(--text); background: var(--hover); } #crumb { font-size: 12.5px; color: var(--text); font-weight: 500; letter-spacing: -.01em; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +#crumb .crumb-seg { color: var(--text-dim); cursor: pointer; } +#crumb .crumb-seg:hover { color: var(--accent-bright); } +#crumb .crumb-sep { color: var(--text-ghost); margin: 0 5px; } #meta { flex: 1; min-width: 0; font-size: 12px; color: var(--text-faint); text-align: right; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .btn { @@ -251,6 +254,27 @@ button, input, a.btn { font-family: inherit; } .tg-desc { font-size: 12px; color: var(--text-faint); margin-top: 3px; line-height: 1.5; } .admin-item.toggle input { width: 16px; height: 16px; margin-top: 2px; accent-color: var(--accent); flex: none; } +/* ---- folder listing ---- */ +.dirlist { max-width: 704px; margin: 0 auto; } +.dl-title { display: flex; align-items: center; gap: 10px; font-size: 21px; font-weight: 640; letter-spacing: -.02em; margin: 0 0 4px; color: #f4f6f9; } +.dl-title-icon { display: flex; color: var(--accent); } +.dl-title-icon .ico { width: 20px; height: 20px; } +.dl-sub { color: var(--text-faint); font-size: 12.5px; margin: 0 0 18px; } +.dl-items { border: 1px solid var(--border); border-radius: var(--r-card); overflow: hidden; background: var(--bg-side); } +.dl-row { display: flex; align-items: center; gap: 11px; padding: 10px 14px; border-bottom: 1px solid var(--border); cursor: pointer; } +.dl-row:last-child { border-bottom: none; } +.dl-row:hover { background: var(--hover); } +.dl-row .ticon { flex: none; display: flex; color: var(--text-ghost); } +.dl-row .ticon .ico { width: 16px; height: 16px; } +.dl-row:hover .ticon { color: var(--text-faint); } +.dl-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13.5px; color: var(--text); } +.dl-meta { flex: none; font-size: 12px; color: var(--text-faint); font-variant-numeric: tabular-nums; } +.dl-empty { padding: 24px 14px; color: var(--text-faint); font-size: 13px; border: 1px dashed var(--border); border-radius: var(--r-card); text-align: center; } +.dl-h3 { margin: 28px 0 8px; font-size: 10.5px; text-transform: uppercase; letter-spacing: .07em; color: var(--text-ghost); font-weight: 600; } +.dl-hlist { border: 1px solid var(--border); border-radius: var(--r-card); background: var(--bg-side); overflow: hidden; max-width: none; } +.dl-hlist .hentry:last-child { border-bottom: none; } +.dl-more { margin-top: 10px; } + /* ---- history ---- */ .history { max-width: 860px; } .hentry { padding: 11px 12px; border-bottom: 1px solid var(--border); } @@ -258,7 +282,12 @@ button, input, a.btn { font-family: inherit; } .hline { display: flex; gap: 10px; align-items: center; } .hkind { display: inline-flex; color: var(--add); } .hkind .ico { width: 13px; height: 13px; } +.hentry.edit .hkind { color: var(--accent); } .hentry.delete .hkind { color: var(--del); } +.htag { flex: none; font-size: 10px; text-transform: uppercase; letter-spacing: .06em; font-weight: 600; padding: 1px 6px; border-radius: 4px; } +.hentry.add .htag { color: var(--add); background: rgba(76,195,138,.12); } +.hentry.edit .htag { color: var(--accent-bright); background: var(--glow); } +.hentry.delete .htag { color: #ff8b8b; background: rgba(242,109,109,.12); } .hpath { font-weight: 500; cursor: pointer; color: var(--text); font-size: 13px; } .hpath:hover { color: var(--accent-bright); } .htime { margin-left: auto; color: var(--text-faint); font-size: 12px; font-variant-numeric: tabular-nums; } @@ -268,6 +297,10 @@ button, input, a.btn { font-family: inherit; } .hact { margin-left: auto; } .hact a { color: var(--accent-bright); text-decoration: none; margin-left: 10px; } .hact a:hover { text-decoration: underline; } +.hnote { margin-top: 4px; padding-left: 23px; font-size: 12px; color: var(--text-faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.hnote::before { content: "› "; color: var(--text-ghost); } +.hnote a { color: var(--accent-bright); text-decoration: none; } +.hnote a:hover { text-decoration: underline; } /* ---- command palette ---- */ #palette-overlay { position: fixed; inset: 0; background: rgba(6,7,9,.62); backdrop-filter: blur(3px); display: flex; justify-content: center; align-items: flex-start; padding-top: 12vh; z-index: 100; } @@ -330,7 +363,7 @@ button, input, a.btn { font-family: inherit; } #invite-btn { min-height: 40px; padding: 0 14px; } #org-name { min-height: 44px; } .nav-add { min-width: 44px; min-height: 44px; } - .markdown, .admin, .onboard, .history { max-width: 100%; } + .markdown, .admin, .onboard, .history, .dirlist { max-width: 100%; } .markdown table, pre.plain { display: block; overflow-x: auto; max-width: 100%; } .ob-row { flex-direction: column; } } From c1f0d0f8ee2a1f49dad3e20236e3d8b5dc3b4cd5 Mon Sep 17 00:00:00 2001 From: Snow Lee Date: Sat, 11 Jul 2026 10:56:53 -0700 Subject: [PATCH 2/3] =?UTF-8?q?feat(sync):=20session-linked=20notes=20?= =?UTF-8?q?=E2=80=94=20stamp=20changes=20with=20the=20agent=20session?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `bdrive sync --note ` stamps session context onto every op the cycle commits and persists it in the volume store (note.json, --note-ttl default 30m) so the daemon's own scans stamp it too — winning the race between one-shot hook syncs and the 3s daemon scan. The plugin sync hook extracts session_id from hook stdin JSON and passes it automatically, so history links every change to the Claude Code session that made it. Conflict-copy ops keep their own note; expired/cleared notes stop applying. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01P5cxPQdSGJnjXCYY9GeWXt --- cmd/bdrive/cmds.go | 17 +++++++- internal/store/note.go | 56 ++++++++++++++++++++++++++ internal/syncer/flows_test.go | 68 ++++++++++++++++++++++++++++++++ internal/syncer/syncer.go | 12 +++++- plugin/scripts/beardrive-sync.sh | 15 ++++++- 5 files changed, 165 insertions(+), 3 deletions(-) create mode 100644 internal/store/note.go diff --git a/cmd/bdrive/cmds.go b/cmd/bdrive/cmds.go index a3caffa..70fc458 100644 --- a/cmd/bdrive/cmds.go +++ b/cmd/bdrive/cmds.go @@ -13,7 +13,9 @@ import ( ) func syncCmd() *cobra.Command { - return &cobra.Command{ + var note string + var noteTTL time.Duration + c := &cobra.Command{ Use: "sync [folder]", Short: "Sync a mounted folder with its remote now", Args: cobra.MaximumNArgs(1), @@ -27,6 +29,16 @@ func syncCmd() *cobra.Command { 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 + } sess.OnProgress = progressReporter() res, err := sess.Cycle(cmd.Context()) if err != nil { @@ -37,6 +49,9 @@ func syncCmd() *cobra.Command { return nil }, } + c.Flags().StringVar(¬e, "note", "", "session context stamped onto changes (e.g. an agent session id); shown in history; empty clears") + c.Flags().DurationVar(¬eTTL, "note-ttl", 30*time.Minute, "how long the note keeps applying to daemon-committed changes") + return c } func statusCmd() *cobra.Command { diff --git a/internal/store/note.go b/internal/store/note.go new file mode 100644 index 0000000..67b6711 --- /dev/null +++ b/internal/store/note.go @@ -0,0 +1,56 @@ +package store + +import ( + "os" + "path/filepath" + "time" +) + +// The session note is transient per-volume context — "why edits are being +// made right now" (e.g. a Claude Code session id set by the plugin's sync +// hook). Whichever scanner commits an op while the note is live (the daemon +// or a one-shot `bdrive sync`) stamps it into Op.Note, so provenance doesn't +// depend on winning the race against the daemon's scan interval. The TTL +// keeps a stale note from mislabeling unrelated edits made hours later. + +// Note is the on-disk shape of the session note. +type Note struct { + Text string `json:"text"` + Expires time.Time `json:"expires,omitzero"` // zero = never expires +} + +func (s *Store) notePath() string { return filepath.Join(s.dir, "note.json") } + +// SaveNote sets the session note. ttl > 0 bounds its life; empty text clears. +func (s *Store) SaveNote(text string, ttl time.Duration) error { + if text == "" { + return s.ClearNote() + } + n := Note{Text: text} + if ttl > 0 { + n.Expires = time.Now().Add(ttl) + } + return WriteJSONAtomic(s.notePath(), n) +} + +// ClearNote removes the session note. +func (s *Store) ClearNote() error { + err := os.Remove(s.notePath()) + if os.IsNotExist(err) { + return nil + } + return err +} + +// LoadNote returns the live session note, or "" if none is set or it has +// expired. Never errors: a missing or unreadable note is just no note. +func (s *Store) LoadNote() string { + var n Note + if err := readJSON(s.notePath(), &n); err != nil { + return "" + } + if !n.Expires.IsZero() && time.Now().After(n.Expires) { + return "" + } + return n.Text +} diff --git a/internal/syncer/flows_test.go b/internal/syncer/flows_test.go index 050b6b6..ee1b46a 100644 --- a/internal/syncer/flows_test.go +++ b/internal/syncer/flows_test.go @@ -8,6 +8,7 @@ import ( "time" "github.com/runbear-io/beardrive/internal/config" + "github.com/runbear-io/beardrive/internal/journal" "github.com/runbear-io/beardrive/internal/remote" "github.com/runbear-io/beardrive/internal/store" ) @@ -207,3 +208,70 @@ func TestConflictCopyNameMatchesDocumentedGlob(t *testing.T) { t.Fatalf("conflict copy name %q should sanitize device names", name) } } + +// Session-linked notes: a note set for the session (explicitly, or persisted +// in the store by `bdrive sync --note`) is stamped onto every op that scan +// commits, travels through the remote to peers, and expires with its TTL — +// so history can link each change to the agent session that made it. +func TestSessionNoteStampsOps(t *testing.T) { + be := sharedRemote(t) + a := newDevice(t, "deva", be) + b := newDevice(t, "devb", be) + + // Explicit note on the session (one-shot `bdrive sync --note`). + a.Note = "claude-code session s-123" + write(t, a.Folder, "wiki/plan.md", "v1") + cycle(t, a) + cycle(t, b) + ops, err := b.Store.DeviceOps("deva") + if err != nil || len(ops) != 1 { + t.Fatalf("peer copy of deva journal: %v (%d ops)", err, len(ops)) + } + if ops[0].Note != "claude-code session s-123" { + t.Fatalf("note on peer = %q", ops[0].Note) + } + + // Persisted note: an empty Session.Note falls back to the store note, the + // path the daemon takes after a one-shot sync persisted it. + a.Note = "" + if err := a.Store.SaveNote("claude-code session s-456", time.Hour); err != nil { + t.Fatal(err) + } + write(t, a.Folder, "wiki/plan.md", "v2") + cycle(t, a) + ops, _ = a.Store.DeviceOps("deva") + if got := ops[len(ops)-1].Note; got != "claude-code session s-456" { + t.Fatalf("persisted-note op = %q", got) + } + + // Deletes carry the note too. + if err := os.Remove(filepath.Join(a.Folder, "wiki", "plan.md")); err != nil { + t.Fatal(err) + } + cycle(t, a) + ops, _ = a.Store.DeviceOps("deva") + last := ops[len(ops)-1] + if last.Kind != journal.KindDelete || last.Note != "claude-code session s-456" { + t.Fatalf("delete op = %+v", last) + } + + // An expired note stops applying: later edits stay unlabeled. + if err := a.Store.SaveNote("stale", time.Millisecond); err != nil { + t.Fatal(err) + } + time.Sleep(10 * time.Millisecond) + write(t, a.Folder, "wiki/new.md", "x") + cycle(t, a) + ops, _ = a.Store.DeviceOps("deva") + if got := ops[len(ops)-1].Note; got != "" { + t.Fatalf("expired note leaked onto op: %q", got) + } + + // Clearing removes the note file entirely. + if err := a.Store.SaveNote("", 0); err != nil { + t.Fatal(err) + } + if got := a.Store.LoadNote(); got != "" { + t.Fatalf("cleared note = %q", got) + } +} diff --git a/internal/syncer/syncer.go b/internal/syncer/syncer.go index 199d2c6..7f53e8e 100644 --- a/internal/syncer/syncer.go +++ b/internal/syncer/syncer.go @@ -55,6 +55,12 @@ type Session struct { // history shows who changed what. Zero on offline/no-auth setups — // Device.Author remains the fallback identity. Account config.Settings + // Note, when set, is stamped into every op this session commits — session + // context like "claude-code session ". Empty means fall back to the + // store's persisted session note (store.LoadNote), which lets a one-shot + // `bdrive sync --note` leave context that the daemon's later scans also + // stamp. Conflict-copy ops keep their own explanatory note. + Note string Backend remote.Backend // nil = work offline // OnProgress, when set, is called during push with upload progress. It may // be invoked concurrently from upload workers, so it must be safe to call @@ -210,6 +216,10 @@ func (s *Session) Cycle(ctx context.Context) (*Result, error) { func (s *Session) scan(cache map[string]store.CachedFile, st *store.SyncState, seqBase int64, filter *Filter) ([]journal.Op, error) { seen := make(map[string]bool, len(cache)) var ops []journal.Op + note := s.Note + if note == "" { + note = s.Store.LoadNote() + } nextOp := func(kind, rel string) journal.Op { st.Lamport++ seqBase++ @@ -217,7 +227,7 @@ func (s *Session) scan(cache map[string]store.CachedFile, st *store.SyncState, s Seq: seqBase, Lamport: st.Lamport, Time: time.Now().UTC(), Device: s.Device.ID, DeviceName: s.Device.Name, Author: s.Device.Author, User: s.Account.Email, UserName: s.Account.Name, - Kind: kind, Path: rel, + Kind: kind, Path: rel, Note: note, } } diff --git a/plugin/scripts/beardrive-sync.sh b/plugin/scripts/beardrive-sync.sh index 6e67bb4..99660b5 100755 --- a/plugin/scripts/beardrive-sync.sh +++ b/plugin/scripts/beardrive-sync.sh @@ -4,7 +4,20 @@ # # Runs blocking on UserPromptSubmit (fresh files before Claude reads them) # and async on Stop (push edits out without delaying the turn). +# +# Hooks receive JSON on stdin with a session_id; it is passed as the sync +# note so every change journaled during this session — including ones the +# background daemon commits — is stamped with the Claude Code session that +# made it, and shows up in `bdrive log` and the hub's history views. cd "${CLAUDE_PROJECT_DIR:-.}" || exit 0 [ -d .bdrive ] || exit 0 command -v bdrive >/dev/null 2>&1 || exit 0 -bdrive sync . >/dev/null 2>&1 || true +sid="" +if [ ! -t 0 ]; then + sid=$(head -c 8192 | sed -n 's/.*"session_id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1) +fi +if [ -n "$sid" ]; then + bdrive sync . --note "claude-code session $sid" >/dev/null 2>&1 || true +else + bdrive sync . >/dev/null 2>&1 || true +fi From 626a9c0a0736abe14c0a62d6d6d5e31fdea278c3 Mon Sep 17 00:00:00 2001 From: Snow Lee Date: Sat, 11 Jul 2026 10:57:07 -0700 Subject: [PATCH 3/3] =?UTF-8?q?feat(cli):=20bdrive=20hooks=20=E2=80=94=20a?= =?UTF-8?q?gent-agnostic=20sync=20hook=20registration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `bdrive hooks install` detects the agent platforms in use — Claude Code (.claude/), Codex (.codex/), Gemini CLI (.gemini/), Hermes (~/.hermes/) — and idempotently merges beardrive's turn-boundary sync hooks into each platform's own hook config (JSON for claude/codex/gemini, YAML for hermes), preserving existing hooks. All four pipe hook JSON with a session_id, so one POSIX-sh hook command serves every platform: pull at turn start, push after edits, changes stamped " session ". Bare `bdrive hooks` prints the detection/registration table. The beardrive skill now runs it automatically after `bdrive init`, and /beardrive:install's hand-maintained settings.json block is replaced by the command, so the hook content has one source of truth in the binary. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01P5cxPQdSGJnjXCYY9GeWXt --- README.md | 3 +- cmd/bdrive/hooks.go | 88 ++++++++ cmd/bdrive/main.go | 1 + go.mod | 1 + go.sum | 8 + internal/agenthooks/agenthooks.go | 267 +++++++++++++++++++++++++ internal/agenthooks/agenthooks_test.go | 243 ++++++++++++++++++++++ plugin/commands/init.md | 11 +- plugin/commands/install.md | 59 ++---- plugin/skills/beardrive/SKILL.md | 32 ++- 10 files changed, 670 insertions(+), 43 deletions(-) create mode 100644 cmd/bdrive/hooks.go create mode 100644 internal/agenthooks/agenthooks.go create mode 100644 internal/agenthooks/agenthooks_test.go diff --git a/README.md b/README.md index 9e395a0..d34e85b 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,8 @@ beardrive uses each provider's standard credential chain — nothing beardrive-s | `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 share ` | Public URL for a synced file (`--list`, `--revoke`, `--expires`) | -| `bdrive sync [folder]` | Run one sync cycle now | +| `bdrive sync [folder]` | Run one sync cycle now. `--note ` 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 | +| `bdrive hooks [install]` | Register turn-boundary sync hooks with detected agent platforms (Claude Code, Codex, Gemini CLI, Hermes) — pull each turn, push after edits, session-note stamping; idempotent (`--agent` overrides detection) | | `bdrive status [folder]` | Projects, daemon state, pending changes | | `bdrive log [folder] [-p path] [-n N]` | Change history: account, device, time, file | | `bdrive web [folder \| storage-root-url]` | Web server: viewer (rendered markdown, downloads, history), uploads, multi-project sync hub | diff --git a/cmd/bdrive/hooks.go b/cmd/bdrive/hooks.go new file mode 100644 index 0000000..ffb89c7 --- /dev/null +++ b/cmd/bdrive/hooks.go @@ -0,0 +1,88 @@ +package main + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/runbear-io/beardrive/internal/agenthooks" +) + +// bdrive hooks — register BearDrive's turn-boundary sync hooks with whatever +// AI agent platforms the user works with (Claude Code, Codex, Gemini CLI, +// Hermes). One command instead of hand-editing four config formats; the +// beardrive skill runs it right after `bdrive init`. +func hooksCmd() *cobra.Command { + c := &cobra.Command{ + Use: "hooks", + Short: "Show which AI agent platforms have beardrive sync hooks registered", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + folder, err := absFolder(nil) + if err != nil { + return err + } + detected := map[string]bool{} + for _, a := range agenthooks.Detect(folder) { + detected[a] = true + } + for _, a := range agenthooks.Agents { + state := "not detected" + if detected[a] { + state = "detected, hooks not registered" + if agenthooks.Registered(folder, a) { + state = "hooks registered" + } + } + fmt.Printf(" %-8s %-32s %s\n", a, state, agenthooks.ConfigPath(folder, a)) + } + fmt.Println("\nregister with: bdrive hooks install [--agent claude,codex,gemini,hermes]") + return nil + }, + } + + var agentsFlag string + install := &cobra.Command{ + Use: "install [folder]", + Short: "Register sync hooks for detected agent platforms (or --agent list)", + Long: "Registers beardrive's sync hooks with each agent platform's own hook\n" + + "config: files pull before every turn and push after edits, and changes\n" + + "are stamped with the agent session that made them (`bdrive sync --note`).\n" + + "Merging is idempotent and preserves hooks you already have.", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + folder, err := absFolder(args) + if err != nil { + return err + } + var agents []string + if agentsFlag != "" && agentsFlag != "auto" { + agents = strings.Split(agentsFlag, ",") + } + results, err := agenthooks.Install(folder, agents) + if err != nil { + return err + } + if len(results) == 0 { + fmt.Println("no agent platforms detected (looked for .claude/, .codex/, .gemini/ here or in ~; ~/.hermes/)") + fmt.Println("pick explicitly: bdrive hooks install --agent claude,codex,gemini,hermes") + return nil + } + for _, r := range results { + state := "already registered" + if r.Changed { + state = "registered" + } + fmt.Printf(" %-8s %s → %s\n", r.Agent, state, r.Path) + if r.Note != "" { + fmt.Printf(" note: %s\n", r.Note) + } + } + return nil + }, + } + install.Flags().StringVar(&agentsFlag, "agent", "auto", "comma-separated platforms (claude,codex,gemini,hermes) or auto") + c.AddCommand(install) + return c +} diff --git a/cmd/bdrive/main.go b/cmd/bdrive/main.go index 1258d29..52d5c65 100644 --- a/cmd/bdrive/main.go +++ b/cmd/bdrive/main.go @@ -36,6 +36,7 @@ everything keeps working offline; changes sync when the remote is reachable.`, shareCmd(), stopCmd(), syncCmd(), + hooksCmd(), statusCmd(), logCmd(), webCmd(), diff --git a/go.mod b/go.mod index 71c44e0..bf2bcec 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( golang.org/x/crypto v0.51.0 golang.org/x/sync v0.21.0 google.golang.org/api v0.284.0 + gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.53.0 ) diff --git a/go.sum b/go.sum index 704b4b8..b7c5347 100644 --- a/go.sum +++ b/go.sum @@ -130,6 +130,10 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mattn/go-colorable v0.1.2 h1:/bC9yWikZXAL9uJdulbSfyVNIR3n3trXl+v8+1sx8mU= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= @@ -146,6 +150,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= @@ -243,6 +249,8 @@ google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zN google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/agenthooks/agenthooks.go b/internal/agenthooks/agenthooks.go new file mode 100644 index 0000000..f6200bd --- /dev/null +++ b/internal/agenthooks/agenthooks.go @@ -0,0 +1,267 @@ +// Package agenthooks detects which AI agent platforms a user works with and +// registers BearDrive's sync hooks in each platform's own hook config, so +// files sync at turn boundaries no matter which agent edits them. +// +// Every supported platform runs command hooks the same way — spawn a shell +// command, pipe event JSON (with a session_id) on stdin — so one hook command +// works everywhere; only the config file format and event names differ: +// +// claude .claude/settings.json UserPromptSubmit / PostToolUse (project) +// codex .codex/hooks.json UserPromptSubmit / PostToolUse (project) +// gemini .gemini/settings.json BeforeAgent / AfterTool (project) +// hermes ~/.hermes/config.yaml pre_llm_call / post_tool_call (user) +// +// The hook syncs the project and stamps changes with " session " +// (see `bdrive sync --note`), so hub history links every change to the agent +// session that made it. Hooks are fast no-ops outside bdrive projects. +package agenthooks + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "gopkg.in/yaml.v3" + + "github.com/runbear-io/beardrive/internal/store" +) + +// marker identifies our hooks inside a config, for idempotency and status. +const marker = "bdrive sync" + +// Agent names, in the order they are reported. +var Agents = []string{"claude", "codex", "gemini", "hermes"} + +// Result reports what Install did for one agent platform. +type Result struct { + Agent string + Path string // config file the hooks live in + Changed bool // false = already registered + Note string // extra step the user must take, if any +} + +// hookCommand is the one shell command every platform runs: sync the project +// if it is a bdrive mount, stamping changes with the agent session id parsed +// from the hook's stdin JSON. POSIX sh only — no jq, no bashisms. +func hookCommand(label string) string { + return `sh -c '` + + `cd "${CLAUDE_PROJECT_DIR:-.}" && [ -d .bdrive ] && command -v bdrive >/dev/null || exit 0; ` + + `s=; [ -t 0 ] || s=$(head -c 8192 2>/dev/null | tr -d \" | sed -n "s/.*session_id[[:space:]]*:[[:space:]]*\([a-zA-Z0-9_-]*\).*/\1/p" | head -n 1); ` + + `if [ -n "$s" ]; then bdrive sync . --note "` + label + ` session $s" >/dev/null 2>&1 || true; ` + + `else bdrive sync . >/dev/null 2>&1 || true; fi'` +} + +type platform struct { + label string // session-note label + projectDir string // presence of this dir (project or home) = detected + userLevel bool // config lives in the home dir, not the project + install func(folder string) (path string, changed bool, err error) + note string +} + +var platforms = map[string]platform{ + "claude": { + label: "claude-code", + projectDir: ".claude", + install: func(folder string) (string, bool, error) { + return mergeJSONHooks(filepath.Join(folder, ".claude", "settings.json"), + "UserPromptSubmit", "PostToolUse", "Write|Edit|MultiEdit", "claude-code", 30, true) + }, + }, + "codex": { + label: "codex", + projectDir: ".codex", + install: func(folder string) (string, bool, error) { + return mergeJSONHooks(filepath.Join(folder, ".codex", "hooks.json"), + "UserPromptSubmit", "PostToolUse", "apply_patch", "codex", 30, false) + }, + note: "run /hooks inside Codex once to trust the project's .codex layer", + }, + "gemini": { + label: "gemini", + projectDir: ".gemini", + install: func(folder string) (string, bool, error) { + // Gemini uses its own event names and millisecond timeouts. + return mergeJSONHooks(filepath.Join(folder, ".gemini", "settings.json"), + "BeforeAgent", "AfterTool", "write_file|replace|edit", "gemini", 30000, false) + }, + }, + "hermes": { + label: "hermes", + userLevel: true, + install: installHermes, + }, +} + +// Detect reports which agent platforms are in use, judged by their config +// dirs existing in the project or the home directory. +func Detect(folder string) []string { + home, _ := os.UserHomeDir() + var found []string + for _, name := range Agents { + p := platforms[name] + switch { + case p.userLevel: + if dirExists(filepath.Join(home, "."+name)) { + found = append(found, name) + } + case dirExists(filepath.Join(folder, p.projectDir)) || + (home != "" && dirExists(filepath.Join(home, p.projectDir))): + found = append(found, name) + } + } + return found +} + +// Registered reports whether an agent's config already carries our hooks. +func Registered(folder, agent string) bool { + data, err := os.ReadFile(ConfigPath(folder, agent)) + return err == nil && strings.Contains(string(data), marker) +} + +// ConfigPath returns where an agent's hooks are (or would be) registered. +func ConfigPath(folder, agent string) string { + switch agent { + case "claude": + return filepath.Join(folder, ".claude", "settings.json") + case "codex": + return filepath.Join(folder, ".codex", "hooks.json") + case "gemini": + return filepath.Join(folder, ".gemini", "settings.json") + case "hermes": + home, _ := os.UserHomeDir() + return filepath.Join(home, ".hermes", "config.yaml") + } + return "" +} + +// Install registers the sync hooks for the given agents ("auto"/empty = +// every detected platform). Merging is idempotent and preserves whatever +// hooks the config already has. +func Install(folder string, agents []string) ([]Result, error) { + if len(agents) == 0 || (len(agents) == 1 && agents[0] == "auto") { + agents = Detect(folder) + } + var out []Result + for _, name := range agents { + p, ok := platforms[name] + if !ok { + return out, fmt.Errorf("unknown agent %q (supported: %s)", name, strings.Join(Agents, ", ")) + } + path, changed, err := p.install(folder) + if err != nil { + return out, fmt.Errorf("%s: %w", name, err) + } + out = append(out, Result{Agent: name, Path: path, Changed: changed, Note: p.note}) + } + return out, nil +} + +// mergeJSONHooks adds the pull + push hook pair to a Claude-style hooks JSON +// file (Claude, Codex, and Gemini all use this shape: hooks. is an +// array of {matcher?, hooks: [{type: "command", ...}]} groups). +func mergeJSONHooks(path, pullEvent, pushEvent, pushMatcher, label string, timeout int, async bool) (string, bool, error) { + root := map[string]any{} + if data, err := os.ReadFile(path); err == nil { + if err := json.Unmarshal(data, &root); err != nil { + return path, false, fmt.Errorf("parse %s: %w", path, err) + } + } else if !os.IsNotExist(err) { + return path, false, err + } + hooks, ok := root["hooks"].(map[string]any) + if !ok { + hooks = map[string]any{} + root["hooks"] = hooks + } + cmd := hookCommand(label) + pull := map[string]any{"hooks": []any{map[string]any{ + "type": "command", "command": cmd, "timeout": timeout, + "statusMessage": "beardrive: pulling latest files", + }}} + pushHook := map[string]any{"type": "command", "command": cmd, "timeout": timeout} + if async { + pushHook["async"] = true + } + push := map[string]any{"matcher": pushMatcher, "hooks": []any{pushHook}} + + changed := false + for event, group := range map[string]any{pullEvent: pull, pushEvent: push} { + arr, _ := hooks[event].([]any) + if containsMarker(arr) { + continue + } + hooks[event] = append(arr, group) + changed = true + } + if !changed { + return path, false, nil + } + return path, true, writeConfig(path, func() ([]byte, error) { + return json.MarshalIndent(root, "", " ") + }) +} + +// installHermes merges the hook pair into ~/.hermes/config.yaml +// (hooks. is an array of {matcher?, command, timeout}). +func installHermes(string) (string, bool, error) { + path := ConfigPath("", "hermes") + root := map[string]any{} + if data, err := os.ReadFile(path); err == nil { + if err := yaml.Unmarshal(data, &root); err != nil { + return path, false, fmt.Errorf("parse %s: %w", path, err) + } + } else if !os.IsNotExist(err) { + return path, false, err + } + hooks, ok := root["hooks"].(map[string]any) + if !ok { + hooks = map[string]any{} + root["hooks"] = hooks + } + cmd := hookCommand("hermes") + groups := map[string]any{ + "pre_llm_call": map[string]any{"command": cmd, "timeout": 30}, + "post_tool_call": map[string]any{"matcher": "write_file|patch", "command": cmd, "timeout": 30}, + } + changed := false + for event, group := range groups { + arr, _ := hooks[event].([]any) + if containsMarker(arr) { + continue + } + hooks[event] = append(arr, group) + changed = true + } + if !changed { + return path, false, nil + } + return path, true, writeConfig(path, func() ([]byte, error) { + return yaml.Marshal(root) + }) +} + +// containsMarker reports whether a hook array already holds one of ours. +// Serializing sidesteps walking every platform's nesting by hand. +func containsMarker(v any) bool { + data, err := json.Marshal(v) + return err == nil && strings.Contains(string(data), marker) +} + +func writeConfig(path string, marshal func() ([]byte, error)) error { + data, err := marshal() + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + return store.WriteFileAtomic(path, append(data, '\n'), 0o644) +} + +func dirExists(p string) bool { + fi, err := os.Stat(p) + return err == nil && fi.IsDir() +} diff --git a/internal/agenthooks/agenthooks_test.go b/internal/agenthooks/agenthooks_test.go new file mode 100644 index 0000000..30b1a54 --- /dev/null +++ b/internal/agenthooks/agenthooks_test.go @@ -0,0 +1,243 @@ +package agenthooks + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +func readJSON(t *testing.T, path string) map[string]any { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("%s is not valid JSON: %v", path, err) + } + return m +} + +func TestDetect(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + folder := t.TempDir() + + if got := Detect(folder); len(got) != 0 { + t.Fatalf("nothing configured, detected %v", got) + } + // project-level dirs + os.MkdirAll(filepath.Join(folder, ".codex"), 0o755) + os.MkdirAll(filepath.Join(folder, ".gemini"), 0o755) + // home-level dirs + os.MkdirAll(filepath.Join(home, ".claude"), 0o755) + os.MkdirAll(filepath.Join(home, ".hermes"), 0o755) + got := Detect(folder) + want := []string{"claude", "codex", "gemini", "hermes"} + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("detected %v, want %v", got, want) + } +} + +func TestInstallJSONPlatforms(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + folder := t.TempDir() + + results, err := Install(folder, []string{"claude", "codex", "gemini"}) + if err != nil { + t.Fatal(err) + } + for _, r := range results { + if !r.Changed { + t.Fatalf("%s: fresh install reported unchanged", r.Agent) + } + } + + // Claude: both events present, push is async, command carries the label. + cl := readJSON(t, filepath.Join(folder, ".claude", "settings.json")) + hooks := cl["hooks"].(map[string]any) + for _, ev := range []string{"UserPromptSubmit", "PostToolUse"} { + if _, ok := hooks[ev]; !ok { + t.Fatalf("claude missing %s", ev) + } + } + raw, _ := json.Marshal(cl) + if !strings.Contains(string(raw), "claude-code session $s") { + t.Fatal("claude hook lacks its session-note label") + } + if !strings.Contains(string(raw), `"async":true`) { + t.Fatal("claude push hook should be async") + } + + // Codex: same schema, its own label and matcher, no async field. + cx, _ := json.Marshal(readJSON(t, filepath.Join(folder, ".codex", "hooks.json"))) + if !strings.Contains(string(cx), "codex session $s") || !strings.Contains(string(cx), "apply_patch") { + t.Fatalf("codex hooks wrong: %s", cx) + } + if strings.Contains(string(cx), "async") { + t.Fatal("codex should not get the claude-only async field") + } + + // Gemini: its own event names and ms timeout. + gm, _ := json.Marshal(readJSON(t, filepath.Join(folder, ".gemini", "settings.json"))) + for _, want := range []string{"BeforeAgent", "AfterTool", "gemini session $s", "30000"} { + if !strings.Contains(string(gm), want) { + t.Fatalf("gemini hooks missing %q: %s", want, gm) + } + } +} + +func TestInstallIdempotentAndPreserving(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + folder := t.TempDir() + + // Pre-existing user hook must survive the merge. + pre := `{"permissions":{"allow":["Bash(ls:*)"]},"hooks":{"PostToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"echo mine"}]}]}}` + os.MkdirAll(filepath.Join(folder, ".claude"), 0o755) + os.WriteFile(filepath.Join(folder, ".claude", "settings.json"), []byte(pre), 0o644) + + if _, err := Install(folder, []string{"claude"}); err != nil { + t.Fatal(err) + } + cfg := readJSON(t, filepath.Join(folder, ".claude", "settings.json")) + raw, _ := json.Marshal(cfg) + if !strings.Contains(string(raw), "echo mine") { + t.Fatal("merge dropped the user's existing hook") + } + if _, ok := cfg["permissions"]; !ok { + t.Fatal("merge dropped unrelated settings keys") + } + if got := len(cfg["hooks"].(map[string]any)["PostToolUse"].([]any)); got != 2 { + t.Fatalf("PostToolUse groups = %d, want user's + ours", got) + } + + // Second install: no change, byte-identical file. + before, _ := os.ReadFile(filepath.Join(folder, ".claude", "settings.json")) + results, err := Install(folder, []string{"claude"}) + if err != nil { + t.Fatal(err) + } + if results[0].Changed { + t.Fatal("re-install reported a change") + } + after, _ := os.ReadFile(filepath.Join(folder, ".claude", "settings.json")) + if string(before) != string(after) { + t.Fatal("re-install rewrote the file") + } +} + +func TestInstallHermesYAML(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + os.MkdirAll(filepath.Join(home, ".hermes"), 0o755) + // Existing config keys must survive. + os.WriteFile(filepath.Join(home, ".hermes", "config.yaml"), + []byte("model: hermes-4\nhooks_auto_accept: false\n"), 0o644) + + results, err := Install(t.TempDir(), []string{"hermes"}) + if err != nil { + t.Fatal(err) + } + if !results[0].Changed { + t.Fatal("fresh hermes install reported unchanged") + } + data, _ := os.ReadFile(filepath.Join(home, ".hermes", "config.yaml")) + var m map[string]any + if err := yaml.Unmarshal(data, &m); err != nil { + t.Fatalf("config.yaml no longer parses: %v", err) + } + if m["model"] != "hermes-4" { + t.Fatal("merge dropped existing hermes config") + } + hooks := m["hooks"].(map[string]any) + for _, ev := range []string{"pre_llm_call", "post_tool_call"} { + if _, ok := hooks[ev]; !ok { + t.Fatalf("hermes missing %s", ev) + } + } + if !strings.Contains(string(data), "hermes session $s") { + t.Fatal("hermes hook lacks its session-note label") + } + + // Idempotent. + results, _ = Install(t.TempDir(), []string{"hermes"}) + if results[0].Changed { + t.Fatal("hermes re-install reported a change") + } +} + +func TestInstallAutoUsesDetection(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + folder := t.TempDir() + os.MkdirAll(filepath.Join(folder, ".gemini"), 0o755) + + results, err := Install(folder, nil) + if err != nil { + t.Fatal(err) + } + if len(results) != 1 || results[0].Agent != "gemini" { + t.Fatalf("auto install = %+v, want just gemini", results) + } +} + +func TestInstallUnknownAgent(t *testing.T) { + if _, err := Install(t.TempDir(), []string{"cursor"}); err == nil { + t.Fatal("unknown agent should error") + } +} + +// The generated hook command must extract a session id from hook stdin JSON +// and invoke bdrive with the platform label — run it for real against a fake +// bdrive to pin the shell behavior on every platform's payload shape. +func TestHookCommandExtraction(t *testing.T) { + if _, err := os.Stat("/bin/sh"); err != nil { + t.Skip("no /bin/sh") + } + dir := t.TempDir() + os.MkdirAll(filepath.Join(dir, ".bdrive"), 0o755) + bin := filepath.Join(dir, "bin") + os.MkdirAll(bin, 0o755) + fake := "#!/bin/sh\necho \"$@\" > \"" + dir + "/args.txt\"\n" + os.WriteFile(filepath.Join(bin, "bdrive"), []byte(fake), 0o755) + + payloads := map[string]string{ + "claude-code": `{"session_id":"abc-123","hook_event_name":"UserPromptSubmit"}`, + "codex": `{"session_id":"th_042","turn_id":"t1","cwd":"/x"}`, + "gemini": `{"session_id":"g-9f","timestamp":"2026-07-11T00:00:00Z"}`, + "hermes": `{"hook_event_name":"pre_llm_call","tool_name":null,"session_id":"sess_abc123"}`, + } + for label, payload := range payloads { + os.Remove(filepath.Join(dir, "args.txt")) + cmdline := hookCommand(label) + sh := "cd " + dir + " && PATH=" + bin + ":$PATH " + cmdline + if err := runShell(t, sh, payload); err != nil { + t.Fatalf("%s: %v", label, err) + } + got, err := os.ReadFile(filepath.Join(dir, "args.txt")) + if err != nil { + t.Fatalf("%s: hook never called bdrive: %v", label, err) + } + want := "sync . --note " + label + " session " + if !strings.Contains(string(got), want) { + t.Fatalf("%s: bdrive argv = %q, want it to contain %q", label, got, want) + } + } +} + +func runShell(t *testing.T, script, stdin string) error { + t.Helper() + cmd := exec.Command("/bin/sh", "-c", script) + cmd.Stdin = strings.NewReader(stdin) + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("%v: %s", err, out) + } + return nil +} diff --git a/plugin/commands/init.md b/plugin/commands/init.md index a6eb740..dd13a7a 100644 --- a/plugin/commands/init.md +++ b/plugin/commands/init.md @@ -52,7 +52,16 @@ Follow these steps: bdrive init --name --shared wiki # in a repo: only ./wiki syncs ``` -5. **Verify**: run `bdrive status ` and confirm the daemon is +5. **Register agent sync hooks**: run `bdrive hooks install `. It + detects the agent platforms in use (Claude Code, Codex, Gemini CLI, + Hermes — by their config dirs in the project or home) and idempotently + merges beardrive's sync hooks into each platform's own hook config, so + files pull at every turn start, push after edits, and every change is + stamped with the agent session that made it. Tell the user which + platforms got hooks; if Codex is among them, mention they must run + `/hooks` inside Codex once to trust the project's `.codex` layer. + +6. **Verify**: run `bdrive status ` and confirm the daemon is running and pending is 0. Summarize: project name/id, what syncs, and that edits propagate to every team member within seconds. Offer a consent-gated CLAUDE.md note and tell the user how teammates connect diff --git a/plugin/commands/install.md b/plugin/commands/install.md index 42baaa6..1adaf7d 100644 --- a/plugin/commands/install.md +++ b/plugin/commands/install.md @@ -61,49 +61,28 @@ every change is tracked (who, when, from which device). the URL. ``` -## 5. Register project-level sync hooks (ask first) +## 5. Register agent sync hooks -Ask: "Want me to register sync hooks in `.claude/settings.json` so files -sync automatically during Claude sessions — for every teammate, plugin or -not?" If yes, merge this into the project's `.claude/settings.json` -(create it if missing; preserve existing hooks — append to the arrays, -never overwrite them): +Run `bdrive hooks install` in the project. It detects the agent platforms +in use — Claude Code (`.claude/`), Codex (`.codex/`), Gemini CLI +(`.gemini/`), Hermes (`~/.hermes/`) — and idempotently merges beardrive's +sync hooks into each platform's own hook config, preserving any hooks +already there. Project-level files (`.claude/settings.json`, +`.codex/hooks.json`, `.gemini/settings.json`) ride the repo, so every +teammate gets them — plugin or not, whatever agent they use; Hermes hooks +are per-user (`~/.hermes/config.yaml`). -```json -{ - "hooks": { - "UserPromptSubmit": [ - { - "hooks": [ - { - "type": "command", - "command": "sh -c 'cd \"${CLAUDE_PROJECT_DIR:-.}\" && [ -d .bdrive ] && command -v bdrive >/dev/null && bdrive sync . >/dev/null 2>&1 || true'", - "timeout": 30, - "statusMessage": "beardrive: pulling latest files" - } - ] - } - ], - "PostToolUse": [ - { - "matcher": "Write|Edit|MultiEdit", - "hooks": [ - { - "type": "command", - "command": "sh -c 'cd \"${CLAUDE_PROJECT_DIR:-.}\" && [ -d .bdrive ] && command -v bdrive >/dev/null && bdrive sync . >/dev/null 2>&1 || true'", - "async": true - } - ] - } - ] - } -} -``` +The registered hooks pull before every turn (the agent always reads the +team's latest files), push right after edits (artifacts land on the server +seconds after they're created — daemon or no daemon), and stamp every +change with the agent session that made it (`bdrive sync --note " +session "` — visible in `bdrive log` and the hub's history views). +They are fast no-ops in folders without `.bdrive/`. -The pull at prompt-submit means Claude always reads the team's latest -files; the async push after each Write/Edit means artifacts land on the -server seconds after Claude creates them — daemon or no daemon. Both are -fast no-ops in folders without `.bdrive/`. +Tell the user which platforms got hooks (`bdrive hooks` shows the status +table). If Codex is among them, mention they must run `/hooks` inside +Codex once to trust the project's `.codex` layer. To register a platform +that wasn't detected: `bdrive hooks install --agent claude,codex,gemini,hermes`. ## 6. Verify and summarize diff --git a/plugin/skills/beardrive/SKILL.md b/plugin/skills/beardrive/SKILL.md index 5829acf..b7ad61d 100644 --- a/plugin/skills/beardrive/SKILL.md +++ b/plugin/skills/beardrive/SKILL.md @@ -17,13 +17,14 @@ Use this skill whenever the user is working with the `bdrive` CLI: initializing | Run the daemon in the foreground | `bdrive init -f` | | Stop syncing | `bdrive stop []` (`--forget` also unregisters) | | One sync cycle now | `bdrive sync []` | +| Register agent sync hooks (Claude Code, Codex, Gemini CLI, Hermes) | `bdrive hooks install []` — auto-detects the platforms in use and merges pull/push/session-note hooks into each one's own hook config, idempotently; bare `bdrive hooks` shows the status table | | Mounts + daemon + pending state | `bdrive status []` | | Change history | `bdrive log [] [-p path] [-n N]` | | This device's identity | `bdrive whoami` | | Sign this device in (once per device) | `bdrive login [url]` — bare form uses the remembered server or beardrive.ai. Opens the sign-in page in a browser (sign-up available there); the terminal completes on its own and stores a per-device token. `--device` prints a code to approve from any browser (SSH/headless); `--status` shows server + account. Password reset: "Forgot password?" on the sign-in page (emailed via the server's SMTP config, or the link appears in the server log). **Switch hubs** with `bdrive login `, then re-run `bdrive init` in each folder. | | Sign this device out | `bdrive logout` — clears the saved token + account (folders untouched); `--forget` also drops the remembered server. The device token stays valid server-side until it expires — revoke it from the hub's device list to be sure. | | Share a synced file publicly by URL | `bdrive share ` — prints a link anyone can open (HTML renders as a page, markdown rendered, PDFs inline; sandboxed; always the latest content; no account needed). `--expires 24h` for self-destructing links; `--list` / `--revoke ` to manage. Put generated reports in the shared folder, sync, then share. | -| Set up a project for a Claude Code team | `/beardrive:install` — installs the CLI, signs in, runs init (whole/shared folder), offers a CLAUDE.md section about the shared folder, and registers project-level hooks (blocking pull at prompt-submit, async push after Write/Edit) in `.claude/settings.json` | +| Set up a project for a Claude Code team | `/beardrive:install` — installs the CLI, signs in, runs init (whole/shared folder), offers a CLAUDE.md section about the shared folder, and registers agent sync hooks via `bdrive hooks install` (pull at turn start, push after edits, session-note stamping — for every detected platform, not just Claude) | | Per-file / folder change history in the web UI | History button (file versions or project feed) and per-folder ⌚ — each entry: account, time, device (name/OS/IP), view/download of that exact version. API: `GET /api/p//history?path=\|prefix=`, `GET /api/p//blob?sha=` | | Web server: viewer + multi-project sync hub (read-only unless `--upload`) | `bdrive web [ \| ]` (serves cwd by default, `--addr :4173`; `-c config.json` reads remote/addr/upload/projects_db/database/auth settings from a file, explicit flags win; a storage root URL makes it a hub hosting many projects at `//`, registry in `--projects-db` file, default `$BDRIVE_HOME/projects.json`; `--upload` lets browsers add files, client devices push, and projects be created — direct to storage via expiring presigned URLs on S3/GCS, relayed through the server for `file://`; `--upload-ttl 15m`; clients never see the remote URL or credentials; hub projects are walled by org membership — invite teammates from the web UI; the viewer has a ⌘K palette for fuzzy file search, project switching, and quick actions) | @@ -114,6 +115,35 @@ Renaming/moving a project folder is safe: the daemon notices its folder vanished - Verify credentials and the remote end-to-end. - Sync once even when the daemon is stopped. +**Session-linked notes**: `bdrive sync --note ""` stamps the text onto +every change this cycle commits — and persists it (in the mount's volume +store, never synced) so changes the background daemon commits over the next +`--note-ttl` (default 30m) carry it too. Notes appear in `bdrive log` (as +`[note]`) and under each entry in the hub's history views. The plugin's sync +hooks pass `--note "claude-code session "` automatically, so +every change made during a Claude Code session is traceable to the session +that made it. An explicit empty `--note ""` clears the persisted note; +conflict-copy ops keep their own `conflict copy of ` note. + +### Agent sync hooks (Claude Code, Codex, Gemini CLI, Hermes) + +`bdrive hooks install []` registers turn-boundary sync for every +agent platform it detects (by config dir, in the project or home): + +| Platform | Config it writes | Pull / push events | +|---|---|---| +| Claude Code (& Cowork) | `/.claude/settings.json` | `UserPromptSubmit` / `PostToolUse` (Write\|Edit) | +| Codex (ChatGPT) | `/.codex/hooks.json` | `UserPromptSubmit` / `PostToolUse` (apply_patch) — user must `/hooks`-trust the layer once | +| Gemini CLI | `/.gemini/settings.json` | `BeforeAgent` / `AfterTool` (write_file\|replace) | +| Hermes | `~/.hermes/config.yaml` (per-user) | `pre_llm_call` / `post_tool_call` (write_file\|patch) | + +Every platform pipes hook JSON with a `session_id`, so one hook command +serves all four: it syncs the project (fast no-op outside bdrive folders) +and stamps changes with ` session `. Merging is idempotent and +preserves existing hooks; `--agent claude,codex,gemini,hermes` overrides +detection; bare `bdrive hooks` prints the detection/registration table. +Project-level configs ride the repo, so hooks reach the whole team. + ### Examples to walk a user through ```sh