Skip to content

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
mainfrom
bea-217-ph-idea-tell-the-agent-which-files-a-teammate-has-open-right
Open

feat(hook): tell the agent who has a file open in the hub right now (BEA-217)#212
ssowonny wants to merge 2 commits into
mainfrom
bea-217-ph-idea-tell-the-agent-which-files-a-teammate-has-open-right

Conversation

@ssowonny

@ssowonny ssowonny commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

TL;DR

  • An agent could rewrite docs/plan.md while 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."
  • Both halves already shipped — the hub's roster and the turn-start context. This is the wire between them: one read-only route, one optional remote capability, one sentence.
  • The one design call: it is a new GET .../presence, not the POST the 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.
  • Advisory only — nothing blocks, nothing prompts. Empty roster, unreachable hub, or a hub too old to answer all emit exactly what today emits.
  • Known gap, kept from the spec: this fixes the human-teammate collision, not the agent-agent one. Two agents editing one file from two terminals still see nothing about each other.

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 added
Loading

A 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 .../presence already returns. That reuse is not free:

                  ┌─────────────────────────────┐
                  │  the hook needs the roster  │
                  │   once per turn, read-only  │
                  └──────────────┬──────────────┘
               ┌─────────────────┴─────────────────┐
┌────────────────────────────┐      ┌────────────────────────────┐
│ POST presence (as written) │      │  GET presence (this PR)    │
├────────────────────────────┤      ├────────────────────────────┤
│ marks the AGENT present,   │      │ reads only, no SSE frame   │
│ and {leave:true} evicts    │      │ hub omits the caller       │
│ the user's own browser tab │      │ 404 on an older hub        │
│                            │      │                            │
│ REJECTED                   │      │ CHOSEN                     │
└────────────────────────────┘      └────────────────────────────┘

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 existing for prefix, resolve := range … loop, so the single-volume /api/ prefix comes free.

rosterFor filters expired rows and the reader's own row out of the result and touches nothing — mark stays 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 and maxPresencePerProject still 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. presenceActor is 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, httpBackend only, 404ErrNoRoster so "this hub has no opinion" never reads as "the hub is down". It inherits do(), so it carries the bearer token, the device headers and refuseOffOriginRedirect.

ErrNoRoster is kept for parity with ErrNoScope and 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:

Rule Why
Own 2s context httpBackend's client carries a 5-minute whole-request timeout; a turn must never wait that long for a nicety
Error is swallowed, not returned An unreachable roster must not join runHookSync's early-return paths, or the turn loses its links over a nicety
Paths run through hookAgentPath The agent's view of a path, same as hookChanged
Empty Path rows dropped "Someone is in the project" is not a collision; the browser sends "" for the dashboard and root listing
Cap 10, then +N more Below hookChangedMax's 20 on purpose: a presence path may be up to presencePathMax (1024) bytes, paid every turn of every session on the machine
Nothing to say → nothing emitted Output is what today's is when the roster is empty, offline, or old-hub

The 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. hookSafeName filters per rune through journal.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.

TestHookRosterSanitizesNames covers 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: handlePresence already blanks anything failing journal.SafePath.

Answering the issue's open question

the roster is a live snapshot, but an agent's turn can run for minutes. Is "who was on it when the turn started" honest enough, or does the sentence need a staleness qualifier?

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 PreToolUse design 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 hookSafeName as "drop runes below 0x20 and 0x7f". That misses C1, the Cf class, the tag block and U+2028/9 — all of which journal.SafeText already refuses in one place the repo consolidated on purpose. Filtering per rune through SafeText is 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 — new Rosterer optional-capability interface and its Person row beside Scoper/Scope, implemented by the hub Backend, consumed by Commands (sync --hook) once per mount per turn.

✅ added · ❌ removed (strikethrough) · unmarked = unchanged

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:2px
Loading

architecture/webapp-server.mdRosterer beside Watcher as a third optional Backend capability, and presenceHub gained rosterFor. The presenceHub → eventHub relation is unchanged and deliberately not reached by the new route.

✅ added · ❌ removed (strikethrough) · unmarked = unchanged

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:2px
Loading

Invariants

None touched. No journal write, no blob, no change to Cycle's scan→pull→push ordering, no state file. internal/agenthooks is not edited at all — the guard decides whether to spawn bdrive, and this adds no spawn, only work inside the bdrive the hook already runs.

cmd/bdrive/desktop.go classifies the route routeProxy: the roster lives in the hub's memory and the sidecar has none of it. routeLocal would have compiled, passed TestDesktopRoutesClassified, and made the Mac app answer every roster query with an empty list forever.

What was run

go test ./... ✅ every package, internal/webapp included (376s)
go vet ./... ✅ clean
CI (ubuntu + macos + docs) ✅ green — the first macOS run tripped TestStopRecoversLegacyDaemon (internal/daemon, a package this branch does not touch); it passed on re-run, and main's own CI run at the merge base is red on macOS with a different timing test
New hub tests TestPresenceRosterExcludesTheCaller, …PublishesNoEvent, …HidesExpiredRows, …RefusesNonMember
New client tests TestRosterFromHubAnd404, TestRosterIsHubOnly, plus var _ Rosterer = (*httpBackend)(nil)
New hook tests TestHookRosterPaths (prefix / subpath-strip / out-of-reach / empty-path / multi-mount / cap), TestHookRosterSanitizesNames, TestSyncHookModeRoster (real httptest hub, end to end), TestSyncHookModeNoRosterIsSilent
npm run e2e ⚠️ 219 passed, 7 pre-existing failures — see below
Screenshots none — no frontend change, so no npm run build and no static/ 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:15 fails its post-rename #menu-org-settings assertion, so its revert-to-default never 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/main run in isolation, and a second full origin/main run 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 committed static/ bundle is byte-identical to main's.

Worth its own issue; it is not worth blocking this one.

Caveats, kept

  • Presence is viewing, not editing. Browser.tsx heartbeats route.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.
  • Two agents editing one file from two terminals still see nothing about each other. This is the human-teammate collision only.
  • No PreToolUse check. It is M, not S, and would put a spawn on every tool call.
  • One dissent from the scoring run, recorded: Priya (×2 weight) ranked this second and traded everything for share-link provenance instead. Not a disagreement that collisions matter — her job is handing artifacts to stakeholders, and she never hits the two-writer case.

Closes BEA-217.

Build session

cd $(git worktree list | grep bea-217 | awk '{print $1}') && claude --resume 3190aff6-ea64-4e97-86df-482ed9ecee01

Only works on the machine this ran on.

ssowonny and others added 2 commits September 9, 2026 09:14
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant