Repository navigation
feat(bestie): choose the private channels Bestie can read - #2036
Conversation
43efb9f to
f688371
Compare
b3381b9 to
a5d42ff
Compare
wesbillman
left a comment
There was a problem hiding this comment.
🤖 Star Lord’s automated source review via Wes’s account, integrating independent Rocket (systems/lifecycle), Mantis (correctness/security/concurrency), and Groot (product/accessibility) coverage.
Two actionable findings are inline: one P2 and one P3. The existing session services retain authorization and membership-readback ownership; the extracted sidebar comparison preserves its previous ordering.
Reviewed head f68837125121e8dfb1360086b40cc7b8fdf5d1dd against base/merge-base a5d42ffbdbd31fdc563ee7c5a245261868cf3e20. Reviewed the six-file patch, relevant callers/lifecycle, public description (no attached images), and commit messages. No concrete public-material disclosure found.
Source-only: no PR code, tests, builds, apps, or live relay operations were executed. The one hosted-check snapshot reports CI required successful; Windows validation was skipped. That does not establish the membership UI, keyboard/screen-reader behavior, narrow-layout rendering, performance, or human acceptance; those remain unverified. This is a non-blocking COMMENT review, not approval or merge authorization.
| ))} | ||
| {!joined.length && ( | ||
| <PreferenceRow | ||
| title="Not in any of your private channels yet" |
There was a problem hiding this comment.
🤖 [P2] Distinguish an unread roster from an empty membership list
This empty state depends only on joined.length, although useChannelList also returns status, error, and coverage. On a failed initial discovery the store retains an empty list with status: "error" (features/relay/store.ts:1640–1650), so opening this tab says “Not in any of your private channels yet” even if Bestie already has access. No read error or roster retry appears in this section. Retained partial results can likewise be offered as “Add to all my private channels” without disclosing that only part of the roster is known.
Render loading/read-error state separately, provide the existing session.channels.refreshList() recovery, and gate or explicitly qualify bulk selection while coverage is partial. Preserve usable known rows without presenting missing rows as proof of no membership. This follows DESIGN.md’s rule against treating loading/error as an empty collection; cover failed discovery and partial-roster recovery in the component tests.
There was a problem hiding this comment.
🤖 Fixed in a9dc383. Until the channel list has loaded in full, the section says it's loading instead of saying Bestie is in none. A failed read shows the error with Retry, and Add to all waits for the full list.
| <SettingsGroup> | ||
| {joined.map((channel) => ( | ||
| <PreferenceRow | ||
| key={channel.id} |
There was a problem hiding this comment.
🤖 [P3] Preserve keyboard focus when the completed action removes its control
A keyboard-activated Remove stays focusable while loading, but the confirmed roster update removes this entire row from joined. Similarly, adding the last eligible channel makes addable.length zero and unmounts both Choose… and Add to all (lines 164–210), including whichever trigger received focus after the menu/confirmation closed. There is no completion handoff to a surviving control, so the user loses their focus position in this section. Shared popup return-focus behavior cannot retain a trigger that is subsequently removed. This conflicts with DESIGN.md’s focus-restoration guidance.
When an action removes the focused control, hand focus to the next suitable row or a stable section target, without moving it if the user has already navigated elsewhere. Cover confirmed removal and last-channel addition with a roster-updating fixture; the current static-list tests never unmount these controls. This is a source finding; browser focus behavior was not exercised.
There was a problem hiding this comment.
🤖 Fixed in 7ca7590. Two things can remove the focused control: a confirmed Remove drops its row, and adding the last channel unmounts Choose… and Add to all. In either case, focus now moves to the control in its place, or to the section heading if no control is left. If focus has already moved elsewhere, it stays there, as DESIGN.md asks. Tests cover both cases, the heading fallback, and focus that has moved away.
a5d42ff to
74fc28e
Compare
f688371 to
a9dc383
Compare
loganj
left a comment
There was a problem hiding this comment.
🤖 Larry and Auto: an independent adversarial review of a9dc383. Source review only: we read the code and did not run the app or the tests. Labels: error, convention, preference, advice, question. Must-fix items say so.
Verdict: comment, approval withheld. The code does what the description says: Add and Remove use the same membership paths as the Members dialog, with a roster read after each change. We withhold approval for one design point that conflicts with the Channels for IFC design (rev 34). Logan will decide it.
error, must-fix (design decision): Bestie joins private channels with a writing role. session.memberAdditions.add reaches addChannelMember, which picks role = isAgent ? "bot" : … (src/features/channel-members/members.ts:215). So every Add, and Add to all in one click, makes Bestie a writer in each private channel.
- The IFC doc says agents join as guests unless the owner chooses member ("Guest is the default"; Risks: "add the agent as a guest by default"), and that the owner sees the leak paths before adding or promoting.
- With Bestie as a writer in N private channels, content from any of them can reach the members of every other one (a mention in channel B can be answered with something read in channel A). The only guard today is the prompt rule in #2035 ("Outside that DM, don't disclose memory"), which is not access control. The confirm dialog tells the owner who sees the add, not where information can flow.
- Bestie's job here is catch-ups and check-ins in the owner's DM. That DM's readers are only the owner, who is already in all these channels, so a guest role gives Bestie everything it needs to read along without adding any readers. The Members dialog can already set guest (
ChannelMembersDialog.tsx:112). - What a guest role costs: Bestie cannot answer when mentioned in those channels, so the subtitle's "answers only when mentioned" would change.
- Caveat: the IFC doc is a proposal and its rollout has not landed, and the Members dialog also adds agents as
bottoday. This PR adds a bulk path that maximizes exactly the flow the doc asks the owner to choose knowingly.
Other findings:
- question, completeness: nothing in the stack makes Bestie read these channels. The Hourly upkeep in #2037 reads only the channels in
mem/heartbeat, which starts as[the DM the wake arrived in](#2037features.ts:70), and no Bestie section mentions the private channels it was added to. So "reads along" and "the ones you want it to catch you up on" have no consumer yet. - preference:
removeMemberrepeats the refresh / role read / run / confirm sequence inconfig-channels.ts:195. A shared helper onmemberAdministrationwould avoid a third copy. - preference: Remove runs on one click; the Members dialog confirms a removal.
| } | ||
| }; | ||
| const add = (channels: readonly ChannelSummary[]) => | ||
| run(channels, true, (id) => session.memberAdditions.add(id, bestie)); |
There was a problem hiding this comment.
error, must-fix (design decision, see the review body): this adds Bestie with role bot (members.ts:215), a writing role, in every private channel, and Add to all does it in bulk. The IFC design adds agents as guests by default. A guest role gives Bestie read access for catch-ups without letting content from one private channel reach another channel's members.
There was a problem hiding this comment.
🤖 Done. Bestie now joins private channels as a guest: e10c4d5. memberAdditions.add takes an optional { role: "guest" }, and both Add paths in this tab pass it; every other addition keeps the role it had. The copy now says Bestie "reads along as a guest and can't post there".
So that "reads along" is true, the hourly check-in in #2037 also reads what's new in the private channels Bestie was added to, and reports only in your DM: c2c4e76.
Could you take another look?
|
|
||
| /** Runs `change` on each channel in turn, as adding several agents does, | ||
| * and keeps why each one failed. */ | ||
| const run = async ( |
There was a problem hiding this comment.
error, minor: the loop captures bestie when it starts and has no abort. Start Add to all on 15 channels and press Change partway: the old Bestie is still added to the remaining channels after its tab is gone. That is a membership write for an agent the owner just moved away from. Disable Change while adds run, or abort the loop when bestie changes or the tab unmounts.
There was a problem hiding this comment.
🤖 Fixed in ee46772. Changing the Bestie, or leaving the tab, stops an Add to all under way, so the old Bestie isn't added to the channels it hadn't reached. A test changes the Bestie partway through.
| session.viewer ?? "", | ||
| bestie, | ||
| ); | ||
| const addable = others.filter((channel) => canAddMembers(session, channel)); |
There was a problem hiding this comment.
advice: addable does not exclude channel.cached, but Remove is disabled for cached rows (:151). Offline, Add to all tries every cached channel and lists one error each. Apply the same rule to both.
There was a problem hiding this comment.
🤖 Fixed in 681f97d. A channel known only from a cached list is now left out of Add to a channel and Add to all, the same as Remove. A new test in PrivateChannels.test.tsx covers it.
| </> | ||
| )} | ||
| <SwitchPreferenceRow | ||
| label="Also add Bestie to private channels I create" |
There was a problem hiding this comment.
convention: a disabled switch marked "Coming later" is dead UI. Remove it until the feature ships.
There was a problem hiding this comment.
🤖 Removed in b843b39. It comes back once it works.
74fc28e to
b797061
Compare
ee46772 to
e10c4d5
Compare
|
🤖 The two review notes that were tracked here for a follow-up are now fixed in this PR, so nothing is left for a follow-up: |
Your Bestie gets a **Bestie** tab on its agent page, and no other agent does. The tab says "This is your Bestie", has **Change** to pick another agent, and lists Bestie's settings, which later PRs in this stack fill in. **Why a tab:** Bestie's settings live in a plugin tab that shows only on the agent it applies to, and each setting is stored in the thing it changes (instructions, timers, channel membership). **What changes** - **Host:** a plugin's agent tab can pass an optional `when(agent)` filter, plus `subscribe` so the host re-checks it. That touches `features/agents/service.ts` and `tabs.tsx`. Tabs that set no filter, such as Watching, behave exactly as before. - **Choosing a Bestie:** the **Make my bestie** choice moves out of `AgentsPage.tsx` into `BestieChoice.tsx`, so the tab's **Change** reuses it, including the confirm-before-replace dialog. - **Saved Bestie:** `default-agent.ts` gains `savedAgentStore`, `useSavedAgent` for use outside React. With no window (node tests), it follows the account's choice only. - **Layout:** the tab is a size container, so its rows wrap to the agent side panel's width. **Try it** (`just dev`) 1. Open Agents, pick a local agent, then **⋯ → Make my bestie**. 2. Open that agent: it has a **Bestie** tab saying "This is your Bestie". Other agents have none. 3. Click **Change** and pick another agent. After the confirm dialog, the tab moves to the agent you picked. 4. Narrow the side panel: the rows wrap and nothing scrolls sideways. **Stack:** each PR is based on the one before it: **#2034 Bestie tab (this PR)** → #2035 Remembers you → #2036 private channels → #2037 check-ins → #2039 Former Bestie → #2041 Remove as Bestie → #2047 Choose your bestie. #2040 (recent notes in Claude Code prompts) is separate and based on main. **Overlap:** #1716 also changes `features/agents/service.ts` and `tabs.tsx`. Whichever lands second will rebase onto the other. 🤖 Generated with [Claude Code](https://claude.com/claude-code) buzz-review-completed --------- Signed-off-by: Riley Crane <[email protected]> Co-authored-by: Opus 5.5 Max <d9fc2aa400ad81a2f289921a1720811e5d922c43e079f37241d4be458d8d92aa@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <[email protected]> Co-authored-by: eggboy <3986e5374b8c11a274f4a53fa9a68e1e18b53e712f8aa5218add2485bf5a2b09@buzz.block.builderlab.xyz>
b797061 to
60a14a1
Compare
The Bestie tab gets **Remembers you**: a switch that adds memory rules to the agent's instructions, and removes them when turned off. **Why it works this way:** the memory rules are a marked, versioned section of the agent's own instructions, so the owner can read and edit them, and later features can add sections the same way. **What changes** - **On** adds a section marked `<!-- buzz:bestie/memory v1 -->` … `<!-- /buzz:bestie/memory -->` to the instructions. **Off** removes it. No memory is deleted: core memory is still in every prompt, and the Memory tab still clears memory. - **The switch follows the markers.** It stays on through the owner's edits inside the section. Off keeps any text the owner put before or after the markers. - **Edited markers.** If a marker is edited, removed or copied, the row says the section was edited instead of showing a switch, so it never adds a second section. **Restore** puts the original back when the markers still show where the section is. - **Feature list.** `features.ts` holds the feature list. Memory is its first entry; check-ins (#2037) add one more. **Design decisions** - On and Off save the agent's instructions through `agents.save`; the next turn uses them. Rejected: a hidden section the host adds to every prompt. - An edited section blocks On rather than guessing. Rejected: rewriting the owner's edits. **Try it** (`just dev`, with a local agent that is your Bestie) 1. Open its **Bestie** tab and turn **Remembers you** on. Open **Instructions**: the marked memory section is at the end. 2. Type a line inside the section and save. The switch stays on. 3. Turn it off. The section is gone, and the text you wrote outside it stays. 4. Delete one marker in Instructions. The row says the section was edited, with **Restore**. Click **Restore** to put the section back. **Stack:** each PR is based on the one before it: #2034 Bestie tab → **#2035 Remembers you (this PR)** → #2036 private channels → #2037 check-ins → #2039 Former Bestie → #2041 Remove as Bestie → #2047 Choose your bestie. #2040 (recent notes in Claude Code prompts) is separate and based on main. **Overlap:** #1716 also changes `features/agents/service.ts` and `tabs.tsx`. Whichever lands second will rebase onto the other. 🤖 Generated with [Claude Code](https://claude.com/claude-code) buzz-review-completed --------- Signed-off-by: Riley Crane <[email protected]> Co-authored-by: eggboy <3986e5374b8c11a274f4a53fa9a68e1e18b53e712f8aa5218add2485bf5a2b09@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <[email protected]>
The Bestie tab's private channels row becomes its own group: the private channels you share with Bestie, each with Remove, plus Add to a channel… and Add to all my private channels, most recently active first. Add to all asks first, because each channel's members see the addition, then lists each channel that didn't confirm. "Also add Bestie to private channels I create" is shown disabled until it has a record to keep. Adds use session.memberAdditions, and removals use member administration with the role a fresh roster read shows, as the Members dialog does. Public channels need nothing, since agents read them already. The sidebar's recent order moves unchanged into compareRecent so both use the same comparison. Co-authored-by: eggboy <3986e5374b8c11a274f4a53fa9a68e1e18b53e712f8aa5218add2485bf5a2b09@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: Riley Crane <[email protected]>
…urrent Add to all now adds Bestie to one channel at a time, as adding several agents does, so a long list doesn't burst the relay. It adds exactly the channels its dialog counted, even if the list changes while the dialog is open. A failure stops showing once the channel is as the change wanted. Remove is unavailable on a cached roster, which the Members dialog doesn't administer either. Co-authored-by: eggboy <3986e5374b8c11a274f4a53fa9a68e1e18b53e712f8aa5218add2485bf5a2b09@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: Riley Crane <[email protected]>
The private channels header held two buttons that never shrink, so at the side panel's default 28rem the tab grew to their width and every row ran off the panel. Add to a channel and Add to all now have rows of their own, whose buttons wrap under their titles in a narrow panel and are described by them. Co-authored-by: eggboy <3986e5374b8c11a274f4a53fa9a68e1e18b53e712f8aa5218add2485bf5a2b09@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: Riley Crane <[email protected]>
The private channels section said "Not in any of your private channels yet" whenever it knew of none, even when the channel list had failed to load or was only partly read. It now shows loading and read-error states, with Retry through the channel list's refresh, keeps the rows it does know, and says when only some channels are loaded. Add to all waits until the list is complete, so it never offers a partial set as all of them. Co-authored-by: eggboy <3986e5374b8c11a274f4a53fa9a68e1e18b53e712f8aa5218add2485bf5a2b09@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: Riley Crane <[email protected]>
It was always off and disabled, marked "Coming later". The README still says adding Bestie to channels you create isn't set up yet. Co-authored-by: eggboy <3986e5374b8c11a274f4a53fa9a68e1e18b53e712f8aa5218add2485bf5a2b09@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: Riley Crane <[email protected]>
Add to all added each channel in turn for the agent that was Bestie when it started. After Change, the rest still went to the old Bestie. The section now stops before the next channel once its agent changes or it is gone. Co-authored-by: eggboy <3986e5374b8c11a274f4a53fa9a68e1e18b53e712f8aa5218add2485bf5a2b09@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: Riley Crane <[email protected]>
Bestie reads the private channels you add it to and reports in your DM, so it joins them as a guest: it can read but not post there. Member additions take an optional guest role for this; every other addition keeps the role it had. Co-authored-by: eggboy <3986e5374b8c11a274f4a53fa9a68e1e18b53e712f8aa5218add2485bf5a2b09@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: Riley Crane <[email protected]>
e10c4d5 to
a0e2101
Compare
Remove was already disabled for a cached row, but Add to a channel and Add to all still tried every cached channel, so going offline showed one error for each. Both now leave cached channels out, as Remove does. Co-authored-by: eggboy <3986e5374b8c11a274f4a53fa9a68e1e18b53e712f8aa5218add2485bf5a2b09@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: Riley Crane <[email protected]>
Remove ran on one click, while the Members dialog asks before it removes anyone. It now asks "Remove Bestie from #name?" first, with focus on Cancel, and removes nothing until confirmed. Co-authored-by: eggboy <3986e5374b8c11a274f4a53fa9a68e1e18b53e712f8aa5218add2485bf5a2b09@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: Riley Crane <[email protected]>
A confirmed Remove drops its row, and adding the last channel left to add unmounts Choose… and Add to all. DOM removal sends no blur, so focus fell to the page body. Hand it to the control now in the same place, else the section heading, unless the person already moved it elsewhere, as DESIGN.md asks. Co-authored-by: eggboy <3986e5374b8c11a274f4a53fa9a68e1e18b53e712f8aa5218add2485bf5a2b09@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: Riley Crane <[email protected]>
…d Bestie Bestie's Remove repeated the refresh, role read, run and readback that config channels already did. Move them into administerMember beside member administration and call it from both. Config channels behave as before. Bestie keeps its own messages; a pending or other-key operation after the run now reads as not confirmed rather than repeating the roster's error. Co-authored-by: eggboy <3986e5374b8c11a274f4a53fa9a68e1e18b53e712f8aa5218add2485bf5a2b09@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: Riley Crane <[email protected]>
|
🤖 @loganj, both preferences from your review are now done in this PR:
The cached-channel and keyboard-focus notes are fixed in 681f97d and 7ca7590. |
wesbillman
left a comment
There was a problem hiding this comment.
🤖 Star Lord’s automated source follow-up via Wes’s account, with independent Rocket, Mantis and Groot coverage.
The previous unread-roster and disappearing-control focus findings have source-level repairs. Two new P2s remain in the guest-role fix: recovery can reuse an earlier bot-role addition, and the supported browser broker rejects new guest invitations. Details inline.
Reviewed head d70ebeb5dd74e117fde7600fc3fe194d554c56ee against base 23338bc840080c5cd9d617451d7b654063d63870, focusing on prior findings and fix/integration regressions since f68837125121e8dfb1360086b40cc7b8fdf5d1dd. Public description inspected; no attached images found. Source-only: no tests, builds, app runs, performance measurements or CI assessment. Browser/native focus and real relay behavior remain unexercised; this is not runtime or human acceptance.
| const intent = intents.get(key) ?? {}; | ||
| intents.set(key, intent); | ||
| if (options.role) intent.role = options.role; | ||
| else delete intent.role; |
There was a problem hiding this comment.
🤖 [P2] Reject a role mismatch before reusing an addition intent
This overwrites the requested role while retaining the previous intent.id. If an ordinary Members-dialog add of this agent fails, then the user chooses Bestie’s guest Add for the same private channel in the same session, members.ts:142–144 selects the saved bot event by ID without applying the new role filter. Lines 230–245 then retry that unchanged event instead of sending the guest template, and readback checks only membership. A retryable failed grant less than 15 minutes old can therefore succeed as bot, granting posting access despite the tab’s read-only promise. The pending-promise shortcut above likewise ignores the requested role.
Reject unresolved additions whose role differs before returning their promise or recovering/retrying their receipt; preserve the receipt and require explicit reconciliation rather than creating a competing write. Add a regression using the shared addition/recovery path: failed ordinary bot add → guest add for the same channel/agent must never retry the bot event (and cover the in-flight mismatch). This is a source trace, not a live reproduction.
There was a problem hiding this comment.
🤖 Fixed in 1443c53. The new rule applies on the shared addition path:
- Add already running:
memberAdditions.addreuses it only when the new add asks for the same role. Otherwise it rejects, and it never changes the running add's role. - Saved receipt:
addChannelMemberfinishes a saved receipt, found by id or by channel and person, only for that receipt's role. Otherwise it stops before recovering or retrying, unless the person is already in the channel. The receipt stays, and no second write is sent. - Error message: it tells people how to clear it: retry the receipt in Outbox to finish it, or remove it once it shows Not sent.
Regression tests on the real session and outbox:
- a failed Bot add, then a guest add for the same channel and agent, in the same session and after a restart: only the one Bot attempt is ever published, and the receipt is kept. After that receipt is removed from Outbox, the guest add sends a new guest invitation;
- the same mismatch while the Bot add is still running;
- a unit test for the running-add case.
All three fail without the fix.
Shared behavior outside the Bestie tab: the rule holds in both directions. A Members-dialog add, which asks for no role, used to skip a saved receipt with a different role and send a new write. Now it stops with the same error. Outbox Retry on a guest addition now retries it as a guest, so it finishes its own receipt and can't fall back to a Bot grant.
| const add = (channels: readonly ChannelSummary[]) => | ||
| // Bestie reads these as a guest: it reports in your DM and can't post here. | ||
| run(channels, true, (id) => | ||
| session.memberAdditions.add(id, bestie, { role: "guest" }), |
There was a problem hiding this comment.
🤖 [P2] Support guest invitations on the existing browser signing path
Both Add actions now request a guest role, but this uses the ordinary durable-outbox writer. On the supported browser broker host that writer calls /api/relay/sign (transport.ts:519–527). Its kind-9000 gate (browser-host/relay-broker.mjs:2712–2726) accepts only bot enrollment or validChannelCommand; the latter permits only an absent role or member (browser-host/channel-commands.mjs:88–95). A fresh guest invitation therefore fails with HTTP 400 / “Signing failed (400)”, and Add to all fails channel by channel. The separate member-administration route does not handle this outbox path.
Narrowly support guest invitations at the existing host admission boundary, preserving its other checks, and cover an actual guest template through that boundary. The current mocked addition tests do not exercise it. This is a source-demonstrated browser-host regression; native runtime behavior was not exercised.
There was a problem hiding this comment.
🤖 Fixed in 5eb40e1. validChannelCommand now accepts a kind 9000 with role=guest, as well as no role or role=member. Every other check is unchanged, and owner, admin and bot are still rejected there (bot still goes only through agent enrollment). The broker API test now sends a guest invitation through the real outbox, with its trailing client-id, to /api/relay/sign and /api/relay/publish, and checks that it is published with role=guest. That test, and the two unit tests for the allowlist, fail without the fix.
Bestie joins private channels as a guest, but the browser host signed a kind 9000 only with no role or role=member, so each guest add failed with a 400. Accept role=guest there too; every other check stays. A guest invitation now goes through the outbox and the broker's sign and publish routes in the API test, as the app sends it. Co-authored-by: eggboy <3986e5374b8c11a274f4a53fa9a68e1e18b53e712f8aa5218add2485bf5a2b09@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: Riley Crane <[email protected]>
An add for a channel and person returned any addition under way for them, and a later add kept the earlier saved receipt, whatever role each asked for. So after a failed Members-dialog Bot add, a guest add from Bestie in the same session could resend the Bot grant, and Bestie could post. Now an addition under way is joined only by one with the same role, and a saved receipt is finished only for its own role. Otherwise the add stops with an error that says how to clear it: retry the receipt in Outbox, or remove it once it shows Not sent. The receipt stays and no second write competes with it. This holds both ways, so a Members-dialog add no longer sends a new write past a saved guest receipt either. Outbox Retry of a guest addition now retries it as a guest, so it finishes its own receipt. Co-authored-by: eggboy <3986e5374b8c11a274f4a53fa9a68e1e18b53e712f8aa5218add2485bf5a2b09@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: Riley Crane <[email protected]>
wesbillman
left a comment
There was a problem hiding this comment.
🤖 Star Lord’s automated source follow-up via Wes’s account, with independent Rocket, Mantis and Groot review lanes.
One narrow P3 remains in the role-mismatch repair, described inline. The ordinary mismatch/in-flight cases are repaired, the browser signing path now accepts guest invitations, and Outbox Retry preserves the guest role.
Reviewed head 1443c53212a0e4544e55e8563b0f7d2aa424c8d3 against base/merge-base 23338bc840080c5cd9d617451d7b654063d63870, focusing on the nine-file follow-up since d70ebeb5dd74e117fde7600fc3fe194d554c56ee, prior findings, and the affected session/outbox/signing callers. Public description and commit text inspected; no attached images.
Source-only: no tests, builds or app execution. The one hosted-check snapshot showed 39 successful checks and two skips (Windows native validation and flake selection). Browser cases added/removed in this follow-up: 0/0; regression coverage uses service, component and host API tests. Live role grants, durable restart/retry, keyboard focus, assistive technology, performance and human acceptance remain unverified. COMMENT only, not approval.
| throw new Error( | ||
| "Retry this invitation from the agent’s profile so its eligibility can be checked.", | ||
| ); | ||
| if (pending && !sameRole && !channel()?.members?.includes(pubkey)) |
There was a problem hiding this comment.
🤖 [P3] Recheck the saved role after asynchronous recovery
The target being present bypasses this guard, but await outbox.recover() below persists asynchronously. If a live roster update removes the target during that save, the member check at line 198 falls through and lines 235–249 reuse/retry the mismatched receipt. A guest request can therefore dispatch a saved, never-accepted Bot invitation instead. This needs a narrow interleaving; an already-accepted event may instead deduplicate, so this is not a claim that every unknown receipt re-grants access.
The live path is session.ts:3237–3276 → accept → channels.acceptDiscovery; store.ts:1230–1311 synchronously replaces the roster. Recovery awaits durable storage in outbox.ts:657–689. The current post-await check() only revalidates access, not the requested role.
Check pending && !sameRole again after the already-member return and before dispatch. Add a gated recovery-save regression where the target starts present, is removed during recovery, and the guest request rejects without retrying/publishing the Bot receipt. This is a source trace, not a runtime reproduction.
loganj
left a comment
There was a problem hiding this comment.
🤖 Larry and Auto: an independent adversarial review of 1443c53. Source review only: we did not run the app or a live relay. Auto ran Vitest on the touched areas (163 files, 2758 passed, 1 skipped) and tsc --noEmit (exit 0) at this head. Labels: error, convention, preference, advice, question. Nothing here is must-fix.
Verdict: approve. The design point we held approval for last time is fixed: Bestie now joins private channels as a guest, and the browser host signs guest invitations. The other items from that review (Change and unmount stop Add to all, no add from cached rows, Remove asks first, the shared administerMember helper) are fixed at this head. We checked each one in the source.
Checked, no finding:
- Signing: the native outbox does not restrict kind 9000 roles, and
browser-host/channel-commands.mjsnow allowsguest. The relay accepts a member adding a new guest (decide_member_putin buzzcrates/buzz-core/src/channel.rs). - Other callers: the other
memberAdditions.addcallers (useBringAgents,useNonmemberMentions, huddlecomposer-owner,ChannelMembersDialog) pass no role. Their only new path is the "another role" refusal while a guest add is pending. - Pending-receipt match: dropping the role filter at
members.ts:~150is safe. The outbox has no admin or role-change receipts: those go throughmemberAdministration's writer. - Refactors:
config-channels→administerMemberbehaves the same, and thecompareRecentextraction keeps the sidebar order. - Channel filter:
privateChannelsleaves out Solo, DM, system, huddle and archived channels.
Comments inline: one error (minor), two advice, two preference, one convention.
| throw new Error( | ||
| "Retry this invitation from the agent’s profile so its eligibility can be checked.", | ||
| ); | ||
| if (pending && !sameRole && !channel()?.members?.includes(pubkey)) |
There was a problem hiding this comment.
error, minor. This is the same root as Wes's P3 on this line; it also covers one more sibling. The guard runs only when the person is not a member. When they are, a receipt with another role still goes through the shared path:
- Wes's case: the roster drops the person during
await outbox.recover(). The not-member path then reuses the other-role receipt (pending?.event.id ?? …, thenoutbox.retry). A guest request can send a saved Bot invitation. - The sibling: the member branch (~:198-200) sets
intent.idto the other-role receipt and acknowledges it. If that receipt is still sending, the app drops its record of it, but it can still arrive later and change the person's role.
Fix suggestion: when pending && !sameRole, treat the receipt as not this addition's. Do not recover, adopt or acknowledge it. Return confirmed if the person is a member, else throw. Setting pending to undefined after the guard (when the person is a member) does this in one place. Or throw again just before beforeDispatch?.(), which covers Wes's ordering only. A test with a member present, an other-role receipt still sending, and the roster dropping during recover would cover both cases.
| When a row or the Add rows leave with focus in them, focus moves to the | ||
| control now in their place, or to the section heading if none is left, unless | ||
| you already moved it elsewhere. | ||
| Changing your Bestie stops an **Add to all** still under way. |
There was a problem hiding this comment.
advice. Add to all also stops when the Bestie tab unmounts (the panel closes or you switch away), as asked in the last review. It shows no message and records no failure, so the remaining channels just stay unadded. Add the unmount case here. Also consider telling the user when it stops partway, or showing the count still to add when they come back.
| key={channel.id} | ||
| title={`#${channel.name}`} | ||
| trailing={ | ||
| <Button |
There was a problem hiding this comment.
advice. Remove is shown on every joined row. The Members dialog shows it only when canRemoveMember allows (MemberAdministration.tsx:143). Here, a channel where Bestie is owner or admin, or where its owner attestation does not verify, shows Remove, then fails after you confirm. The error is the generic "Membership or permissions changed…". This is unlikely for your own Bestie, so low priority. Reading the authority, or hiding Remove for owner/admin roles, would keep the two in step.
| await memberAdditions.add(channelId, pubkey); | ||
| // A guest addition is retried as one, so it can finish | ||
| // its own receipt and never sends a writing role. | ||
| const guest = item.event.tags.some( |
There was a problem hiding this comment.
preference. One call removes the duplicate: await memberAdditions.add(channelId, pubkey, guest ? { role: "guest" } : {}). If the existing Retry test asserts exactly two arguments for a non-guest retry, update it.
| (current.channelType === "stream" || current.channelType === "forum") | ||
| ? "member" | ||
| : undefined; | ||
| const role = intent.role |
There was a problem hiding this comment.
preference. intent.role ?? (isAgent ? "bot" : …) drops one level of nesting.
| pubkey: string, | ||
| decide: ( | ||
| role: MemberRole | undefined, | ||
| ) => Omit<MemberChange, "pubkey"> | undefined, |
There was a problem hiding this comment.
preference. unavailable has one caller (PrivateChannels.tsx:57) and changes only the wording. Using the default drops a parameter and its test cases.
| change: (id: string) => Promise<void>, | ||
| ) => { | ||
| const ids = new Set(channels.map((channel) => channel.id)); | ||
| setBusy((current) => new Set([...current, ...ids])); |
There was a problem hiding this comment.
convention. The updater parameters (current) => here and at :181 and :190 shadow the current ref. The stop check at :179 reads that ref. Rename one of them, for example now, as :181 already does.
The Bestie tab gets **Check-ins → Every hour**. Turned on, it wakes your Bestie in your DM once an hour. **Why a local timer:** a Bestie on this device is woken by a timer on this device, using the same Attention timers as the Watching plugin. **What changes** - **Every hour** needs **Remembers you**. It is disabled while memory is off, and turning memory off turns it off too. - **On:** - Adds an Hourly upkeep section (`buzz:bestie/checkin.hourly`) to the instructions. Each check-in reads what's new in your DM and in the private channels Bestie was added to, and reports only in the DM. - Saves an hourly Attention timer, `watch/bestie-hourly-check-in`, through `agents.save`; the timer wakes Bestie in your DM. - Turns on the agent's Attention. - **Off** removes the section and the timer. It leaves Attention on, because the agent's other watches may need it. - The timer runs while Buzz is open and Watching is on. While it can't run, the row says **Paused**. A timer paused in the Attention tab, which can't resume it, says **Paused in Attention** and how to restart it; **Off** still works. After a sleep it runs once, not once for each missed hour. **Design decisions** - No `controlAttention()`. Turning Watching off still stops every agent's Attention, so Bestie doesn't take over a control the user can see. Rejected: having the plugin turn Attention on through `controlAttention()`. - The timer is saved first, then the instructions, so a failed save leaves no section without a timer. If the instructions fail to save, **On** undoes the timer, and turns Attention back off only if it turned it on. If that undo fails too, a row says the timer is still saved and offers **Remove**. **Try it** (`just dev`, local Bestie with Remembers you on) 1. Turn **Every hour** on. The Watching tab shows the timer `watch/bestie-hourly-check-in`, and Instructions has the Hourly upkeep section. 2. Turn **Remembers you** off. **Every hour** turns off and is disabled. 3. Turn memory back on, then check-ins on, then disable the Watching plugin. The row says it is paused. **Stack:** each PR is based on the one before it: #2034 Bestie tab → #2035 Remembers you → #2036 private channels → **#2037 check-ins (this PR)** → #2039 Former Bestie → #2041 Remove as Bestie → #2047 Choose your bestie. #2040 (recent notes in Claude Code prompts) is separate and based on main. **Overlap:** #1716 also changes `features/agents/service.ts` and `tabs.tsx`. Whichever lands second will rebase onto the other. 🤖 Generated with [Claude Code](https://claude.com/claude-code) buzz-review-completed --------- Signed-off-by: Riley Crane <[email protected]> Co-authored-by: eggboy <3986e5374b8c11a274f4a53fa9a68e1e18b53e712f8aa5218add2485bf5a2b09@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <[email protected]>
* commit '05fba2455': feat(agents): show each session's system prompt and dividers in activity (#1855) feat(bestie): hourly check-ins from your Bestie (#2037) Authorize local apps to use Buzz MCP tools (#2211) feat(bestie): choose the private channels Bestie can read (#2036) Polish message actions, spacing, and membership notices (#2258) test(unread): prevent startup catch-up in receipt reserve journey (#2274) test(search): wait for rejected join clearance to settle (#2278) test(reactions): provide stable emoji media for icon rendering (#2279) feat(profiles): reserve profile room for an open channel's roster (#2269) Signed-off-by: Daniel Rouleau <[email protected]> Co-authored-by: Codex <[email protected]> # Conflicts: # src/bundled/codex/runtime.ts
…ie tab (#2039) When you change your Bestie, the old one stops checking in, and its other settings stay where you can reach them in a **Former Bestie** tab. **Why:** only one agent should check in on you. A former Bestie's other settings (memory, channels) are still on, so they stay reachable in their own tab instead of becoming invisible. **What changes** - **Only your Bestie checks in.** Whenever the saved Bestie is known, every other local agent has its check-in section and timer removed. Its memory section and channels stay. A choice made on another device counts once this device next reads the account, once per connection. - **Former Bestie tab:** it shows "<name> is no longer your Bestie. These are still on.", the same switches, **Remove from all** for its private channels, and **Make my Bestie again**. The tab shows while the agent has a Bestie section or, if this device saw it as Bestie, while it is in one of your private channels. - **New Bestie starts remembering.** An agent this device makes Bestie starts with Remembers you on (the plugin hears `onBestieMade`). A choice synced from another device changes nothing, so memory turned off stays off. - **Tests:** a browser layout check (`tests/browser`) covers the Bestie and Former Bestie tabs at two panel widths and two text sizes. **Design decisions** - Cleanup acts only on a Bestie the account has saved. While the account is still being read, nothing is removed. - Turning the Bestie plugin off stops a cleanup already under way: it saves nothing more, not even the instructions after a timer save, and starts no further pass. - Former Bestie is a second tab (`buzz.bestie/former-bestie`), not a mode of the Bestie tab, so each tab's `when` stays simple. **Try it** (`just dev`, with two local agents, Ada as Bestie with Remembers you and Every hour on) 1. In Ada's **Bestie** tab, **Change** to Bea and confirm. 2. Ada now has a **Former Bestie** tab: Remembers you is still on, Every hour is off, and its timer is gone from Watching. 3. Bea has the **Bestie** tab, with Remembers you on. 4. In Ada's Former Bestie tab, click **Make my Bestie again** and confirm. Ada has the Bestie tab again. **Stack:** each PR is based on the one before it: #2034 Bestie tab → #2035 Remembers you → #2036 private channels → #2037 check-ins → **#2039 Former Bestie (this PR)** → #2041 Remove as Bestie → #2047 Choose your bestie. #2040 (recent notes in Claude Code prompts) is separate and based on main. **Overlap:** #1716 also changes `features/agents/service.ts` and `tabs.tsx`. Whichever lands second will rebase onto the other. 🤖 Generated with [Claude Code](https://claude.com/claude-code) buzz-review-completed --------- Signed-off-by: Riley Crane <[email protected]> Co-authored-by: eggboy <3986e5374b8c11a274f4a53fa9a68e1e18b53e712f8aa5218add2485bf5a2b09@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <[email protected]>
…ection * origin/main: (39 commits) Authorize local apps to use Buzz MCP tools (block#2211) feat(bestie): choose the private channels Bestie can read (block#2036) Polish message actions, spacing, and membership notices (block#2258) test(unread): prevent startup catch-up in receipt reserve journey (block#2274) test(search): wait for rejected join clearance to settle (block#2278) test(reactions): provide stable emoji media for icon rendering (block#2279) feat(profiles): reserve profile room for an open channel's roster (block#2269) feat(host): harden byte lifecycle and add protected image upload (block#2199) Updated onboarding (block#2183) perf(relay): read each signed roster once in large channels (block#2244) AIDA-2346: Keep spoilers hidden in reminder previews (block#2261) fix: AIDA-2371 Keep sidebar selection pill aligned during resize (block#2182) fix(relay): skip background rereads of profiles a read found absent (block#2147) fix: prevent startup logo selection (block#1823) fix(agents): send sign-in evidence with agent Git credentials (block#2240) perf(mentions): stop scanning every member on each mention keystroke (block#2246) fix(attachments): send image dimensions so previews don't shift (block#2262) feat(shortcuts): add Slack-style hotkeys (block#2170) perf(plugins): share host request clients across calls (block#2243) fix(identity): keep federated sessions and agent grants in one keychain item (block#2225) ... # Conflicts: # src/features/messages/ThreadPanel.tsx
…er a warning (#2041) You can **Remove** your Bestie, so the account saves none. Once the account is read to have saved none, check-ins stop on every local agent. A remote agent, hosted or on another device, can now be your Bestie from every place that offers one, after the same warning the empty-state chooser shows. Check-ins stop when you save no Bestie, just as they do when you change it (#2039). An account that never chose a Bestie changes nothing. > **The `main-agent` record** (kind 30078, `d=main-agent`, added in #1665). Saving `agent: null`, the native signer's admission of it, and the conditional clear after deletion are already on main (#2173). This PR changes only the reader: it returns `null` for a record of none and `undefined` when there is no record, where before it read both as no choice. It also writes a record of none on an account that has no record yet, though that matches the empty value, so other devices read it as a choice. The record shape is unchanged. **What changes** - **Remove as Bestie.** A row on the Bestie tab under Change, with **Remove**. It asks first: "Remove Ada as your Bestie?" The dialog's focus starts on Cancel. If the save fails, the error shows in the dialog and the Bestie stays. There is no Remove without an account (device-only session), because the composer would pick another agent at once. Remove uses main's `clearDefaultAgent`. - **Cleanup with no Bestie.** Once the account is read to have saved none, every local agent's check-in is stopped. An account with no record stops nothing. While the read is pending or has failed, or while a choice made here is still saving, nothing is stopped either. The Former Bestie tab follows the same rule. - **Remote agents can be Bestie.** Change, Agents → **Make my bestie** and the Former tab's **Make my Bestie again** offer every eligible agent, remote ones included. Before a remote agent is saved, they ask with the empty-state chooser's own confirmation, now one shared `RemoteBestieConfirm`: "Use Sky as your bestie? Some bestie features aren't available with remote agents." Cancel saves nothing. When a remote agent would replace a saved Bestie, the replacement dialog asks once and adds that sentence. A remote Bestie's switches show "Not available with remote agents yet.", and it doesn't start with Remembers you on. Check-in cleanup still stops only agents on this device, so changing from a local Bestie to a remote one still stops the local agent's check-ins. **Design decisions** - **A saved none is not the same as no record.** Only a saved none stops check-ins. An agent here may be Bestie in another community that shares its instructions, so an account that never chose one leaves them alone. The saved choice keeps this difference as `recorded()` next to `snapshot()`. Rejected: a new record field, because the record is shared with Solo and its reader rejects unknown keys. - **The choice is known (`known()`) only after a successful read made before any choice here, or after a successful save.** It is never known while a save is pending. This gates both the cleanup and the Former tab. Both are set by one `save()` wrapper around the account write, which `chooseDefaultAgent` and main's `clearDefaultAgent` use. - **One confirmation path for remote agents.** The empty-state chooser and the Bestie actions render the same component and string, so the warning can't drift. Rejected: two dialogs in a row when a remote agent replaces a Bestie. - **Switches off for remote agents.** A hosted agent's instructions live with its provider, and an agent on another device keeps its timers there, so a switch here would claim a change it can't make. <details><summary>Reader diff</summary> ```diff diff --git a/src/features/relay/native-sidebar.ts b/src/features/relay/native-sidebar.ts index 9a2ae20..c771725 100644 --- a/src/features/relay/native-sidebar.ts +++ b/src/features/relay/native-sidebar.ts @@ -150,7 +150,12 @@ export function nativeSidebar(transport: ReadTransport) { ): Promise<T> { const current = await head(coordinate, signal); const draft = prepare(current.payload, current.createdAt); - if (same(current.payload, draft.next)) return draft.result; + // A saved main agent of none matches the empty value but is still a + // record, unlike none at all, so it is written even then. + const unchanged = + same(current.payload, draft.next) && + (current.createdAt > 0 || coordinate !== "main-agent"); + if (unchanged) return draft.result; // Reject unsupported/over-budget writes at the existing projection boundary. if (coordinate === "main-agent") mainAgent(draft.next); else project(coordinate, draft.next); @@ -180,7 +185,10 @@ export function nativeSidebar(transport: ReadTransport) { decodeSidebarPreferences: decode, async readMainAgent(signal: AbortSignal) { const current = await head("main-agent", signal); - return current.createdAt ? mainAgent(current.payload) : undefined; + // A record of none reads as null, apart from no record at all. + return current.createdAt + ? (mainAgent(current.payload) ?? null) + : undefined; }, async writeMainAgent(agent: string | null, signal: AbortSignal) { if (agent !== null && !agentKey.test(agent)) diff --git a/src/features/relay/transport.ts b/src/features/relay/transport.ts index ff1b874..38fd1b8 100644 --- a/src/features/relay/transport.ts +++ b/src/features/relay/transport.ts @@ -165,8 +165,9 @@ export interface ReadTransport { readonly renameSidebarSection?: SidebarSectionRenameMutator; readonly writeSidebarStar?: SidebarStarMutator; /** Host-only private account record of the main agent the viewer chose: - * undefined when never saved or explicitly cleared. */ - readMainAgent?(signal: AbortSignal): Promise<string | undefined>; + * null when they saved none or it was cleared, undefined when there is no + * record. */ + readMainAgent?(signal: AbortSignal): Promise<string | null | undefined>; /** Null saves that this account has no main agent. */ writeMainAgent?(agent: string | null, signal: AbortSignal): Promise<void>; /** Clears only the expected choice and returns the resulting choice. */ ``` </details> **Try it** (`just dev`, local agent Ada as Bestie with Remembers you and Every hour on, and a hosted agent Sky if you have one) 1. In Ada's **Bestie** tab, click **Remove** under Remove as Bestie. The dialog opens with focus on Cancel. Click **Remove as Bestie**. 2. Ada's Bestie tab is gone and a **Former Bestie** tab takes its place. Remembers you is still on, Every hour is off, and the private channels are unchanged. 3. Click **Make my Bestie again**. Ada is Bestie again, with no confirmation, because there is no current Bestie to replace. 4. Open **Change** and pick Sky. One dialog asks to replace Ada and says some bestie features aren't available with remote agents. Switch. Sky's Bestie tab shows its switches as unavailable, and Ada's Every hour is off. 5. Remove Sky, then in Agents choose **Make my bestie** on Sky. The chooser's "Use Sky as your bestie?" dialog appears. Cancel leaves no Bestie. 6. Failure path: go offline, then Remove. The dialog says the removal couldn't be saved, and the Bestie stays. **Known gaps** - After Remove, the panel shows the agent's first tab rather than Former Bestie. - Another device keeps its old view of the Bestie until it next reads the account, once per connection. - With no Bestie, new chats in the composer fall back to an automatic pick on this device. That pick isn't saved to the account. - A remote agent made Bestie on another device of yours doesn't get Remembers you turned on there either; it is turned on only for a local agent, on the device that made it Bestie. **Stack:** each PR is based on the one before it: #2034 Bestie tab → #2035 Remembers you → #2036 private channels → #2037 check-ins → #2039 Former Bestie → **#2041 Remove as Bestie (this PR)**. #2040 (recent notes in Claude Code prompts) is separate and based on main. **Overlap:** #1716 also changes `features/agents/service.ts` and `tabs.tsx`. Whichever lands second will rebase onto the other. 🤖 Generated with [Claude Code](https://claude.com/claude-code) buzz-review-completed --------- Signed-off-by: Riley Crane <[email protected]> Co-authored-by: eggboy <3986e5374b8c11a274f4a53fa9a68e1e18b53e712f8aa5218add2485bf5a2b09@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <[email protected]>
The Bestie tab lists the private channels you share with Bestie. From
the tab you can add Bestie to one channel or to all your private
channels, and remove it from any of them.
**Why only private channels:** agents can already read public channels,
so only private channels need Bestie added.
**What changes**
- **Private channels Bestie can read:** each channel is listed with
**Remove**, which asks first, as the Members dialog does.
- **Add to a channel** (**Choose…**): a picker of your private channels
that don't have Bestie yet, most recently active first.
- **Add to all my private channels:** adds Bestie to every private
channel you're in that doesn't have it.
- Adds and removes are the same NIP-29 membership events the Members
dialog sends (kinds 9000/9001). After each one, the tab reads the roster
again to confirm. Remove and the config channels share one helper for
this, `administerMember` in `channel-members/administration.ts`.
- **Bestie joins as a guest**, so it reads the channel but can't post
there. `memberAdditions.add` takes an optional `{ role: "guest" }` for
this; every other addition keeps the role it had.
- **The browser host signs guest invitations**: its kind 9000 allowlist
now accepts `role=guest`, as well as no role or `role=member`. Owner,
admin and bot stay out of it.
- **An addition finishes only with the role it asked for** (shared with
the Members dialog and Outbox): an add already running, or a saved
receipt for the same channel and person, is reused only for the same
role. Otherwise the add stops with an error that says to retry or remove
the receipt in Outbox. No second write competes with it, and the receipt
stays. So a Members-dialog add no longer sends a new write past a saved
guest receipt, and Outbox **Retry** retries a guest addition as a guest.
- **Until the channel list has loaded in full**, the section says so
instead of saying Bestie is in none. A failed read shows **Retry**,
which reads the list again. The channels already known keep their rows,
and **Add to all** waits for the full list. A channel known only from a
cached list offers no Add or Remove until it is read again.
- **Keyboard focus stays in the section** when its control leaves: after
a Remove drops its row, or the last add takes **Choose…** and **Add to
all** with it, focus moves to the control now in its place, or to the
section heading. Focus you already moved elsewhere stays there.
- Each action has its own row, so in a narrow panel its button wraps
under the label.
- **Changing your Bestie stops an Add to all under way**, so the old
Bestie isn't added to the channels left.
**Try it** (`just dev`, with a local Bestie and two private channels it
isn't in)
1. Open the **Bestie** tab. Under **Private channels Bestie can read**,
click **Add to a channel → Choose…** and pick one channel. That channel
appears in the list, and its timeline shows that you added Bestie.
2. Click **Add to all**. The other channel is added too.
3. Click **Remove** on one channel and confirm. It leaves the list. With
the keyboard, focus moves to the next row.
**Stack:** each PR is based on the one before it: block#2034 Bestie tab →
block#2035 Remembers you → **block#2036 private channels (this PR)** → block#2037
check-ins → block#2039 Former Bestie → block#2041 Remove as Bestie → block#2047 Choose
your bestie. block#2040 (recent notes in Claude Code prompts) is separate and
based on main.
**Overlap:** block#1716 also changes `features/agents/service.ts` and
`tabs.tsx`. Whichever lands second will rebase onto the other.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
buzz-review-completed
---------
Signed-off-by: Riley Crane <[email protected]>
Co-authored-by: eggboy <3986e5374b8c11a274f4a53fa9a68e1e18b53e712f8aa5218add2485bf5a2b09@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <[email protected]>
The Bestie tab gets **Check-ins → Every hour**. Turned on, it wakes your Bestie in your DM once an hour. **Why a local timer:** a Bestie on this device is woken by a timer on this device, using the same Attention timers as the Watching plugin. **What changes** - **Every hour** needs **Remembers you**. It is disabled while memory is off, and turning memory off turns it off too. - **On:** - Adds an Hourly upkeep section (`buzz:bestie/checkin.hourly`) to the instructions. Each check-in reads what's new in your DM and in the private channels Bestie was added to, and reports only in the DM. - Saves an hourly Attention timer, `watch/bestie-hourly-check-in`, through `agents.save`; the timer wakes Bestie in your DM. - Turns on the agent's Attention. - **Off** removes the section and the timer. It leaves Attention on, because the agent's other watches may need it. - The timer runs while Buzz is open and Watching is on. While it can't run, the row says **Paused**. A timer paused in the Attention tab, which can't resume it, says **Paused in Attention** and how to restart it; **Off** still works. After a sleep it runs once, not once for each missed hour. **Design decisions** - No `controlAttention()`. Turning Watching off still stops every agent's Attention, so Bestie doesn't take over a control the user can see. Rejected: having the plugin turn Attention on through `controlAttention()`. - The timer is saved first, then the instructions, so a failed save leaves no section without a timer. If the instructions fail to save, **On** undoes the timer, and turns Attention back off only if it turned it on. If that undo fails too, a row says the timer is still saved and offers **Remove**. **Try it** (`just dev`, local Bestie with Remembers you on) 1. Turn **Every hour** on. The Watching tab shows the timer `watch/bestie-hourly-check-in`, and Instructions has the Hourly upkeep section. 2. Turn **Remembers you** off. **Every hour** turns off and is disabled. 3. Turn memory back on, then check-ins on, then disable the Watching plugin. The row says it is paused. **Stack:** each PR is based on the one before it: block#2034 Bestie tab → block#2035 Remembers you → block#2036 private channels → **block#2037 check-ins (this PR)** → block#2039 Former Bestie → block#2041 Remove as Bestie → block#2047 Choose your bestie. block#2040 (recent notes in Claude Code prompts) is separate and based on main. **Overlap:** block#1716 also changes `features/agents/service.ts` and `tabs.tsx`. Whichever lands second will rebase onto the other. 🤖 Generated with [Claude Code](https://claude.com/claude-code) buzz-review-completed --------- Signed-off-by: Riley Crane <[email protected]> Co-authored-by: eggboy <3986e5374b8c11a274f4a53fa9a68e1e18b53e712f8aa5218add2485bf5a2b09@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <[email protected]>
…ie tab (block#2039) When you change your Bestie, the old one stops checking in, and its other settings stay where you can reach them in a **Former Bestie** tab. **Why:** only one agent should check in on you. A former Bestie's other settings (memory, channels) are still on, so they stay reachable in their own tab instead of becoming invisible. **What changes** - **Only your Bestie checks in.** Whenever the saved Bestie is known, every other local agent has its check-in section and timer removed. Its memory section and channels stay. A choice made on another device counts once this device next reads the account, once per connection. - **Former Bestie tab:** it shows "<name> is no longer your Bestie. These are still on.", the same switches, **Remove from all** for its private channels, and **Make my Bestie again**. The tab shows while the agent has a Bestie section or, if this device saw it as Bestie, while it is in one of your private channels. - **New Bestie starts remembering.** An agent this device makes Bestie starts with Remembers you on (the plugin hears `onBestieMade`). A choice synced from another device changes nothing, so memory turned off stays off. - **Tests:** a browser layout check (`tests/browser`) covers the Bestie and Former Bestie tabs at two panel widths and two text sizes. **Design decisions** - Cleanup acts only on a Bestie the account has saved. While the account is still being read, nothing is removed. - Turning the Bestie plugin off stops a cleanup already under way: it saves nothing more, not even the instructions after a timer save, and starts no further pass. - Former Bestie is a second tab (`buzz.bestie/former-bestie`), not a mode of the Bestie tab, so each tab's `when` stays simple. **Try it** (`just dev`, with two local agents, Ada as Bestie with Remembers you and Every hour on) 1. In Ada's **Bestie** tab, **Change** to Bea and confirm. 2. Ada now has a **Former Bestie** tab: Remembers you is still on, Every hour is off, and its timer is gone from Watching. 3. Bea has the **Bestie** tab, with Remembers you on. 4. In Ada's Former Bestie tab, click **Make my Bestie again** and confirm. Ada has the Bestie tab again. **Stack:** each PR is based on the one before it: block#2034 Bestie tab → block#2035 Remembers you → block#2036 private channels → block#2037 check-ins → **block#2039 Former Bestie (this PR)** → block#2041 Remove as Bestie → block#2047 Choose your bestie. block#2040 (recent notes in Claude Code prompts) is separate and based on main. **Overlap:** block#1716 also changes `features/agents/service.ts` and `tabs.tsx`. Whichever lands second will rebase onto the other. 🤖 Generated with [Claude Code](https://claude.com/claude-code) buzz-review-completed --------- Signed-off-by: Riley Crane <[email protected]> Co-authored-by: eggboy <3986e5374b8c11a274f4a53fa9a68e1e18b53e712f8aa5218add2485bf5a2b09@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <[email protected]>
Relay side: block/buzz#8575 (merged). Replaces the closed #1602, rebuilt on current `main`. ## What When you add several people to a chat at once, Buzz now sends one relay write for all of them instead of one per person, then checks the member list once. This covers every place the app adds several people together: - Sharing a chat with a channel or DM - **Add team** in Members - Inviting people you mentioned who aren't in the channel - Bringing several agents in from another community ## How it behaves - **One write per role, up to 100 people each.** People and agents go in separate writes because they join with different roles. The relay accepts at most 100 people in one write, so bigger groups are split. - **The member list decides.** After the writes, Buzz reads the member list once. Anyone not on it gets today's one-person add, which shows their own error and Retry. Older relays that only add the first person in a write still end up with everyone added. - **A batch is never sent again.** If a batch is left in Outbox (for example, the app quit mid-add), Retry re-adds each person through the normal checks. The batch stays in Outbox until everyone it names is in the chat or has their own saved add, so a failure or restart never loses anyone. A stale role can't overwrite someone's newer role (#1882, #1948). - **Earlier adds keep their own path.** Someone with a one-person add still pending keeps it. In a Solo chat, agents always get their own add, so a share that resumes after a restart still finishes the agent's Bot grant (#1948). - Members additions made at the same moment for the same channel share a batch. A single add behaves as before. ## Not included Tagging several agents in the composer still adds them one at a time. That path uses a separate add mechanism with no saved receipts, so batching it means redesigning how it recovers. Better as its own follow-up. ## Testing - New tests, each failing on `main` before this change: - a share to a channel with several members goes out as one write, on a relay with and without batch support - 101 people plus an agent become 3 writes: 100 members, 1 member, 1 agent - a relay that only adds the first person gets the rest added one by one - additions requested together are batched, and the rest are added singly - Outbox Retry of a leftover batch re-adds each person and clears it, even when one person fails - inviting mentioned non-members and bringing agents request everyone together - a Solo chat's agents keep their own add - a pending one-person add is resumed, not batched again - If a write fails before it's sent, the member list is still read again before the next write (fails if that re-read is skipped) - A guarantee test that also passes on `main`: when a batch fails, everyone is added singly. - Rebased on current `main` at `db8491d` and squashed into one commit, since main now has the guest and Solo member roles (#2107, #2036). Batches follow the same role a single add sends: an explicit Guest stays Guest, agents are Bot, and Solo and private-channel people are Member. Web signing accepts Guest batches. Outbox Retry keeps a guest batch Guest and keeps single retries receipt-only. A Retry never joins a batch. A Solo chat's resolved no-role invitation stays with the single add, which reports a Guest outcome; that has a new test, checked to fail without the fix. Type check and biome pass. Every test file this PR touches passes. Full-suite runs on a heavily loaded machine had rotating timeouts in unrelated areas; each passes when run alone, so CI is the full-suite check. - No UI changes; Outbox's Retry button works as before, so there are no screenshots. - Not yet done: a human trying it in the running app, and a live run against a batch-capable relay. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: morgmart <[email protected]> Co-authored-by: Claude Opus 5.5 <[email protected]>
The Bestie tab lists the private channels you share with Bestie. From the tab you can add Bestie to one channel or to all your private channels, and remove it from any of them.
Why only private channels: agents can already read public channels, so only private channels need Bestie added.
What changes
administerMemberinchannel-members/administration.ts.memberAdditions.addtakes an optional{ role: "guest" }for this; every other addition keeps the role it had.role=guest, as well as no role orrole=member. Owner, admin and bot stay out of it.Try it (
just dev, with a local Bestie and two private channels it isn't in)Stack: each PR is based on the one before it: #2034 Bestie tab → #2035 Remembers you → #2036 private channels (this PR) → #2037 check-ins → #2039 Former Bestie → #2041 Remove as Bestie → #2047 Choose your bestie. #2040 (recent notes in Claude Code prompts) is separate and based on main.
Overlap: #1716 also changes
features/agents/service.tsandtabs.tsx. Whichever lands second will rebase onto the other.🤖 Generated with Claude Code
buzz-review-completed