Skip to content

feat(bestie): remove your Bestie, and only local agents can be Bestie for now - #2041

Open
rileycrane wants to merge 8 commits into
rileycrane/bestie-l5-former-bestiefrom
rileycrane/bestie-l6-remove
Open

rileycrane wants to merge 8 commits into
rileycrane/bestie-l5-former-bestiefrom
rileycrane/bestie-l6-remove

Conversation

@rileycrane

@rileycrane rileycrane commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

You can Remove your Bestie, so the account has none. Once the account is known to have no Bestie, check-ins stop on every local agent. For now only agents on this device can be made Bestie, and the app says so.

Check-ins stop when there is no Bestie, just as they do when you change it (#2039).

The main-agent record (kind 30078, d=main-agent, added in #1665). This PR makes its writer and the native signer accept agent: null (none). The record shape and the reader are unchanged: the reader already reads agent: null as none.

Removing writes the record's empty value rather than deleting the record. {version: 1, agent: null} is exactly empty("main-agent") in native-sidebar.ts, which is what an account that never chose a Bestie reads as.

FOUNDATION file: src/features/relay/session.ts changes one type, write: (agent: string | null). It needs a human's review.

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.
  • Cleanup with no Bestie. Once the account is read to have none, every local agent's check-in is stopped. While the read is pending or has failed, or while a choice made here is still saving, nothing is stopped.
  • Local agents only, for now. Change lists only agents with place === "here". Agents → Make my bestie and the Former tab's Make my Bestie again are offered only for them. Each says "Only agents on this device can be your Bestie for now."
  • Saving none. The TS writer and the Rust native signer accept agent: null (diffs below). clearDefaultAgent clears the device cache, then saves none. If the save fails, it brings back the previous choice. Without the Rust change, the desktop app rejected the save, with "Removing your bestie couldn't be saved"; identity/tests.rs now signs, admits and decodes {"version":1,"agent":null}.

Design decisions

  • A never-saved choice counts as none. The reader can't tell the two apart, so on an account that never chose a Bestie, a leftover check-in is stopped once the read succeeds. 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.
  • Local only. A remote agent isn't set up to be Bestie yet. Rejected: offering it and failing later.
TS writer diff
diff --git a/src/features/relay/native-sidebar.ts b/src/features/relay/native-sidebar.ts
index 9d07550de..d1e99bfdd 100644
--- a/src/features/relay/native-sidebar.ts
+++ b/src/features/relay/native-sidebar.ts
@@ -182,8 +182,10 @@ export function nativeSidebar(transport: ReadTransport) {
       const current = await head("main-agent", signal);
       return current.createdAt ? mainAgent(current.payload) : undefined;
     },
-    async writeMainAgent(agent: string, signal: AbortSignal) {
-      if (!agentKey.test(agent)) throw new Error("Invalid main agent intent");
+    /** Saves the main agent the viewer chose, or none for null. */
+    async writeMainAgent(agent: string | null, signal: AbortSignal) {
+      if (agent !== null && !agentKey.test(agent))
+        throw new Error("Invalid main agent intent");
       await mutate(
         "main-agent",
         signal,
diff --git a/src/features/relay/session.ts b/src/features/relay/session.ts
index 1d2fbaa55..0b67f5e18 100644
--- a/src/features/relay/session.ts
+++ b/src/features/relay/session.ts
@@ -1452,7 +1452,8 @@ export function createRelaySession(
     return read && write
       ? Object.freeze({
           read: () => read(bounded(10_000)),
-          write: (agent: string) => queue(() => write(agent, bounded(20_000))),
+          write: (agent: string | null) =>
+            queue(() => write(agent, bounded(20_000))),
         })
       : undefined;
   })();
diff --git a/src/features/relay/transport.ts b/src/features/relay/transport.ts
index 3f4b48d30..ae0f982fb 100644
--- a/src/features/relay/transport.ts
+++ b/src/features/relay/transport.ts
@@ -164,7 +164,8 @@ export interface ReadTransport {
   /** Host-only private account record of the main agent the viewer chose:
    * undefined when never saved. */
   readMainAgent?(signal: AbortSignal): Promise<string | undefined>;
-  writeMainAgent?(agent: string, signal: AbortSignal): Promise<void>;
+  /** Null saves none. */
+  writeMainAgent?(agent: string | null, signal: AbortSignal): Promise<void>;
   readonly profiling?: RelayProfiler;
   /** Verified incoming traffic. The session owns this subscription and fences late delivery. */
   subscribe?(callbacks: LiveCallbacks): LiveSubscription;
Rust signer diff
diff --git a/src-tauri/src/identity.rs b/src-tauri/src/identity.rs
index 1f4f74d6d..c569e35b6 100644
--- a/src-tauri/src/identity.rs
+++ b/src-tauri/src/identity.rs
@@ -532,12 +532,14 @@ fn validate_sidebar_payload(coordinate: &str, value: &serde_json::Value) -> Resu
 
         "main-agent" => {
             let agent = data.get("agent").ok_or_else(invalid)?;
+            // A null agent saves none: the owner removed their Bestie.
             if data.len() != 2
-                || !agent.as_str().is_some_and(|v| {
-                    v.len() == 64
-                        && v.bytes()
-                            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
-                })
+                || !(agent.is_null()
+                    || agent.as_str().is_some_and(|v| {
+                        v.len() == 64
+                            && v.bytes()
+                                .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
+                    }))
             {
                 return Err(invalid());
             }

Try it (just dev, local agent Ada as Bestie with Remembers you and Every hour on, and a hosted agent 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: a hosted agent isn't listed, and the menu says "Only agents on this device can be your Bestie for now." In Agents, the hosted agent's Make my bestie is disabled, with the same line.
  5. Failure path: go offline, then Remove. The dialog says the removal couldn't be saved, and Ada stays Bestie.

Known gaps

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) → #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

rileycrane and others added 8 commits October 30, 2026 14:45
The main-agent record already reads {version: 1, agent: null} as none;
its writer now saves it for null, through the session's queued write.
clearDefaultAgent clears this device's choice and saves none to the
account, bringing the device's choice back if that save fails. The saved
choice's store also says whether the account's choice is known, read or
confirmed by a save here, so none saved can be told from not read yet or
unreadable.

Co-authored-by: eggboy <3986e5374b8c11a274f4a53fa9a68e1e18b53e712f8aa5218add2485bf5a2b09@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Riley Crane <rileycrane@squareup.com>
The Bestie tab has Remove as Bestie under Change. It asks first, saying
the agent stays, its check-ins stop, and its memory section and channels
stay, then saves no Bestie. Once the account is read to have none, every
agent here has its check-ins stopped with the same guards, and one with a
setting still on shows Former Bestie. While the account is read, or if it
can't be, nothing changes.

Co-authored-by: eggboy <3986e5374b8c11a274f4a53fa9a68e1e18b53e712f8aa5218add2485bf5a2b09@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Riley Crane <rileycrane@squareup.com>
A choice or removal asked for while the account is read, or still being
saved, leaves the saved choice unknown until its save succeeds. A failed
removal brings the Bestie back over an automatic pick, not a newer choice.

Co-authored-by: eggboy <3986e5374b8c11a274f4a53fa9a68e1e18b53e712f8aa5218add2485bf5a2b09@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Riley Crane <rileycrane@squareup.com>
… an account

The cleanup waits for the account's choice to be known, and each save
rechecks it, so an agent made Bestie meanwhile keeps its check-ins.
Remove is hidden where no account can save none.

Co-authored-by: eggboy <3986e5374b8c11a274f4a53fa9a68e1e18b53e712f8aa5218add2485bf5a2b09@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Riley Crane <rileycrane@squareup.com>
Co-authored-by: eggboy <3986e5374b8c11a274f4a53fa9a68e1e18b53e712f8aa5218add2485bf5a2b09@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Riley Crane <rileycrane@squareup.com>
… community

Co-authored-by: eggboy <3986e5374b8c11a274f4a53fa9a68e1e18b53e712f8aa5218add2485bf5a2b09@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Riley Crane <rileycrane@squareup.com>
Remove as Bestie writes {version: 1, agent: null}, and the desktop signer
admitted only a 64-hex agent, so Remove always failed in the app with
"couldn't be saved". The main-agent payload now also admits a null agent,
and the signer test signs, admits and decodes one.

Co-authored-by: eggboy <3986e5374b8c11a274f4a53fa9a68e1e18b53e712f8aa5218add2485bf5a2b09@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Riley Crane <rileycrane@squareup.com>
A remote agent isn't set up to be Bestie yet. Change on the Bestie tab
lists only agents on this device, and Agents → Make my bestie is offered
only for one; each says "Only agents on this device can be your Bestie
for now."

Co-authored-by: eggboy <3986e5374b8c11a274f4a53fa9a68e1e18b53e712f8aa5218add2485bf5a2b09@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Riley Crane <rileycrane@squareup.com>
@rileycrane
rileycrane marked this pull request as ready for review October 30, 2026 23:18
@rileycrane
rileycrane requested a review from a team as a code owner October 30, 2026 23:18
@rileycrane rileycrane changed the title feat(bestie)[L6]: remove your Bestie, and only local agents can be Bestie for now feat(bestie): remove your Bestie, and only local agents can be Bestie for now Oct 30, 2026
@rileycrane
rileycrane force-pushed the rileycrane/bestie-l5-former-bestie branch from eeb3b0b to 2372470 Compare October 30, 2026 23:24
@rileycrane
rileycrane force-pushed the rileycrane/bestie-l6-remove branch from 7b9426c to 4bca2e3 Compare October 30, 2026 23:24

@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, with independent Rocket, Mantis and Groot review lanes.

Reviewed head 4bca2e33bb2fb1525d126c16913749d9fb9dfb04 against base 2372470cd869c8d96cf266bd3dfe68765ecfb513. One P3 recommendation inline: give successful Remove a surviving focus destination when it removes its own tab. The documented choice of the first remaining tab is preserved; the issue is keyboard focus, not which tab is selected.

The nullable account record, native signer change and local-agent eligibility are consistent with the stated design in the inspected source. The inherited cleanup-disposal concern remains as reported on the stack’s preceding PR #2039; it is not duplicated inline here. No concrete disclosure issue found in the public description (no attached images) or eight commit messages.

Validation: source only; no tests, builds, apps, native signing, live relay or assistive-technology workflows executed. A single hosted-check snapshot showed CI required successful in run 41144261889; Windows and browser flake checks were skipped, and an older run was cancelled. Incremental test cost and actual completion focus remain unmeasured. This is a non-blocking COMMENT review, not approval or runtime acceptance.

<AlertDialog
title={`Remove ${name} as your Bestie?`}
description={`${name} stays in Agents, with your chats in your DMs. Its check-ins stop. Its memory rules and private channels stay; turn them off in its Former Bestie tab.`}
initialFocus={keep}

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] Give successful removal a surviving focus destination

When this confirmation succeeds, clearDefaultAgent notifies the saved-choice listeners and bestieTab.when becomes false. That removes both this dialog and its original Remove button. In the Agents detail flow, AgentTabs selects the first remaining section but does not move focus there; AgentDetails only performs its fallback focus on initial mount. Thus this newly added flow has no reliable completion handoff to a surviving control. Cancel still has its trigger, and failure keeps the dialog open, so those paths do not cover this case.

Use a surviving host-owned tab/control as the completion focus target (through the shared finalFocus/host handoff), and cover successful removal with the actual tab host mounted. This follows DESIGN.md’s modal return-focus contract while retaining the documented first-tab selection behavior.

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.

2 participants