Skip to content

feat(bestie): choose the private channels Bestie can read - #2036

Merged
rileycrane merged 13 commits into
mainfrom
rileycrane/bestie-l4-private-channels
Nov 3, 2026
Merged

rileycrane merged 13 commits into
mainfrom
rileycrane/bestie-l4-private-channels

Conversation

@rileycrane

@rileycrane rileycrane commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

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: #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.ts and tabs.tsx. Whichever lands second will rebase onto the other.

🤖 Generated with Claude Code

buzz-review-completed

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 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.

Comment thread src/bundled/bestie/PrivateChannels.tsx Outdated
))}
{!joined.length && (
<PreferenceRow
title="Not in any of your private channels yet"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 [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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 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}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 [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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 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.

@rileycrane
rileycrane force-pushed the rileycrane/bestie-l2-memory branch from a5d42ff to 74fc28e Compare November 2, 2026 19:09
@rileycrane
rileycrane force-pushed the rileycrane/bestie-l4-private-channels branch from f688371 to a9dc383 Compare November 2, 2026 19:10

@loganj loganj left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 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 bot today. 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] (#2037 features.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: removeMember repeats the refresh / role read / run / confirm sequence in config-channels.ts:195. A shared helper on memberAdministration would avoid a third copy.
  • preference: Remove runs on one click; the Members dialog confirms a removal.

Comment thread src/bundled/bestie/PrivateChannels.tsx Outdated
}
};
const add = (channels: readonly ChannelSummary[]) =>
run(channels, true, (id) => session.memberAdditions.add(id, bestie));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 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 (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 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.

Comment thread src/bundled/bestie/PrivateChannels.tsx Outdated
session.viewer ?? "",
bestie,
);
const addable = others.filter((channel) => canAddMembers(session, channel));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 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.

Comment thread src/bundled/bestie/PrivateChannels.tsx Outdated
</>
)}
<SwitchPreferenceRow
label="Also add Bestie to private channels I create"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

convention: a disabled switch marked "Coming later" is dead UI. Remove it until the feature ships.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Removed in b843b39. It comes back once it works.

@rileycrane
rileycrane force-pushed the rileycrane/bestie-l2-memory branch from 74fc28e to b797061 Compare November 2, 2026 23:10
@rileycrane
rileycrane force-pushed the rileycrane/bestie-l4-private-channels branch from ee46772 to e10c4d5 Compare November 2, 2026 23:10
@rileycrane

rileycrane commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor Author

🤖 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:

  • PrivateChannels.tsx: offline, Add to all tried channels that are only cached (r4446656629). Fixed in 681f97d.
  • PrivateChannels.tsx: keep keyboard focus when a finished action removes its control (r4431137261). Fixed in 7ca7590.

rileycrane added a commit that referenced this pull request Nov 2, 2026
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>
@rileycrane
rileycrane force-pushed the rileycrane/bestie-l2-memory branch from b797061 to 60a14a1 Compare November 2, 2026 23:28
rileycrane added a commit that referenced this pull request Nov 2, 2026
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]>
Base automatically changed from rileycrane/bestie-l2-memory to main November 2, 2026 23:46
rileycrane and others added 7 commits November 2, 2026 15:46
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]>
@rileycrane
rileycrane force-pushed the rileycrane/bestie-l4-private-channels branch from e10c4d5 to a0e2101 Compare November 2, 2026 23:50
rileycrane and others added 4 commits November 2, 2026 16:04
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]>
@rileycrane

Copy link
Copy Markdown
Contributor Author

🤖 @loganj, both preferences from your review are now done in this PR:

  • Remove asks first: de5b059. It uses an alert dialog like the removal step in the Members dialog: Cancel, then a destructive Remove Bestie, with focus starting on Cancel.
  • Shared helper: d70ebeb. The refresh, role read, run and readback are now in one administerMember in channel-members/administration.ts, and config channels and Bestie's Remove both call it. It is a function that takes the capability rather than a new method on it, so the session capability and its test mocks don't change. Config channels behave as before, and their tests pass unchanged. A new unit test covers the helper.

The cached-channel and keyboard-focus notes are fixed in 681f97d and 7ca7590.

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 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.

Comment on lines 64 to +67
const intent = intents.get(key) ?? {};
intents.set(key, intent);
if (options.role) intent.role = options.role;
else delete intent.role;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 [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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Fixed in 1443c53. The new rule applies on the shared addition path:

  • Add already running: memberAdditions.add reuses 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: addChannelMember finishes 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" }),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 [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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 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.

rileycrane and others added 2 commits November 3, 2026 08:54
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 wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 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))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 [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 loganj left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 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.mjs now allows guest. The relay accepts a member adding a new guest (decide_member_put in buzz crates/buzz-core/src/channel.rs).
  • Other callers: the other memberAdditions.add callers (useBringAgents, useNonmemberMentions, huddle composer-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:~150 is safe. The outbox has no admin or role-change receipts: those go through memberAdministration's writer.
  • Refactors: config-channels → administerMember behaves the same, and the compareRecent extraction keeps the sidebar order.
  • Channel filter: privateChannels leaves 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))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 ?? …, then outbox.retry). A guest request can send a saved Bot invitation.
  • The sibling: the member branch (~:198-200) sets intent.id to 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

preference. intent.role ?? (isAgent ? "bot" : …) drops one level of nesting.

pubkey: string,
decide: (
role: MemberRole | undefined,
) => Omit<MemberChange, "pubkey"> | undefined,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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]));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@rileycrane
rileycrane merged commit 7cb3cb2 into main Nov 3, 2026
41 checks passed
@rileycrane
rileycrane deleted the rileycrane/bestie-l4-private-channels branch November 3, 2026 19:05
rileycrane added a commit that referenced this pull request Nov 3, 2026
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]>
dr0uleau added a commit that referenced this pull request Nov 3, 2026
* 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
rileycrane added a commit that referenced this pull request Nov 3, 2026
…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]>
ljknight added a commit to ljknight/buzz-app that referenced this pull request Nov 3, 2026
…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
rileycrane added a commit that referenced this pull request Nov 4, 2026
…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]>
arividar pushed a commit to minniai/minni-buzz-app that referenced this pull request Nov 4, 2026
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]>
arividar pushed a commit to minniai/minni-buzz-app that referenced this pull request Nov 4, 2026
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]>
arividar pushed a commit to minniai/minni-buzz-app that referenced this pull request Nov 4, 2026
…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]>
morgmart added a commit that referenced this pull request Nov 4, 2026
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]>
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.

3 participants