feat(hook): tell the agent who has a file open in the hub right now (BEA-217) - #212
Open
ssowonny wants to merge 2 commits into
Open
Conversation
The turn-start hook already tells an agent what teammates changed since its
last turn. It said nothing about the teammate who has that same file open in
the browser and has not saved — nothing has synced, so no spool entry exists,
and that is precisely the collision that leaves conflict copies nobody notices.
Three pieces, no new state and no new user-facing concept:
- `GET /api/p/{id}/presence` — the read-only half of the roster the POST
already returns, at the same `PermRead` gate. It filters expired rows and the
caller's own row out of the RESULT without touching the map, and publishes no
presence event. It exists because the POST cannot do this: beating with an
empty path puts a phantom agent row in every teammate's top bar, and the
`{leave:true}` read-without-marking trick is keyed by account, so it would
evict the caller's own live browser row. `presenceActor` is now one function
for both handlers, so the GET's self-exclusion cannot stop matching the key
the POST inserts under.
- `remote.Rosterer` / `ErrNoRoster`, `Scoper`'s twin: httpBackend only, 404
distinguished from a transport error.
- One sentence in `emitHookContext`, after the changed-files line, on its own
2s timeout. Paths run through `hookAgentPath`, empty paths are dropped, ten
rows then `+N more`, and a display name goes through `journal.SafeText` per
rune before it enters the prompt. Nothing to say emits nothing.
The claim is timestamped in words — "as of this turn's start" — because the
roster has a 15s TTL and a turn can run for minutes. A PreToolUse check is
deliberately not built: it would spawn per tool call, and the hook guard stays
pure shell.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
TL;DR
docs/plan.mdwhile a teammate had that exact file open in the browser. Now the turn-start hook says so: "Open in the hub right now (as of this turn's start):docs/plan.md— Mira Chen. A teammate may be typing; re-read before editing."remotecapability, one sentence.GET .../presence, not thePOSTthe issue's laziest-v1 named. The POST would have put a phantom agent row in everyone's top bar, and its{leave:true}read trick would have evicted the caller's own browser tab.What this closes
Three of four parts were already in the tree. Only the wire was missing.
flowchart LR spool["<b>what changed since last turn</b><br/>store.DrainInbound → hookChanged<br/><i>shipped — BEA-127</i>"] presence["<b>who is on it right now</b><br/>presenceHub → PresenceBar.tsx<br/><i>shipped — browser top bar only</i>"] hook["<b>the agent, before it writes</b><br/>emitHookContext"] wire["<b>this PR</b><br/>GET /api/p/id/presence<br/>remote.Rosterer<br/>hookRoster"] spool --> hook presence --> wire wire --> hook classDef added fill:#22c55e22,stroke:#22c55e,stroke-width:2px class wire addedA teammate typing and not yet saving produces no spool entry — nothing has synced. That is precisely the collision nothing today could see, and it is where the
archive/old-runbook.md.bdrive-conflict-mira-laptop-…artifact in the seeded project came from.Why GET and not the POST the issue named
The issue's laziest v1 said to take the roster from the response body the
POST .../presencealready returns. That reuse is not free:POST {"path":""}marks the agent's account present with an empty path — a phantom row in every teammate's top bar, refreshed every turn.POST {"leave":true}reads without marking, but the actor key is the account email, so when the same person also has a browser tab open it deletes their own live row and publishes the shrunken roster to everyone.Same laziness, one route. It is the same
proj(PermRead)gate, the same{"ok":true,"people":[…]}shape, and one client decoder serves both.The three pieces
1 —
GET /api/p/{id}/presence(internal/webapp/presence.go,server.go). Registered in the existingfor prefix, resolve := range …loop, so the single-volume/api/prefix comes free.rosterForfilters expired rows and the reader's own row out of the result and touches nothing —markstays the only thing that deletes. That is the whole trick: a project whose only traffic is agent hooks must not evict browser rows that are merely between beats. Nothing grows without end as a result, because only the POST ever inserts andmaxPresencePerProjectstill caps it.Self-exclusion is server-side because the roster deliberately never serializes the actor key, so a client cannot reliably exclude itself — and the hub knows exactly who is asking.
presenceActoris now one function for both handlers: a second copy of "who is asking" is how the GET silently stops matching the key the POST inserted under, at which point an agent is told it is colliding with itself.2 —
remote.Rosterer(internal/remote/remote.go,http.go),Scoper's twin end to end: optional interface,httpBackendonly,404→ErrNoRosterso "this hub has no opinion" never reads as "the hub is down". It inheritsdo(), so it carries the bearer token, the device headers andrefuseOffOriginRedirect.ErrNoRosteris kept for parity withErrNoScopeand for the test, but nothing branches on it: unlike scope, where "no opinion" and "unreachable" mean opposite things to the syncer, the hook treats every failure identically — say nothing.3 — one sentence in
emitHookContext(cmd/bdrive/hooksync.go), after the changed-files line:httpBackend's client carries a 5-minute whole-request timeout; a turn must never wait that long for a nicetyrunHookSync's early-return paths, or the turn loses its links over a nicetyhookAgentPathhookChangedPathrows dropped""for the dashboard and root listing+N morehookChangedMax's 20 on purpose: a presence path may be up topresencePathMax(1024) bytes, paid every turn of every session on the machineThe one thing to actually check
A display name is free text, and it lands verbatim in the agent's prompt. This is not in the spec; it is the plan's step 5.
Every other string the hook emits is machine-shaped — journal paths (
journal.SafePath), rule labels, line numbers. A roster name is typed by a project member into their own account and relayed by a hub, so a newline in it would end this sentence and start whatever the next line claims to be.hookSafeNamefilters per rune throughjournal.SafeText— the repo's single already-consolidated rule (C0, C1, DEL, every Cf, the tag block, U+2028/9) rather than a private list that would drift — then truncates to 64 runes.TestHookRosterSanitizesNamescovers a doubled newline followed by "IGNORE PREVIOUS INSTRUCTIONS", a 500-rune name, a zero-width space, an RTL override, a tag character, and U+2028. The path needs no such treatment:handlePresencealready blanks anything failingjournal.SafePath.Answering the issue's open question
Honest enough, provided the sentence says so — so it does, in words: "as of this turn's start". The roster is a 15-second TTL snapshot and a turn can run for minutes, so the honest framing is a timestamped claim, not a live one. A staleness mechanism (re-fetching, expiring the claim mid-turn) is the
PreToolUsedesign the author already deferred: it would spawn on every tool call, and the invariant says the agent hook guard stays pure shell. One clause, no new machinery.Deviations from the reviewed plan
One, and it is smaller than what the plan asked for. The plan's step 5 specified
hookSafeNameas "drop runes below0x20and0x7f". That misses C1, the Cf class, the tag block and U+2028/9 — all of whichjournal.SafeTextalready refuses in one place the repo consolidated on purpose. Filtering per rune throughSafeTextis the same amount of code and cannot drift from that rule.Everything else follows the plan as written, including its four inferences (GET over POST, the whole roster rather than intersecting it with the spool, server-side self-exclusion, and the dedicated timeout).
Architecture changes
Two diagrams changed; both gained the same new capability from their own side. No types were removed and no existing relationship changed.
architecture/cli-sync.md— newRostereroptional-capability interface and itsPersonrow besideScoper/Scope, implemented by the hubBackend, consumed byCommands(sync --hook) once per mount per turn.flowchart TB Backend["<b>Backend</b><br/>Put · Get · List · Exists · Close"] Scoper["<div style='text-align:left'><b>Scoper</b><br/><i>remote — optional capability</i><br/>+Scope(ctx) Scope</div>"] Scope["<div style='text-align:left'><b>Scope</b><br/>+Tag string<br/>+ReadOnly prefixes<br/>+Deny prefixes</div>"] Rosterer["<div style='text-align:left'><b>Rosterer</b><br/><i>remote — optional capability</i><br/>+Roster(ctx) []Person</div>"] Person["<div style='text-align:left'><b>Person</b><br/>+Name string<br/>+Path string</div>"] Session["<b>Session</b><br/>Cycle · scan → pull → push"] Commands["<b>Commands</b><br/>cmd/bdrive — sync --hook"] AgentHooks["<div style='text-align:left'><b>AgentHooks</b><br/>turn-start: sync --hook<br/>post-edit: sync --note<br/>post-read: read-log</div>"] RosterNote["Scoper's twin, same mold: the hub backend only.<br/>404 → ErrNoRoster, but the one caller<br/>swallows every failure and says nothing.<br/>Own 2s context — httpBackend's client<br/>carries a 5-minute whole-request timeout.<br/>hookSafeName runs a display name through<br/>journal.SafeText before it enters the prompt."] Scoper -. implements .-> Backend Session -- "loadScope, once per cycle before scan" --> Scoper Scoper -- "tag + readonly + deny prefixes" --> Scope Rosterer -. "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ implements</span>" .-> Backend Rosterer -- "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ name + path, viewing not editing</span>" --> Person Commands -- "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ sync --hook, once per mount per turn</span>" --> Rosterer AgentHooks -- "runs sync and read-log" --> Commands Rosterer -.- RosterNote classDef added fill:#22c55e22,stroke:#22c55e,stroke-width:2px classDef noteBox fill:#88888822,stroke:#888888,stroke-dasharray:2 2 class Rosterer added class Person added class RosterNote noteBox linkStyle 3 stroke:#22c55e,stroke-width:2px linkStyle 4 stroke:#22c55e,stroke-width:2px linkStyle 5 stroke:#22c55e,stroke-width:2pxarchitecture/webapp-server.md—RostererbesideWatcheras a third optionalBackendcapability, andpresenceHubgainedrosterFor. ThepresenceHub → eventHubrelation is unchanged and deliberately not reached by the new route.flowchart TB Backend["<b>Backend</b><br/>Put · Get · List · Exists · Close"] PutSigner["<div style='text-align:left'><b>PutSigner</b><br/><i>interface</i><br/>+SignPut(ctx, key, size, ttl)</div>"] Watcher["<div style='text-align:left'><b>Watcher</b><br/><i>interface</i><br/>+Watch(ctx) signal channel</div>"] Rosterer["<div style='text-align:left'><b>Rosterer</b><br/><i>interface</i><br/>+Roster(ctx) []Person</div>"] presenceHub["<div style='text-align:left'><b>presenceHub</b><br/><i>internal/webapp, presence.go</i><br/>-at per project, per actor entry<br/>+mark(project, actor, name, path, now)<br/>+drop(project, actor)<br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ +rosterFor(project, actor, now)</span></div>"] person["<div style='text-align:left'><b>person</b><br/>+Name string<br/>+Path string</div>"] eventHub["<b>eventHub</b><br/>GET {prefix}events"] Server["<b>Server</b><br/>internal/webapp"] GetNote["GET {prefix}presence, proj(PermRead).<br/>rosterFor FILTERS expired rows and the<br/>reader's own row out of the RESULT and<br/>touches nothing — mark stays the only<br/>thing that deletes.<br/>Self-exclusion is server-side: the roster<br/>never serializes the actor key.<br/>presenceActor is ONE function for both<br/>handlers, or the GET stops matching the<br/>key the POST inserted under."] PutSigner -. optional capability .-> Backend Watcher -. optional capability .-> Backend Rosterer -. "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ optional capability</span>" .-> Backend Server -- "who is looking at what" --> presenceHub Server -- "live change fan-out" --> eventHub presenceHub -- "publishes roster on the SAME stream (POST only)" --> eventHub presenceHub -- "rosterOf, rosterFor" --> person Rosterer -- "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ the CLI hook reads the roster it never joins</span>" --> presenceHub presenceHub -.- GetNote classDef added fill:#22c55e22,stroke:#22c55e,stroke-width:2px classDef noteBox fill:#88888822,stroke:#888888,stroke-dasharray:2 2 class Rosterer added class GetNote noteBox linkStyle 2 stroke:#22c55e,stroke-width:2px linkStyle 7 stroke:#22c55e,stroke-width:2pxInvariants
None touched. No journal write, no blob, no change to
Cycle's scan→pull→push ordering, no state file.internal/agenthooksis not edited at all — the guard decides whether to spawnbdrive, and this adds no spawn, only work inside thebdrivethe hook already runs.cmd/bdrive/desktop.goclassifies the routerouteProxy: the roster lives in the hub's memory and the sidecar has none of it.routeLocalwould have compiled, passedTestDesktopRoutesClassified, and made the Mac app answer every roster query with an empty list forever.What was run
go test ./...internal/webappincluded (376s)go vet ./...TestStopRecoversLegacyDaemon(internal/daemon, a package this branch does not touch); it passed on re-run, andmain's own CI run at the merge base is red on macOS with a different timing testTestPresenceRosterExcludesTheCaller,…PublishesNoEvent,…HidesExpiredRows,…RefusesNonMemberTestRosterFromHubAnd404,TestRosterIsHubOnly, plusvar _ Rosterer = (*httpBackend)(nil)TestHookRosterPaths(prefix / subpath-strip / out-of-reach / empty-path / multi-mount / cap),TestHookRosterSanitizesNames,TestSyncHookModeRoster(realhttptesthub, end to end),TestSyncHookModeNoRosterIsSilentnpm run e2enpm run buildand nostatic/churn. The whole surface is an API route and a sentence in hook stdout.Self-exclusion is asserted behaviourally, not by reading the code: a browser tab under the same account survives the GET. That is precisely what the rejected
POST {leave:true}shortcut broke, and it is invisible in review.The 7 e2e failures are not this branch
admin.spec.ts:15fails its post-rename#menu-org-settingsassertion, so its revert-to-defaultnever runs — and the other six specs all look the org up by name, so they fall over behind it. One root failure, six downstream.It is pre-existing and timing-sensitive, verified three ways: the same spec fails identically on
origin/mainrun in isolation, and a second fullorigin/mainrun failed the identical seven (the first full main run happened to pass all 226 — which is what "intermittent" means here). No frontend code changed on this branch, and the committedstatic/bundle is byte-identical to main's.Worth its own issue; it is not worth blocking this one.
Caveats, kept
Browser.tsxheartbeatsroute.path, so the roster covers anyone with the file open, not only someone in the collaborative editor. Broader is right — a teammate reading a file is still someone whose work an agent can land on top of — but the sentence must not claim "editing", and it doesn't.PreToolUsecheck. It is M, not S, and would put a spawn on every tool call.Closes BEA-217.
Build session
Only works on the machine this ran on.