Commit Graph

2179 Commits

Author SHA1 Message Date
Michael Neale
ea389a4fb4 fix(mesh): the shared-compute dot moves only when something is happening
The sidebar dot pulsed constantly on a healthy row. Motion was wired to
standing states as well as to events, and "capacity you have not joined"
and "sharing with no agent using it" are both permanently true, so the dot
moved forever and stopped meaning anything.

Motion is now reserved for two facts:

  - activity - work in flight on this node (usage.inflight)
  - progress - a start or stop in flight

Standing states say what they are through tone, tooltip, and the popover's
own prompt, none of which animate. Amber now pulses while a model loads,
where a still dot read as stuck through minutes of work.

Brightness only, on the dot itself. An earlier revision animated a
transparent halo ring scaled to 2x while fading to zero, which is
imperceptible at 8px; the correction scaled the dot, which shifted the
row's optical baseline and read as jitter. Opacity is what Tailwind's
`pulse` animates and what Buzz already uses to mean "working"
(ChannelWorkingBadge, AgentStatusBadge).

Short work is no longer dropped: `inflight` is sampled every few seconds,
so a request that starts and finishes between two polls was never seen at
all. The last sighting is latched briefly - it never invents activity, it
only stops forgetting it immediately.

Both share/stop switches read one derived value, so the row and the popover
cannot disagree about an action while a model loads.

Signed-off-by: Michael Neale <michael.neale@gmail.com>
2026-08-06 14:04:23 +10:00
Alessandro Joabar
649df222a0 Merge remote-tracking branch 'origin/main' into micspiral/mesh-share-ux
* origin/main: (32 commits)
  fix(mobile): merge relay recounts with locally seen thread replies (#4633)
  fix(desktop): enable message editing in Inbox (#2198)
  relay: fuzz WebSocket 1012 restart-close timing on graceful drain (BUZZ_DRAIN_JITTER_MS) (#4542)
  fix(desktop): outline the selected community (#4969)
  fix(desktop): clamp thread panel to channel surface (#4965)
  style(messages): increase username contrast (#4948)
  fix(desktop): rename generic attachment action from 'Attach image' to 'Attach file' (#2381) (#4304)
  fix(reactions): support max-length custom emoji (#3833)
  feat(desktop): allow leaving your final community (#3621)
  fix(buzz-agent): recover from context-window 400s instead of sticking (#4946)
  docs(persona-pack): fix stale desktop import instructions (#4500)
  fix(desktop): route macos notification clicks (#4799)
  feat(mobile): sync themes per community (#3767)
  feat(desktop): sync themes per community (#3653)
  feat(desktop): cap OpenClaw agent parallelism at 5 (#4019)
  fix(buzz-agent): scope handoff cap per turn, not per session lifetime (#4805)
  Fix mobile message timeline bounce (#4862)
  Polish mobile bottom sheets and profile cards (#4911)
  Fix media attachment actions (#4849)
  fix(desktop): remove join API token control (#4897)
  ...

Signed-off-by: Alessandro Joabar <sandro@squareup.com>
2026-08-05 19:25:04 -04:00
Alessandro Joabar
fe846295b3 shared compute nomenclature
Signed-off-by: Alessandro Joabar <sandro@squareup.com>
2026-08-05 19:20:24 -04:00
Tom Brow
06b60e682d fix(mobile): merge relay recounts with locally seen thread replies (#4633)
## Summary

- Keep mobile thread reply badges current by merging relay recounts with
replies observed locally.
- Retain replies in the local channel store while continuing to filter
them from the main timeline.
- Match the thread summary behavior already used on desktop, including
the reply count, latest reply time, and participant avatars.

## Why

On mobile, the "N replies" badge under a channel message can stall at a
stale count or remain missing after a reply arrives. This makes the
badge unreliable and can cause people to miss replies.

The badge has two inputs: best-effort recounts from the relay and
replies the client sees arrive. Mobile previously let any positive relay
recount override the local view, while also discarding replies from its
local message store. A delayed or lost recount, or a reply received
after the recount, could therefore leave the badge behind.

This change combines both inputs by using the higher reply count, the
later last-reply time, and a merged participant list. Relay timestamps
have one-second precision, so equal timestamps do not prove that a
recount included a locally observed reply. Comparing counts preserves
that reply instead of trusting recency alone. Desktop already uses this
merge behavior.

## Validation

At commit `4e3356636f5ad62e8f07910af305c532186c6c08` with a clean
worktree:

- `flutter test` for mobile: 1105 passed, 1 skipped
- `flutter analyze` for mobile: no issues found
- Reverting the merge so a positive relay recount shadows local replies
fails 4 of the new tests, including the same-second and
reply-after-recount cases. Restoring the store-level reply drop fails
both new provider tests.

Added tests:

-
[`timeline_message_test.dart`](https://github.com/block/buzz/tree/main/mobile/test/features/channels/timeline_message_test.dart),
covering relay-only recounts, a reply newer than the recount, a reply in
the same second as the recount, a lost recount, a zero recount, nested
replies at the root and at the reply they answer, a deleted reply, and
participant merging and capping.
-
[`channel_messages_provider_test.dart`](https://github.com/block/buzz/tree/main/mobile/test/features/channels/channel_messages_provider_test.dart),
covering a live reply reaching the store while staying out of the main
timeline, and a reply newer than the relay recount raising the badge.

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: npub12uu53ml9upy7ww9apmtv6vm0u8xlcldx7znsjvwgsr7uvy5g0kssw943ca <573948efe5e049e738bd0ed6cd336fe1cdfc7da6f0a70931c880fdc612887da1@buzz.block.builderlab.xyz>
2026-08-05 16:06:54 -07:00
Tom Brow
eb6a37569d fix(desktop): enable message editing in Inbox (#2198)
### What changed?

Inbox detail now gives the current user's messages the same
ownership-gated Edit action as channel view. Editing reuses the existing
composer and mutation flow, preserves attachment metadata, and refreshes
structural overlays so the edited content appears immediately.

Foreign authors' messages remain non-editable, including grouped Inbox
conversations whose selected event is not the representative item.

| Own Inbox message exposes **Edit message**. | Saving the edit updates
the Inbox detail immediately. |
| --- | --- |
| ![Before: Edit message action in Inbox
detail](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/2198/inbox-edit-before.png)
| ![After: edited Inbox message
content](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/2198/inbox-edit-after.png)
|

### Why?

Inbox rows did not pass an edit handler into the shared message action
bar, so a user's own messages could be edited from channel view but not
from Inbox detail.

### How is it tested?

Desktop checks, unit tests, and the full local CI gate passed. The
focused Inbox Playwright regression passed 3 consecutive runs and covers
current-user edit/save, foreign and archived-channel denial, and
attachment preservation when a just-sent reply is edited before its
relay echo arrives.

Added tests:

-
[`inbox-edit.spec.ts`](https://github.com/block/buzz/blob/inbox-message-edit-action/desktop/tests/e2e/inbox-edit.spec.ts)
-
[`inboxViewHelpers.test.mjs`](https://github.com/block/buzz/blob/inbox-message-edit-action/desktop/src/features/home/lib/inboxViewHelpers.test.mjs)

*🤖 This PR was authored [with an
agent](buzz://message?channel=7f2d7e02-f4d5-4fb0-a426-0ca60ed3a1c3&id=c09ee04d18399b90296c3f932d22ab0377fa05f7e690ec7b08c36483ee633fbb).*

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
Signed-off-by: npub1ft62tztwwm2x9xamk25smmuaj4sfckdkldksruf2x2jwqalffkrq0g7arr <4af4a5896e76d4629bbbb2a90def9d95609c59b6fb6d01f12a32a4e077e94d86@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1ft62tztwwm2x9xamk25smmuaj4sfckdkldksruf2x2jwqalffkrq0g7arr <4af4a5896e76d4629bbbb2a90def9d95609c59b6fb6d01f12a32a4e077e94d86@sprout-oss.stage.blox.sqprod.co>
2026-08-05 17:00:23 -06:00
Brad Seiler
e14fff74d0 relay: fuzz WebSocket 1012 restart-close timing on graceful drain (BUZZ_DRAIN_JITTER_MS) (#4542)
## Problem

On SIGTERM the relay sends every live WebSocket a **1012 Service
Restart** close frame via `ConnectionManager::drain_all()` — all in the
same instant (`main.rs` shutdown task → `state.rs::drain_all`). On a pod
holding thousands of sessions, that makes every client reconnect
simultaneously: the thundering-herd reconnect behind the DB pool-timeout
bursts observed on each rolling deploy. Client-side jitter can't fix
this — the desktop client *resets* its backoff to base on a 1012 and
reconnects with only ±25% jitter (`relayClientSession.ts`), so the
spread has to come from the server.

## Change

Add `BUZZ_DRAIN_JITTER_MS` (default `0` = unchanged behavior). The two
paths are kept **deliberately separate** so the default is byte-for-byte
the previously shipped shutdown:

- **Jitter off (`0`/unset, the default):** the original synchronous,
all-at-once `drain_all()` runs unchanged — queue the 1012 on each
connection's control channel, cancel, return. No new machinery on the
default path.
- **Jitter on (`> 0`):** a separate async
`drain_all_jittered(jitter_ms)` spreads each connection's restart close
over an independent uniform delay in **`[1, jitter_ms]`**. Each delayed
close travels a dedicated `RestartClose` channel; the writer flushes the
1012 frame and **acknowledges the flush over a oneshot**, so drain waits
for confirmed delivery (up to `RESTART_CLOSE_ACK_TIMEOUT` = 5s) rather
than assuming it, falling back to cancellation if the channel is
full/closed or the ack times out. The drain future is **owned and
awaited** by the shutdown task, and the 30s hard-drain backstop is
aborted only after a clean drain — so a clean roll exits `0`.

The two methods can be unified and the old one dropped later once the
jittered path is proven for all cases.

- **`config.rs`** — `drain_jitter_ms`: non-negative parse, clamped to
`MAX_DRAIN_JITTER_MS` = **20s** (leaving 10s of the 30s budget for
flush). Junk fails loudly at startup; **empty/whitespace-only is treated
as unset (jitter off)** so a `BUZZ_DRAIN_JITTER_MS=""` kill switch does
not crashloop the relay (matches the sibling env vars in this file).
- **`state.rs`** — `drain_all()` (unchanged synchronous default) +
`drain_all_jittered()` (jittered + flush-ack). Both set the sticky
`draining` flag before the first await. A registration that lands
mid-shutdown always self-signals via the **immediate** control-frame +
cancel path — jitter smears already-established sockets, not late
arrivals.
- **`main.rs`** — shutdown task dispatches: `drain_jitter_ms == 0` →
`drain_all()`, else `drain_all_jittered(...).await`.

## Safety

- **Default off is the currently-committed path.** With jitter unset/0
the shutdown runs the original synchronous `drain_all()` — no restart
channel, no ack wait. Safe to deploy dark and dial up.
- **Shutdown-boundary race preserved.** Sticky flag set before any
await; a late registration self-signals its close with no jitter.
- **Owned + backstopped.** The jittered drain future is awaited; the 30s
hard-drain `process::exit(1)` remains the ceiling. `MAX_DRAIN_JITTER_MS`
(20s) + `RESTART_CLOSE_ACK_TIMEOUT` (5s) = 25s, inside the 30s budget;
5s pre-sleep + 25s = 30s against `terminationGracePeriodSeconds: 60`.

## Known behavior to note (not a blocker, flagged from review)

On a **successful** flush the jittered path deliberately does not cancel
the connection token — teardown then depends on the client echoing our
Close, or on process exit. Compliant clients echo; a silent client rides
to the 30s hard exit. The default (jitter-off) path cancels
deterministically as before.

## Tests

- `config::tests::drain_jitter_defaults_off_and_rejects_junk` — default
off, `20000`, clamp `60000`→`20000`, explicit `0`, junk `"soon"` fails,
**empty `""` and whitespace-only treated as off**.
- `state::tests::drain_all_is_immediate` — default path queues frame +
cancels synchronously.
- `state::tests::drain_all_sends_restart_close_and_cancels_every_conn`,
`drain_all_full_control_buffer_still_cancels`,
`register_after_drain_self_signals_restart_close_and_cancel`.
-
`state::tests::drain_all_jittered_defers_close_until_within_jitter_window`
(paused time).
-
`state::tests::drain_all_jittered_waits_for_writer_acknowledgement_without_cancelling`.
-
`state::tests::drain_all_jittered_cancels_when_restart_channel_is_full_or_closed`.
- `state::tests::drain_all_jittered_cancels_when_flush_ack_times_out`
(paused time — the 5s ack-timeout fallback).

Validation at `46c690940`: `cargo fmt -p buzz-relay --check`, `cargo
clippy -p buzz-relay --all-targets -- -D warnings`, and the drain/config
unit suite all clean. Local live SIGTERM test with a real relay process
+ 200 NIP-42-authenticated sockets — see the PR comment for the
before/after distribution and exit codes.

## Rollout

Ship with default `0`, then set `BUZZ_DRAIN_JITTER_MS` (e.g.
10000–20000) on bb-block first, watch the roll-window pool-timeout
metric, then bb-public. `""` is a safe kill switch. Complements the
preStop `sleep` (stops routing before close).

---------

Signed-off-by: npub1srl70fhzyu3fsnahl06vw2czvqc2w3ds37hyzvjnk8ve8f03ngcqg9le2w <80ffe7a6e22722984fb7fbf4c72b026030a745b08fae413253b1d993a5f19a30@buzz.block.builderlab.xyz>
Signed-off-by: npub128x7j3pwgm4vs8yra3c42fcgcwcvh94g3luwzkqa376du2q6l0esqcrwch <51cde9442e46eac81c83ec71552708c3b0cb96a88ff8e1581d8fb4de281afbf3@buzz.block.builderlab.xyz>
Signed-off-by: Brad Seiler <seiler@squareup.com>
Co-authored-by: npub1srl70fhzyu3fsnahl06vw2czvqc2w3ds37hyzvjnk8ve8f03ngcqg9le2w <80ffe7a6e22722984fb7fbf4c72b026030a745b08fae413253b1d993a5f19a30@buzz.block.builderlab.xyz>
Co-authored-by: npub128x7j3pwgm4vs8yra3c42fcgcwcvh94g3luwzkqa376du2q6l0esqcrwch <51cde9442e46eac81c83ec71552708c3b0cb96a88ff8e1581d8fb4de281afbf3@buzz.block.builderlab.xyz>
2026-08-05 18:54:47 -04:00
Taylor Ho
005fe54d02 fix(desktop): outline the selected community (#4969)
**Category:** improvement
**User Impact:** Selected communities now use a clear offset outline
without tinting or covering their icon.

**Problem:** The selected community state replaced the icon surface with
an accent fill, obscuring image icons and changing the tile's content
treatment. Hover also changed the fill, text color, shape, and opacity,
making navigation states visually jumpy.

**Solution:** Preserve each community tile's neutral surface and content
while using a primary CSS outline for selection and a lighter outline
for hover. The transparent outline offset leaves the space around image
edges unpainted, and adjusted spacing prevents neighboring outlines from
colliding.

<img width="200" height="152" alt="Screen Recording 2026-08-05 at 3 23
32 PM"
src="https://github.com/user-attachments/assets/5c25b1c0-4be8-41c4-8f1d-ad0010310c92"
/>


<details>
<summary>File changes</summary>

**desktop/src/features/sidebar/ui/CommunityRail.tsx**
Replaces selected and hover fills with offset outlines, keeps icon
presentation stable across states, and adjusts rail and tooltip spacing
for the new outline geometry.

**desktop/tests/e2e/community-rail.spec.ts**
Covers the shared active/inactive surface, radius, text color, opacity,
and outline behavior, including hover invariants.

</details>

## Reproduction steps

1. Run the desktop app with two or more communities.
2. Give the active community an image icon.
3. Confirm the active icon keeps its original image and receives a 2px
primary outline with a transparent 2px gap.
4. Hover another community and confirm only a lighter outline appears;
its fill, text color, opacity, and corner radius remain unchanged.
5. Switch communities and confirm the outline follows the active
community.

## Screenshots

**Full desktop context**

![Selected community outline in the desktop
app](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/4969/selected-community-full.png)

---------

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
2026-08-05 22:44:07 +00:00
Taylor Ho
24c7995740 fix(desktop): clamp thread panel to channel surface (#4965)
**Category:** fix
**User Impact:** Expanded thread panels now stay fully visible within
the desktop channel area instead of being cut off.

**Problem:** The resize handler clamped the thread panel against the
full window width, even though the panel renders inside a narrower
channel surface. On a 1720px window, this allowed a 1160px requested
width where only 1111px could render, leaving persisted and visible
geometry out of sync.

**Solution:** Clamp resizing against the measured channel-surface width
so the stored width matches what the layout can render while preserving
the minimum 300px main pane.

<details>
<summary>File changes</summary>

**desktop/src/features/channels/ui/ChannelScreen.tsx**
Passes the measured channel-surface width into the thread-panel sizing
hook.

**desktop/src/shared/hooks/useThreadPanelWidth.ts**
Clamps drag-resize updates against the available channel width instead
of the full viewport.

**desktop/tests/e2e/threadpane-ultrawide.spec.ts**
Adds a 1720px regression proving the requested and rendered panel widths
match, while retaining the ultrawide expansion case.

</details>

### Reproduction steps

1. Open a channel thread in the desktop app at a 1720×900 window size.
2. Drag the thread panel's left resize handle toward the left edge to
expand it as far as possible.
3. Confirm the panel remains fully bounded inside the channel surface
and the main channel pane remains at least 300px wide.
4. Reload the channel and confirm the persisted expanded width renders
without clipping.

### Testing

- `pnpm --dir desktop build:e2e`
- `pnpm --dir desktop exec playwright test
tests/e2e/threadpane-ultrawide.spec.ts` — 2 passed
- Push hooks: `desktop-check` and `desktop-test` passed
- `git diff --check origin/main..HEAD`

### Screenshot

![Expanded thread panel remains bounded at
1720×900](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/4965/threadpane-expanded-after-fix.png)

### Related issue

None found.

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
2026-08-05 15:40:40 -07:00
Taylor Ho
cda33978b1 style(messages): increase username contrast (#4948)
**Category:** improvement
**User Impact:** Message usernames are now bolder, making it easier to
distinguish who said what at a glance.

**Problem:** Usernames and surrounding message metadata had too little
visual separation, which made message headers slower to scan.
**Solution:** Increase the shared message-author label from semibold to
bold while preserving its existing size, spacing, and interaction
behavior.

<details>
<summary>File changes</summary>

**desktop/src/features/messages/ui/MessageHeader.tsx**
Raises the shared message-author font weight so standard and system
message usernames gain consistent visual contrast.

</details>

## Reproduction steps

1. Open a channel containing messages from multiple people or agents.
2. Compare each message username with its timestamp and message body.
3. Confirm the username renders in bold while the surrounding typography
and layout remain unchanged.

## Screenshots

| Before | After |
| --- | --- |
| ![Message usernames
before](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/4948/before-message-usernames.png)
| ![Message usernames
after](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/4948/after-message-usernames.png)
|

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
2026-08-05 15:39:07 -07:00
iroiro147
d42d60d64e fix(desktop): rename generic attachment action from 'Attach image' to 'Attach file' (#2381) (#4304)
Fixes #2381.

## What was broken

The message composer's paperclip accepts generic attachments — images,
videos, PDFs, archives, and any other supported file — but its tooltip
and accessible name still read **"Attach image"**. Sighted users might
reasonably believe the control is image-only, and screen-reader users
get an incomplete description of what the button does.

## The fix

Rename the accessible name and tooltip text on the generic composer
paperclip in `MessageComposerToolbar.tsx`:

- `aria-label` — `"Attach image"` → `"Attach file"`
- `<TooltipContent>` — `"Attach image"` → `"Attach file"`

Plus update the 12 affected Desktop e2e selectors across five spec files
to reference the new accessible name:

- `desktop/tests/e2e/file-attachment.spec.ts` (2 selectors)
- `desktop/tests/e2e/spoiler.spec.ts` (2)
- `desktop/tests/e2e/composer-image-draw.spec.ts` (2)
- `desktop/tests/e2e/image-attachment-gallery.spec.ts` (4)
- `desktop/tests/e2e/video-attachment.spec.ts` (2)

## Scope (per the issue)

The feedback screenshot dialog
(`desktop/src/features/settings/ui/SendFeedbackDialog.tsx`) is
**unchanged** — that dialog itself is image-only, so its "Attach image"
wording is accurate. This PR only touches the generic composer control.

## Test plan

- All **105** unit tests in
`desktop/src/features/messages/ui/*.test.mjs` pass locally.
- Verified no remaining `"Attach image"` string outside the
intentionally preserved feedback dialog:
  ```sh
  grep -rn '"Attach image"' desktop/
  # → only hits in SendFeedbackDialog.tsx
  ```
- The six e2e specs are only exercised in CI; the selector updates are
mechanical and verified by grep to reference the new a11y name.

## Blast radius

- **Files touched**: `MessageComposerToolbar.tsx` (two strings); five
e2e spec files (12 selector updates).
- **User-facing behaviour**: one tooltip + one screen-reader name
change; no functional or visual changes otherwise.
- **No API or state change.**

## Out of scope

- The feedback dialog's "Attach image" wording — kept per the issue's
own "Scope" guidance.
- Any i18n plumbing — Buzz Desktop doesn't currently localize these
strings.

Signed-off-by: Sarthak Singh <sarthak.singh@juspay.in>
Signed-off-by: Ravneet Arora <rarora@squareup.com>
Co-authored-by: Ravneet Arora <rarora@squareup.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 15:16:32 -07:00
Taylor Ho
2ea9385015 fix(reactions): support max-length custom emoji (#3833)
**Category:** fix
**User Impact:** Custom emoji with valid 64-character names can now be
used as reactions without errors.

**Problem:** Buzz accepted 64-character custom emoji names during
registration, but rejected them as reactions after the required
surrounding colons made the payload 66 characters. Validation also
differed between desktop, SDK, relay, and storage boundaries.

<img width="554" height="47" alt="image"
src="https://github.com/user-attachments/assets/4013452f-210e-4dd3-9003-f45ff3b28dc8"
/>

**Solution:** Keep the product limit at 64 ASCII characters for custom
emoji names, enforce it consistently when emoji sets are registered, and
allow only valid matching custom reaction payloads up to 66 characters.
Widen the reaction projection to preserve the wrapped payload while
retaining the existing 64-character limit for ordinary reactions.

<details>
<summary>File changes</summary>

**crates/buzz-sdk/src/builders.rs**
Defines the shared custom emoji boundaries and covers accepted
64-character and rejected 65-character shortcodes.

**crates/buzz-relay/src/handlers/ingest.rs**
Validates emoji-set shortcodes and permits 66-character reactions only
when they are valid colon-wrapped custom emoji with a matching tag.

**crates/buzz-db/src/event.rs**
Adds storage regression coverage for maximum-length custom emoji
reactions.

**crates/buzz-db/src/migration.rs**
Verifies the reaction column migration is applied correctly.

**desktop/src/shared/api/customEmoji.ts**
Enforces the existing 64-character shortcode maximum during desktop
normalization and registration/import.

**desktop/src/shared/api/customEmoji.test.mjs**
Covers the desktop shortcode boundary.

**migrations/0027_long_reaction_payloads.sql**
Widens stored reaction payloads to 66 characters for the two required
surrounding colons.

**schema/schema.sql**
Keeps the desired schema aligned with the migration.

</details>

## Reproduction Steps

1. Register or import a custom emoji whose ASCII shortcode is exactly 64
characters.
2. Select that emoji as a reaction to a message.
3. Confirm the reaction publishes, persists, and renders without an
error.
4. Attempt to register a 65-character shortcode and confirm it is
rejected.
5. Publish an ordinary or malformed reaction over 64 characters and
confirm the relay rejects it.

## Verification

- `cargo test -p buzz-sdk`: 243 passed
- `cargo test -p buzz-db`: 94 passed, 152 Postgres-required tests
ignored
- `pnpm test` in `desktop`: 3,859 passed
- `cargo test -p buzz-relay`: 795 passed, 9 existing
Postgres-unavailable failures, 35 ignored; new reaction boundary tests
pass directly
- `cargo fmt --all -- --check`
- `git diff --check`

Originating Buzz channel: `f2ec9671-d78e-4cde-894c-9f4c458c7f1f`

---------

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz>
2026-08-05 21:02:57 +00:00
Taylor Ho
719f9730d4 feat(desktop): allow leaving your final community (#3621)
**Category:** improvement
**User Impact:** People can leave their final Buzz community and return
to **Join or create a community** without losing their signed-in
identity.

**Problem:** Buzz Desktop blocked people from leaving when only one
community remained. Its existing remove action also changed local
configuration without ending relay membership.

**Solution:** Allow the final community to be left. Buzz now asks the
relay to end membership, removes the community locally only after
acceptance, and returns the person to the community selector while
keeping their identity signed in. If other communities remain, Buzz
switches to one of them. Relay rejection or timeout keeps the community
in place and shows an actionable retry error.

<details>
<summary>File changes</summary>

**desktop/src/features/communities/leaveCommunity.ts**
Adds signed kind 28936 publishing for active and inactive community
relays with actionable timeout handling.

**desktop/src/features/communities/leaveCommunity.test.mjs**
Covers event shape, relay selection, acceptance gating, rejection,
timeout messaging, and cleanup.

**desktop/src/features/communities/useCommunities.tsx**
Allows final-community removal and clears community-specific storage
without touching identity.

**desktop/src/features/communities/resolveCommunityRemoval.test.mjs**
Covers final, active, and inactive community removal state transitions.

**desktop/src/app/useCommunityNavigationTransitions.ts**
Gates local removal on relay acceptance and routes to a fallback
community or setup selector.

**desktop/src/app/AppShell.tsx**
Passes the asynchronous leave operation through shell entry points.

**desktop/src/features/communities/ui/EditCommunityDialog.tsx**
Replaces the local-only remove action with a pending-aware Leave
Community action that retains actionable errors.

**desktop/src/features/communities/ui/CommunitySwitcher.tsx**
Enables leaving the final community and carries the asynchronous
callback.

**desktop/src/features/sidebar/ui/AppSidebar.tsx**
Carries the asynchronous leave callback through sidebar props.

**desktop/src/features/sidebar/ui/CommunityRail.tsx**
Enables leaving the final community from rail settings.

**desktop/src/features/sidebar/ui/SidebarProfileCard.tsx**
Carries the asynchronous leave callback through profile community
settings.

**desktop/src/testing/e2eBridge.ts**
Teaches the mock relay to accept NIP-43 leave events.

**desktop/tests/e2e/community-rail.spec.ts**
Updates leave interactions and verifies final-community setup
navigation, storage cleanup, and identity preservation.

</details>

### Reproduction steps

1. Run Buzz Desktop with a signed-in identity and one joined community.
2. Open Community settings and choose **Leave Community**.
3. Confirm the app shows **Join or create a community** and the existing
identity remains signed in.
4. Repeat with two communities and confirm leaving the active one
switches cleanly to the remaining community.
5. Reject or withhold the relay `OK` response and confirm the community
remains configured with an actionable error in the dialog.

### Test plan

- `pnpm check`
- `pnpm build`
- `pnpm test` (3,913 passing)
- `pnpm build:e2e && pnpm exec playwright test
tests/e2e/community-rail.spec.ts --grep "final community"`


<img width="557" height="316" alt="image"
src="https://github.com/user-attachments/assets/b628182f-cba5-451d-ae4b-bee8d8dd19aa"
/>

---------

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: npub14ndfusear8wdpe4kss8h7juc7wjk78atnqzf63zvppcpneknv4sq6x9370 <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz>
Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz>
Co-authored-by: npub14ndfusear8wdpe4kss8h7juc7wjk78atnqzf63zvppcpneknv4sq6x9370 <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz>
2026-08-05 14:02:43 -07:00
Will Pfleger
ed4b3e7afa fix(buzz-agent): recover from context-window 400s instead of sticking (#4946)
Provider `context_length_exceeded` 400s permanently wedged agent
sessions: the turn errored, the oversized history persisted in the
in-memory session, and the usage baseline stayed frozen at the last
successful sub-threshold reading (failed requests report no usage), so
the preflight handoff gate never fired again — every later prompt failed
identically until an agent restart. The byte-truncation fallback never
intervened because it is a request-body limiter (`estimated_bytes`), not
a context-window defence; at context-window scale it is a measured
no-op.

This adds the reactive recovery path:

- **Typed classification.** `AgentError::LlmContextExceeded` is
classified at both non-success provider terminals — the shared `post()`
(Anthropic, OpenAI, Databricks) and `openrouter_post()` — on `status ==
400` plus a context-window body match, so ordinary 400s stay terminal.
- **Forced handoff.** A context-400 forces a summarize-handoff that
bypasses `should_handoff()` and `BUZZ_AGENT_MAX_HANDOFFS`, bounded by
its own per-turn budget (`MAX_CONTEXT_RECOVERIES_PER_RUN = 3`).
- **Shrink ladder.** The summarize prompt budget halves from the
observed rejected history size — not from `max_context_tokens`, the
number the provider just contradicted — rung to rung, with a 4096-byte
floor. A summarize call rejected for the same reason takes the next rung
instead of re-sticking. At the floor (overflow dominated by unshrinkable
frame: system prompt, tool schemas, live prompt) recovery is refused and
the provider error surfaces clearly instead of self-healing.
- **Baseline reset.** The stale usage baseline is cleared when a request
fails, so the preflight gate cannot stay frozen sub-threshold on
retries.

Named behavior changes:

1. **Anthropic and OpenRouter errors now carry the `(model)` stamp.**
Provider arms return their `Result` into the central error mapper
instead of early-returning past it, making the code match its documented
single-convergence contract at that mapper.
2. **`max_rounds` now counts completions the loop acts on.** A request
rejected with a context-400 that is then successfully recovered refunds
its round before the retry, paired 1:1 with a consumed recovery rung, so
the round cap is neither weakened nor able to drop a recovered turn
unanswered.

Related: #4805 — the complementary proactive fix (per-session
handoff-cap kill switch that let sessions grow to the provider wall).
#4805 prevents reaching the wall; this PR recovers at it.

---------

Co-authored-by: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@buzz.block.builderlab.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-08-05 16:58:25 -04:00
Som Samantray
ccdaa16161 docs(persona-pack): fix stale desktop import instructions (#4500)
## Summary

The desktop app no longer imports persona packs the way the docs
described. `PERSONA_PACK_SPEC.md` and the `meadow-core` example still
pointed users at a `.zip` import through "My Teams / My Agents → Import"
and a future "Install Pack" button — none of that exists anymore. The
app only imports agent/team **snapshots** (`.agent.json`/`.agent.png`,
`.team.json`/`.team.png`), and a persona-pack `.zip` is explicitly
rejected.

## Fix

Updated both docs to describe the current import paths (Agents / Agent
teams sections, snapshot files only) and added a note that persona packs
and desktop snapshots are separate, non-interchangeable formats today.

Fixes #4468

---------

Signed-off-by: SomSamantray <92726151+SomSamantray@users.noreply.github.com>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
2026-08-05 13:53:33 -07:00
Kalvin C
7334ad1e16 fix(desktop): route macos notification clicks (#4799)
## Summary
- deliver macOS notifications through `UNUserNotificationCenter`
- route notification clicks to the referenced channel or thread through
the existing frontend activation path
- preserve click targets across cold startup and frontend remounts with
a small process-wide activation queue
- keep Linux notification activation behavior unchanged

## Architecture
A single `UNUserNotificationCenterDelegate` is installed during Tauri
setup. Each notification stores its navigation target in `userInfo`. On
click, Rust queues the target before emitting a wake-up event; the
frontend atomically drains the queue and dispatches the existing
notification action. The queue is the source of truth, which prevents
cold-start loss and duplicate delivery.

## Validation
Verified at `a81241611d617becf7640bee6fe56b5cdb4d0fab`:
- Biome format/check and lint
- TypeScript typecheck
- repository and desktop-Tauri `cargo fmt --check`
- repository and desktop-Tauri Clippy with `-D warnings`
- full pre-push desktop tests and Tauri workspace checks/tests
- desktop production build

Manual macOS validation passed: after explicitly ad-hoc signing the
local bundle with `xyz.block.buzz.app`, the operator confirmed real
Notification Center delivery and click navigation.

<details>
<summary>Local macOS test procedure</summary>

Tauri's generated ad-hoc signing identifier is not accepted by
`UNUserNotificationCenter`. Re-sign the local bundle with its bundle
identifier and keep other Buzz copies closed:

```bash
just desktop-release-build
APP="$HOME/.cache/cargo-target/aarch64-apple-darwin/release/bundle/macos/Buzz.app"
codesign --force --deep --sign - \
  --identifier xyz.block.buzz.app \
  --entitlements desktop/src-tauri/Entitlements.plist \
  "$APP"
codesign --verify --deep --strict --verbose=2 "$APP"
pkill -x buzz-desktop || true
open -n "$APP"
```

</details>

Buzz channel: `55e2bfca-1b38-48fb-9dc2-584d400501f3`

---------

Signed-off-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz>
Signed-off-by: am <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz>
Co-authored-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz>
2026-08-05 13:29:41 -07:00
Taylor Ho
05150c1188 feat(mobile): sync themes per community (#3767)
**Category:** new-feature
**User Impact:** Mobile now keeps each community’s appearance in sync
with desktop, including theme, accent, and system-mode preference.

**Problem:** Appearance choices were device-local, so the same account
could look different between desktop and mobile. Live sync could also
stop after the relay closed a subscription.

**Solution:** Store each community’s encrypted appearance preference on
its relay using the shared desktop wire contract, restore it from a
local identity-scoped cache, and apply replacement events live. Closed
subscriptions now recover with guarded backoff and fetch the latest
preference so no update is lost during the gap.

<details>
<summary>File changes</summary>

**mobile/lib/app.dart**
Connects community appearance state to the authenticated app lifecycle.

**mobile/lib/features/settings/accent_picker_page.dart**
Aligns mobile accent choices and selection behavior with the shared
catalog.

**mobile/lib/features/settings/settings_page/appearance_section.dart**
Clarifies the active appearance and hides accent controls when the Buzz
theme owns its neutral accent.

**mobile/lib/features/settings/theme_picker_page.dart**
Persists catalog theme choices through the community-scoped provider.

**mobile/lib/shared/theme/accent_colors.dart**
Matches desktop’s accent catalog and wire values.

**mobile/lib/shared/theme/buzz_theme.dart**
Keeps Buzz visually neutral without discarding the user’s stored accent
for other themes.

**mobile/lib/shared/theme/community_theme_preference.dart**
Defines and validates the versioned desktop-compatible appearance
payload.

**mobile/lib/shared/theme/community_theme_provider.dart**
Coordinates cache-first appearance loading with account and community
changes.

**mobile/lib/shared/theme/community_theme_sync.dart**
Adds encrypted NIP-78 relay persistence, live replacement handling,
deterministic ordering, safe seeding, and resilient subscription
recovery.

**mobile/lib/shared/theme/theme.dart**
Exports the community appearance modules.

**mobile/test/features/settings/theme_picker_page_test.dart**
Covers the updated settings behavior.

**mobile/test/shared/crypto/nip44_interop_test.dart**
Proves Dart decrypts a desktop-produced nostr-rs NIP-44 v2 preference.

**mobile/test/shared/theme/buzz_theme_test.dart**
Covers Buzz’s neutral rendering and stored-accent restoration.

**mobile/test/shared/theme/community_theme_preference_test.dart**
Covers wire parsing, validation, migration, and future-version handling.

**mobile/test/shared/theme/community_theme_sync_test.dart**
Covers cache/relay lifecycle, replacement ordering, switching races,
absence-only seeding, and closed-subscription recovery.

</details>

## Reproduction steps

1. Sign into desktop and mobile with the same account and join the same
community relay.
2. On desktop, choose a distinctive non-Buzz theme and accent; mobile
should update without a local toggle.
3. Restart mobile and confirm it restores the same appearance.
4. Change the mobile theme and accent and confirm desktop follows.
5. Leave mobile idle or backgrounded through a relay reconnect, then
change desktop again; mobile should resubscribe and catch up
automatically.
6. Switch communities and confirm each community restores only its own
appearance.

---------

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz>
2026-08-05 13:18:53 -07:00
Taylor Ho
43cced308d feat(desktop): sync themes per community (#3653)
**Category:** new-feature
**User Impact:** Users can keep a distinct Appearance scheme for each
community and restore it on another desktop signed in with the same
identity.

**Problem:** A single global theme makes it harder to distinguish among
communities, and local-only preferences do not follow a user to another
device. **Solution:** Save each community's stable theme, accent, and
system-following selection as private encrypted relay state, backed by a
responsive local cache and guarded against switch races, invalid future
records, and relay failures.

<details>
<summary>File changes</summary>

**desktop/src/app/App.tsx**
Mounts the community-scoped theme controller inside the active community
lifecycle.

**desktop/src/features/settings/lib/appearanceScopeCopy.test.mjs**
Covers active-community and fallback labels used to explain Appearance
scope.

**desktop/src/features/settings/lib/appearanceScopeCopy.ts**
Builds a safe, trimmed label for the currently active community.

**desktop/src/features/settings/ui/SettingsPanels.tsx**
Clarifies which Appearance controls are per-community and which apply
globally when multiple communities exist.

**desktop/src/shared/constants/kinds.ts**
Defines the NIP-78 application-data event kind used for theme
preferences.

**desktop/src/shared/theme/CommunityThemeController.tsx**
Coordinates cached appearance, encrypted relay retrieval, live updates,
reconnect behavior, and safe community switching.

**desktop/src/shared/theme/ThemeProvider.tsx**
Exposes a single appearance application path so synchronized preferences
use the existing renderer and persistence behavior.

**desktop/src/shared/theme/communityThemePreference.test.mjs**
Covers contract validation, user/relay isolation, malformed records,
cache failures, and switch-race decisions.

**desktop/src/shared/theme/communityThemePreference.ts**
Defines the versioned stable preference contract, safe defaults, local
cache keys, and persistence guards.

**desktop/src/shared/theme/communityThemeSync.test.mjs**
Covers relay absence, unreadable records, unavailability, seeding
safety, and teardown of pending writes.

**desktop/src/shared/theme/communityThemeSync.ts**
Encrypts theme preferences to the user, publishes and retrieves NIP-78
state, and handles ordering and lifecycle safety.

</details>

### Reproduction steps

1. Join at least two communities and open **Settings → Appearance**.
2. Choose a different theme, accent, or system-following mode in each
community.
3. Switch between the communities and verify each one restores its own
scheme without overwriting the other.
4. Sign in on another desktop with the same Nostr identity, join the
same community, and verify its saved scheme is restored from that
community's relay.
5. Disconnect the relay, change Appearance, and verify the UI remains
responsive and the local fallback is retained.

### Screenshots / demos
<img width="1733" height="948" alt="image"
src="https://github.com/user-attachments/assets/5afeabaa-0def-482c-9b87-8a880ee0a467"
/>


<img width="700" height="412" alt="Screen Recording 2026-07-29 at 4 39
52 PM"
src="https://github.com/user-attachments/assets/d58da329-aec5-4324-a4b2-cbcb2702a81a"
/>

---------

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz>
Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz>
2026-08-05 13:18:48 -07:00
Will Pfleger
6c40ce394f feat(desktop): cap OpenClaw agent parallelism at 5 (#4019)
OpenClaw connects to a single shared Gateway daemon. Spawning the
default 10 ACP workers per agent is both resource-expensive and
architecturally wrong — each worker opens a separate gateway connection.
Tyler's ruling: cap at 5, lower if needed.

## Contract

Store the requested value (1–32) verbatim at every persistence and wire
boundary. Apply `effective = min(requested, harness_cap)` only at the
four enforcement points:

| Boundary | Implementation |
|---|---|
| Local spawn | `BUZZ_ACP_AGENTS` env var in child `Command` |
| Remote deploy | `launch.policy_env["BUZZ_ACP_AGENTS"]` + legacy
`parallelism` field |
| Restart badge | `SpawnConfigSnapshot.parallelism` stores effective
value; the diff surface displays what actually runs |
| UI copy | Amber hint when requested > cap; no `max` attribute, no
save-path clamp |

`BUZZ_ACP_AGENTS` is added to `RESERVED_ENV_KEYS` — the Desktop resolves
the effective value into `policy_env`; a user-supplied override in `env`
would bypass the cap and is silently stripped.

## Changes

**`managed_agents/parallelism.rs`** (new) — policy core:
- `OPENCLAW_MAX_PARALLELISM = 5`
- `harness_max_parallelism(command)` — keyed on
`normalize_command_identity` so path prefixes, `.exe` suffixes, and
other cosmetic differences are ignored
- `effective_parallelism(command, value)` — identity for uncapped
harnesses
- `acp_agents_value(command, parallelism)` — `env("BUZZ_ACP_AGENTS", …)`
helper

**`runtime.rs`** — spawn clamp: `BUZZ_ACP_AGENTS =
acp_agents_value(effective_command, record.parallelism)`

**`agents_deploy.rs`** — deploy egress clamp: `build_deploy_payload`
resolves `effective_parallelism` once from `descriptor.command`; both
`launch.policy_env["BUZZ_ACP_AGENTS"]` and the legacy top-level
`parallelism` field use that value — the two are always consistent
regardless of stale `record.agent_command` pins

**`spawn_snapshot.rs`** — `from_inputs` stores
`effective_parallelism(&descriptor.command, record.parallelism)` in the
`parallelism` field. Over-cap edits that don't change the pool (e.g. 10
→ 8, both clamp to 5 on OpenClaw) produce equal snapshots; cap crossings
(8 → 3) produce different snapshots.

**`AcpRuntimeCatalogEntry.max_parallelism: Option<u32>`** — derived from
the static definition command, not the probed `entry.command` (which may
be `null` for unavailable entries), so unavailable OpenClaw entries
still carry the cap. Propagated through all four catalog constructors
(builtin discovery, preset catalog construction, custom discovery,
custom-save response), IPC types
(`RawAcpRuntimeCatalogEntry.max_parallelism`), and the frontend catalog
type.

**UI** — `EditAgentAdvancedFields` and `PersonaAdvancedFields` show an
amber hint when `selectedRuntime.maxParallelism` is set and the current
value exceeds it. Cap and label come from the catalog entry — no
hardcoded 5 in TS. No `max` attribute on inputs; the input stays
`type="text"` with 1–32 copy.

**Docs** — `docs/remote-agents.md`: `BUZZ_ACP_AGENTS` moved from the
deliberately-non-reserved section to reserved; new contract documented.
`desktop/src/features/agents/AGENTS.md`: command-keyed execution policy
documented as the sanctioned second metadata source feeding the catalog
projection.

## Tests

**Rust** (`parallelism.rs`):
- `policy_table` — `harness_max_parallelism` and `effective_parallelism`
across all openclaw variants and uncapped harnesses
- `acp_agents_value_openclaw_above_cap_is_capped` — spawn-env seam
- `override_direction_*` — both override directions (openclaw runtime +
goose override; goose runtime + openclaw override)
- `summary_persona_inherited_*` — live persona wins over stale
`agent_command`
- `snapshot_export_carries_requested_definition_parallelism` — requested
value travels wire/sync unchanged

**Rust** (`spawn_snapshot/tests.rs`):
- `openclaw_above_cap_parallelism_snapshots_equal` — stored 10 vs 8,
both clamp to 5 → snapshots equal
- `openclaw_cap_crossing_parallelism_snapshots_differ` — 8 (clamps to 5)
vs 3 → snapshots differ

**Rust** (`discovery/presets.rs`):
- `openclaw_preset_unavailable_carries_max_parallelism` /
`openclaw_preset_available_carries_max_parallelism` — catalog metadata
present with `command: null` and with a resolved path

**Rust** (`agents_deploy.rs`):
- `launch_block_openclaw_over_cap_policy_env_is_capped` — direct
`launch.policy_env` seam
-
`deploy_payload_json_stale_goose_record_live_openclaw_descriptor_both_capped`
— stale `record.agent_command=goose`, live descriptor=openclaw: both
fields cap to 5
-
`deploy_payload_json_stale_openclaw_record_live_goose_descriptor_both_uncapped`
— stale `record.agent_command=openclaw`, live descriptor=goose: both
fields pass through requested
- `deploy_payload_json_explicit_openclaw_override_both_capped` —
explicit `agent_command_override=openclaw`: both fields cap to 5

**Rust** (`persona_events/stale_pin_tests.rs`):
- `apply_persona_snapshot_goose_to_custom_harness_drops_stale_goose_pin`
— custom-direction stale-pin drop (builtin pin → loaded custom harness
via `update_loaded_harness_registry`)

**TypeScript** (`agentParallelism.test.mjs`):
- `parallelismCapHint` — at/below cap (null), above cap (hint includes
label and cap value), singular form for cap=1, uncapped harness (null)

**TypeScript** (`tauri.test.mjs`):
- `fromRawAcpRuntimeCatalogEntry` round-trips `max_parallelism` →
`maxParallelism`; absent when `undefined`

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-08-05 16:09:14 -04:00
Will Pfleger
6df7eba24d fix(buzz-agent): scope handoff cap per turn, not per session lifetime (#4805)
`BUZZ_AGENT_MAX_HANDOFFS` compared against the session-cumulative
`handoff_count` (persisted across prompts). After N handoffs a
long-lived session hit the cap permanently: `maybe_handoff()` returned
`Skipped` on every subsequent prompt, the 16 MiB byte-truncation
fallback never bound before a 1M-token provider wall, and the session
wedged on the first 400 with no recovery path. Thufir's session log
shows 8 days of cap-forced truncation before the first
`context_length_exceeded` 400.

The fix replaces the session-level cap comparison with a local
`handoff_attempts` counter constructed at the start of `run()` and
passed into `maybe_handoff()`. The counter resets on every
`session/prompt` turn so `BUZZ_AGENT_MAX_HANDOFFS` caps compaction loops
within a single turn while allowing unbounded compactions across a
session's lifetime. The session-cumulative `handoff_count` is retained
for log context only and is not reset. Steer-driven rounds share the
per-turn budget automatically since steers inject into the running
`run()` loop, not a new call.

- Move `handoff_attempts` increment to before `summarize()` so failed,
empty, and cancelled summarize calls each consume one budget slot — the
cap cannot be bypassed by a repeatedly-failing summarizer
- Upgrade cap-forced `Skipped` from `INFO` to `WARN`; add structured
fields for `session_id`, attempt count, projected tokens, and threshold
so the cap→wall pairing is attributable per session
- Document `max_handoffs` in `config.rs` as a per-`session/prompt`-turn
bound
- Three new behavioral regression tests: per-turn reset proven across
two separate turns; within-turn cap proven via multi-round tool-call
turn; failed summarize proven to burn the attempt budget

Note: this is the proactive half of the context-window fix. The reactive
`context_length_exceeded` 400 recovery path is owned by Sami's branch
(`buzz-ctxfix-sami`, Tyler's crew); this PR is intended to land after
that one.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
mobile-v0.8.0-rc.3
2026-08-05 15:30:04 -04:00
klopez4212
0c6842931b Fix mobile message timeline bounce (#4862)
## Summary

Stop repeated follow-latest scrolling after layout changes in channels
and DMs.

## Validation

- `flutter analyze lib/features/channels/channel_detail_page.dart`
- `flutter test test/features/channels/channel_detail_page_test.dart`
- Full mobile pre-push suite

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
2026-08-05 12:25:32 -07:00
klopez4212
27b51144f3 Polish mobile bottom sheets and profile cards (#4911)
## Summary

- standardize mobile sheets with shared spacing, close controls, action
tiles, haptics, and motion
- add uniform native concentric corners on iOS 26+ while preserving the
Android sheet shape
- refresh profile actions/status and normalize membership and huddle
timeline spacing

## Validation

- `just mobile-check`
- `just mobile-test` (1,165 tests)
- signed iPhone Release build and device install
- Android debug build and Pixel 10 install

## Snapshots

### Channel actions

![Channel action sheet on
Pixel](https://raw.githubusercontent.com/block/buzz/3babe5d8e339a1e7ad69de3b07e17eab17fe3f9d/pr-4911--pixel-channel-actions.png)

### Profile card

![Profile card sheet on
Pixel](https://raw.githubusercontent.com/block/buzz/3babe5d8e339a1e7ad69de3b07e17eab17fe3f9d/pr-4911--pixel-profile-card.png)

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
2026-08-05 20:22:37 +01:00
klopez4212
f2ce575b62 Fix media attachment actions (#4849)
## Summary

- Upload photos immediately while keeping videos queued for background
upload.
- Move image annotation and video spoiler actions to thumbnail hover
overlays.
- Preserve the image editor's existing Draw and Spoiler controls.

### Snapshots

#### Image annotation overlay

![Image annotation
overlay](https://raw.githubusercontent.com/block/buzz/87d7e1b1a0774ffef7a1a6cba03baffd63e11bd4/pr-4849--01-image-annotation-overlay.png)

#### Image editor controls

![Image editor
controls](https://raw.githubusercontent.com/block/buzz/87d7e1b1a0774ffef7a1a6cba03baffd63e11bd4/pr-4849--02-image-editor-controls.png)

## Testing

- `pnpm typecheck`
- `pnpm check`
- Focused attachment, drawing, and spoiler smoke tests
- Pre-push desktop tests (4,286 passing)

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Honey <47c18026466e670a1618fd7de0ef32b9ff75d6e0b5ccf255d13c8c3d674ed115@buzz.block.builderlab.xyz>
Co-authored-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
2026-08-05 12:17:48 -07:00
klopez4212
2034e693a8 fix(desktop): remove join API token control (#4897)
## Summary

Remove the nonfunctional API-token option from the existing-community
join flow.

## Validation

- Focused Playwright join-flow coverage
- Add-community screenshot coverage

Signed-off-by: kenny lopez <klopez4212@gmail.com>
2026-08-05 11:56:14 -07:00
Wes
014562c063 fix(desktop): allow shared agent mentions (#4913)
## Summary

- admit relay-discovered agents to autocomplete when their response
policy authorizes the viewer
- require authorization in the exact active stream/forum channel for
mentions, while keeping community-wide discovery for member invitation
- fail closed for relay-only agents in DMs and unresolved composer
contexts
- re-authorize cached autocomplete rows after policy/channel changes so
stale agent suggestions cannot leak back in
- preserve managed-agent behavior and explicitly reject stale
agent-marked channel members absent from both live directories

## Validation

- `pnpm --dir desktop test` — 4,288 passed
- `pnpm --dir desktop typecheck`
- `pnpm --dir desktop check`
- `pnpm --dir desktop build:e2e`
- focused Playwright mention matrix — 12 passed
- focused Playwright member-invitation matrix — 2 passed
- pre-push hooks after rebase to current `origin/main` — desktop check
and 4,288 tests passed
- independent correctness/privacy re-review cleared with no remaining
blocker

## Related competing PRs

This supersedes or overlaps #2333, #3056, #4242, #4137, #2314, #4058,
and #2605. This version adds exact-channel authorization, fail-closed
DM/context handling, cached-row reauthorization, forum coverage,
outbound mention-tag coverage, explicit stale-member coverage, and
add-member discovery coverage.

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
2026-08-05 11:15:11 -07:00
klopez4212
ff0b7982f1 Polish mobile top navigation (#4778)
## Summary

- Polish mobile Home, Activity, Search, and Settings navigation chrome.
- Add progressive Buzz gradients/frost, aligned theme colors, dividers,
typography, and section spacing.
- Refine Search and Settings motion, including automatic keyboard focus
on search activation.

<img width="630" height="1368"
alt="C78FA3CE-F2B3-45F2-B9F5-7EA7500778CC"
src="https://github.com/user-attachments/assets/5935514b-d894-4010-80dd-a938363fee93"
/>
<img width="630" height="1368"
alt="5C058A73-1879-476A-881C-531ACC256D84"
src="https://github.com/user-attachments/assets/5266c841-17ee-49e8-9841-b06d84f4195f"
/>
<img width="630" height="1368"
alt="3F50ADB7-9BDA-4A8D-A81E-20560C3B9EA6"
src="https://github.com/user-attachments/assets/f615f61e-9e96-4bce-b261-ae5ec54db872"
/>
<img width="630" height="1368"
alt="35ECE741-01F3-4B79-80C5-1DDD447121A7"
src="https://github.com/user-attachments/assets/b5145186-9884-44eb-8ebc-f3303831c0a4"
/>

## Validation


- `flutter analyze`
- Focused Home, Activity, Channels, Search, theme, and footer widget
tests
- Full pre-push checks, including mobile tests, desktop checks, and
Tauri checks
- On-device iPhone review during the visual polish pass

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: npub1glqcqfjxdens59scl477pmejh8lht4hqkhx0y4w38jxr6e6w6y2sm29y4e <47c18026466e670a1618fd7de0ef32b9ff75d6e0b5ccf255d13c8c3d674ed115@buzz.block.builderlab.xyz>
Signed-off-by: Code Reviewer <037593536284cf40e221c96c931e9877d4166d54f6bb84e5341a86d7fd5d05a4@buzz.block.builderlab.xyz>
Signed-off-by: Kenny Lopez <klopez4212@gmail.com>
Co-authored-by: npub1glqcqfjxdens59scl477pmejh8lht4hqkhx0y4w38jxr6e6w6y2sm29y4e <47c18026466e670a1618fd7de0ef32b9ff75d6e0b5ccf255d13c8c3d674ed115@buzz.block.builderlab.xyz>
Co-authored-by: Code Reviewer <037593536284cf40e221c96c931e9877d4166d54f6bb84e5341a86d7fd5d05a4@buzz.block.builderlab.xyz>
2026-08-05 19:01:20 +01:00
Wes
4674750b7e fix(release): tag immutable desktop candidates (#4811)
## Summary

Redesign the permanent Desktop release flow so unrelated merges to
`main` cannot invalidate an already reviewed, green release candidate.

- Tag the immutable, API-confirmed release PR head instead of its later
squash commit.
- Treat the merged PR—including an authorized owner/admin bypass—as
publication authorization, while requiring trusted check evidence that
was complete at merge time.
- Make tag creation idempotent and collision-safe: an existing tag
succeeds only at the exact candidate SHA, and create races refetch
before accepting equality.
- Replace ancestry-based previous-release discovery with a validated
metadata ledger for side-history candidate tags.
- Compute the next release from the prior frozen base to the new frozen
base, excluding only the prior release squash SHA so unrelated commits
remain in the changelog.
- Preserve schema-1 production-tag migration and reject malformed
metadata or equal/decreasing versions.
- Update operator documentation for the normal squash-merge workflow.

This is the reusable release process for `0.5.6` onward, not the retired
one-shot `0.5.5` recovery path.

### Invariants covered

- Candidate creation → unrelated `main` merge → authorized squash merge
→ immutable candidate tag.
- Trusted producer IDs and merge-time completion timestamps; DCO's
bounded post-merge exception remains isolated.
- Missing/spoofed checks, tampered candidates, ambiguous PR
associations, conflicting tags, and equal/decreasing versions fail
closed.
- Same-SHA retries succeed; different-SHA collisions fail.
- Legacy schema-1 tag-on-main migration and schema-2 side-history
accounting both preserve the correct next-release changelog.

### Related issue

N/A — follows the Desktop release failures in #4788 and #4800 and the
recovery revert in #4808.

### Testing

At clean commit `6a91fbed8147a48cf174997de0c3e4cb2fb26474`:

- `scripts/test-desktop-release-candidate.sh`
- `scripts/test-release-ref-contract.sh`

Both focused suites passed with HEAD unchanged. Princess Donut cleared
the security/provenance surface, including the hostile merge-time
timestamp cases. Mongo cleared the side-history ledger, migration,
version-order, documentation, and contract-test surface.

---------

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
2026-08-05 11:00:14 -07:00
Jordan Mecom
efe1893dd3 fix(channels): restrict private-channel invitations (#4612)
This change requires an active owner or administrator for third-party
additions to private channels. The relay validator and transactional
database authority enforce the same rule, including removed-member
reactivation and role-change paths.

Idempotent self-target behavior remains available, while ordinary
members can no longer extend private-channel access to another identity.

## Testing

- `git diff --check
origin/main...codex/security-private-channel-invite-authority`
- Rebased onto `origin/main` at `5c98932`
- Full CI pending

Originating Buzz thread:
`buzz://message?channel=3928fe05-df61-4b5d-b9c7-d623b9b10ea1&id=3c6c02312f763fbe0d2bfc33a6c1a362f91d0354f3d18b039cf7a0558c1439d1`

---------

Signed-off-by: Jordan Mecom <jm@squareup.com>
Signed-off-by: Eli Foster <efoster@squareup.com>
Co-authored-by: Eli Foster <efoster@squareup.com>
2026-08-05 10:47:00 -07:00
Jordan Mecom
ad538bfb1e fix(acp): reject unattended permission requests (#4609)
This change removes the ACP permission-bypass mode, defaults managed
sessions to `dontAsk`, and answers permission requests with
`reject_once` or cancellation in both ACP read loops.

Unattended operations that require interactive approval now fail closed
instead of being silently authorized. Explicit non-interactive modes
that do not bypass a permission request remain available.

Both layers have to change together: `apply_permission_mode` treats an
unsupported mode and a failed `set_config_option` as non-fatal by
design, so a request can still reach the harness even in a
non-interactive mode. Removing `bypassPermissions` from the enum rather
than only changing the default means the mode cannot be restored by
configuration alone.

The scope of the guarantee is that `buzz-acp` never grants approval. An
agent that pre-authorizes tools in its own configuration (for example
Claude Code's `settings.json`) still runs them without asking, which is
outside this harness.

## Testing

- `env -u BUZZ_ACP_LAZY_POOL bin/cargo test -p buzz-acp` at `16fff4d`:
671 library tests and 9 integration tests passed
- `cargo clippy -p buzz-acp --all-targets -- -D warnings` and `cargo fmt
-p buzz-acp -- --check`: clean
- `git diff --check
origin/main...codex/security-acp-shell-auto-approval`

The permission tests previously re-implemented the `reject_once` lookup
in the test body instead of calling the code under test, so they would
have passed unchanged if the harness went back to selecting
`allow_once`. They could not call it directly, because
`handle_permission_request` is a method on `AcpClient`, which owns a
live `Child` and its stdio pipes. The choice is now a free function,
`permission_denial_response`, and the tests exercise it: `reject_once`
preferred over offered allow options, the cancelled fallback when no
`reject_once` exists, an empty option list, and a `reject_once` missing
its `optionId`. The cancelled fallback had no coverage before despite
being the fail-closed backstop.

## Operator notes

- `BUZZ_ACP_PERMISSION_MODE=bypassPermissions` no longer parses, so a
process configured with it fails to start rather than silently
downgrading.
- Desktop managed agents do not set a permission mode, so they inherit
`dontAsk`. The desktop has no permission prompt, so operations needing
approval now fail with no in-app way to approve them.

Originating Buzz thread:
`buzz://message?channel=3928fe05-df61-4b5d-b9c7-d623b9b10ea1&id=3c6c02312f763fbe0d2bfc33a6c1a362f91d0354f3d18b039cf7a0558c1439d1`

---------

Signed-off-by: Jordan Mecom <jm@squareup.com>
Signed-off-by: Eli Foster <efoster@squareup.com>
Co-authored-by: Eli Foster <efoster@squareup.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 10:38:51 -07:00
Jordan Mecom
885bed35ee fix(workflow): bind trigger author to the signed event (#4607)
This change derives `trigger_author` exclusively from the signed event
pubkey. Actor tags remain available as event data but cannot override
the identity used by author-sensitive workflow conditions.

This removes the impersonation path without changing workflow
definitions or requiring stored-data migration.

## Testing

- `bin/cargo test -p buzz-workflow` at `78819df`: 154 passed, 2
Postgres-dependent tests ignored
- `git diff --check
origin/main...codex/security-workflow-trigger-author`

Originating Buzz thread:
`buzz://message?channel=3928fe05-df61-4b5d-b9c7-d623b9b10ea1&id=3c6c02312f763fbe0d2bfc33a6c1a362f91d0354f3d18b039cf7a0558c1439d1`

Signed-off-by: Jordan Mecom <jm@squareup.com>
2026-08-05 10:35:40 -07:00
Jordan Mecom
997b8caaa4 fix(git): revoke access for banned relay members (#4608)
This change rechecks the durable community ban in the shared Git HTTP
authentication path for advertise, fetch, and push requests. A banned
member is denied even if repository-channel membership still exists, and
restriction lookup errors fail closed.

The additional database lookup happens on every Git HTTP request so
access revocation does not depend on stale session state.

The check also cascades to the NIP-OA owner. Git accepts NIP-OA
attestations on the NIP-98 token, so an agent key can act for its owner
— without the cascade, a banned human would keep clone and push access
through any agent key. This mirrors the NIP-42 gate in `handlers::auth`:
either principal's ban denies the request.

The check runs inside the `GitAuth` extractor, so all three Git routes
inherit it.

## Testing

- `git diff --check origin/main...codex/security-ban-revokes-git`
- Rebased onto `origin/main` at `5c98932`
- `cargo test -p buzz-relay --lib sec005_read_gate_tests`: 8 passed, 7
ignored (Postgres)
- `cargo clippy -p buzz-relay --all-targets -- -D warnings` and `cargo
fmt --check`: clean

Pure tests cover the decision table (agent ban, inherited owner ban, no
attestation). Postgres-gated tests cover the wiring: the real ban row, a
live `compute_auth_tag` attestation, and the 503 fail-closed path.

**Not yet verified:** the three Postgres-gated tests compile and skip
but have not been run — no local Postgres, and CI does not run
`--ignored`. They need `cargo test -p buzz-relay --lib
sec005_read_gate_tests -- --ignored` against a migrated dev database.

Originating Buzz thread:
`buzz://message?channel=3928fe05-df61-4b5d-b9c7-d623b9b10ea1&id=3c6c02312f763fbe0d2bfc33a6c1a362f91d0354f3d18b039cf7a0558c1439d1`

---------

Signed-off-by: Jordan Mecom <jm@squareup.com>
Signed-off-by: Eli Foster <efoster@squareup.com>
Co-authored-by: Eli Foster <efoster@squareup.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 10:32:41 -07:00
Tyler
8a7eb8d3d7 fix(agent): recover from unsupported image input instead of poisoning the turn (#4896)
## Problem

`buzz-dev-mcp` advertises `view_image` to every agent regardless of
whether the session's model accepts images. When a text-only model (e.g.
DeepSeek V4 Flash) takes the bait, the image lands in session history
and every subsequent LLM request 404s with `No endpoints found that
support image input`. The error was classified as `LlmModelNotFound` and
propagated fatally out of the turn loop — history stays poisoned,
buzz-acp retries the batch with exponential backoff, and the session
burns its entire clock doing no work. In a recent trial run, **all 57
trials that called `view_image` on a text-only model died this way; none
recovered.**

## Fix

Capability-gating the advertised tool isn't reliable — there is no
image-capability metadata at the agent layer across providers. Instead,
recover at the turn loop:

- **Typed error**: new `AgentError::UnsupportedImageInput`, classified
narrowly on the exact provider phrase `No endpoints found that support
image input` on both the generic 404 path and OpenRouter's 404 path.
Unknown-model 404s and OpenRouter parameter-routing 404s keep their
existing classifications. No deterministic retry.
- **In-turn recovery**: on this error, `RunCtx::run` strips every image
block from history — keeping the tool result (and therefore
tool-call/result pairing) intact — marks the result `is_error`, appends
actionable model-facing guidance ("The current model does not support
image input. The image was removed from conversation history so this
turn can continue. Use a text-based inspection tool…"), and continues
the same turn. Base64 never replays again.
- **Loop guard**: recovery only fires when at least one image was
removed; if the provider says "image" and history has none, the error
propagates as before.

## Tests

- Unit: phrase classification (typed, not retried; unknown-model 404
unaffected), idempotent image-to-error history mutation preserving call
IDs and text.
- End-to-end (`fake_llm.rs` + `fake_mcp.rs`): tool call → MCP image
result → 404 unsupported-image → same-turn recovery. Captured requests
prove round 2 carried the image, round 3 replays no image, carries the
guidance text, preserves pairing, and ends `end_turn`.
- Loop guard: typed unsupported-image error with **no** image in history
fails after exactly one provider request instead of spinning —
mutation-testing showed deleting the `removed == 0` guard survived the
suite, and `max_rounds` defaults to unlimited in production, so this
branch needed direct coverage.

Verified at `a210305019b33d5f56677b4c82bab79e4ac52d24`: `cargo test -p
buzz-agent` (full package, 381 unit + all integration suites) green;
`clippy --all-targets -D warnings` green; `fmt --check` green; pre-push
hooks (rust-tests, desktop-tauri-checks, branch-skew) green.

**Scope of the classification guarantee**: the classifier runs in the
shared `post()` (which Anthropic and OpenAI paths route through) and in
`openrouter_post()` — i.e., every 404 path in `llm.rs`. It only runs on
404 responses; providers that reject images with a different status
(e.g. a 400) are out of scope for this PR — see the review-comment
discussion for why broadening the phrase list alone would not cover
them.

Authored by Wren, loop-guard test by Sami, reviewed by Eva.

---------

Signed-off-by: Wren <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz>
Signed-off-by: Sami <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@buzz.block.builderlab.xyz>
Co-authored-by: Wren <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz>
Co-authored-by: Sami <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@buzz.block.builderlab.xyz>
2026-08-05 11:51:05 -04:00
Wes
067c085f37 Define private managed agent wire protocol (#4593)
## Summary

- reserve kind `30179` for owner-private managed-agent aggregates
- define the fail-closed owner-self NIP-44 v2 envelope and versioned
payload codec
- bind runnable identity/configuration to complete signed
`30175`/`30177` recovery projections
- validate NIP-OA owner→agent attestations and reject self-attestation
- document NIP-PMA authority, migration prerequisites, privacy, and
deployment order
- keep generic relay ingest closed until private storage and atomic
aggregate CAS exist

## Safety boundary

This is the inert protocol/codec slice only. It does not publish
secrets, change agent authority, migrate local records, or enable kind
`30179` ingestion. The relay regression test proves generic EVENT ingest
still rejects the kind.

The finalized migration plan adds later prerequisites for relay-private
storage/CAS, runtime lease/fencing, Desktop cutover, and harness
authentication. Those belong in staged follow-up PRs rather than
expanding this inert foundation.

## Validation

At commit `67f0ea4ebb8d3ccba3a3eb9374e89a7178913f74`:

- `cargo test -p buzz-core` — 246 unit + 2 doc tests passed
- `cargo test -p buzz-relay
private_managed_agent_kind_remains_rejected_until_atomic_ingest_exists`
— passed
- push hooks: Rust tests and desktop checks passed (`2145` desktop tests
passed, `14` ignored)
- `cargo fmt --all -- --check`
- `git diff --check`

## Review

Princess Donut cleared security/data integrity with no remaining
high/medium findings. Mongo cleared migration compatibility and wire
grammar. The later runtime lease/fencing protocol was also adversarially
cleared as a plan; implementation slices still require independent
evidence before activation.

Deterministic plaintext/signed-projection/auth-tag interoperability
vectors remain a valuable follow-up, not an S0 merge gate; random NIP-44
ciphertext is intentionally not snapshotted.

---------

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
2026-08-05 08:34:02 -07:00
Tom Brow
dc17965c79 fix(mobile): serialize channel sections sync (#3165)
### What changed?

Serializes channel-section relay synchronization so a late relay
`CLOSED` cannot overlap an in-flight retry and install duplicate
subscriptions. Pending subscription results are invalidated and
immediately closed when the manager is disposed or superseded.

### Why?

The startup retry added in #3004 could race with a late `CLOSED` or
manager disposal, leaking an untracked live subscription. This keeps
retry recovery single-flight and makes the lifecycle boundary explicit.

### How is it tested?

Build and run.

Added tests:

-
[`ChannelSectionsManager`](https://github.com/block/buzz/tree/main/mobile/test/features/channels/channel_sections/channel_sections_manager_test.dart)
interleaving coverage for in-flight retry serialization and disposal
during subscription setup

*🤖 This PR was authored with a Buzz agent.*

Signed-off-by: npub15w828kxsxu2684ynste0uah2jwkgatd99flt7ds4523hzm8ju6cshdr8hh <a38ea3d8d03715a3d49382f2fe76ea93ac8eada52a7ebf3615a2a3716cf2e6b1@buzz.block.builderlab.xyz>
Co-authored-by: npub15w828kxsxu2684ynste0uah2jwkgatd99flt7ds4523hzm8ju6cshdr8hh <a38ea3d8d03715a3d49382f2fe76ea93ac8eada52a7ebf3615a2a3716cf2e6b1@buzz.block.builderlab.xyz>
2026-08-05 07:55:26 -07:00
Will Pfleger
6dbc946512 fix(desktop): make missing-command error actionable for released builds (#4802)
User-facing error for missing ACP harness commands has been pointing
released-build users to run `cargo build --release --workspace` and read
TESTING.md — both dead ends for anyone not building from source.

Updated message acknowledges that antivirus software can quarantine
bundled binaries and provides practical remediation steps. Preserves
pointer to TESTING.md for source builds.

Fixes issue context from #4491.

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub16v54tttfqacx9ycvc3k0ut0npj564ahcuajzy6qjvh57ntmsf4uq4806j2 <d32955ad69077062930cc46cfe2df30ca9aaf6f8e76422681265e9e9af704d78@buzz.block.builderlab.xyz>
2026-08-05 09:51:15 -04:00
Michael Neale
ddce5e6694 feat(mesh): experimental tier for models served across machines
Advanced settings gains a nested Experimental section listing split-capable
models that exceed this machine, with the consequence stated before any choice:
sharing will not start until enough well-connected computers are sharing the
same model. It waits instead of loading, and that waiting is indistinguishable
from a slow download to anyone who was not told.

## Why "beyond this machine" is the partition, not "multi-machine"

The catalog's runtime field marks a model multi-machine whenever a layer package
exists, which is not the same as needing several computers -- Qwen3-8B-Q4_K_M is
5 GB and split-capable. mesh-llm agrees about precedence:
evaluate_model_target_capacity returns SingleNodeFit first and only falls back
to SplitCandidate when no single node can hold the model. So the tier is the
intersection: split-capable AND beyond local memory. Anything that fits belongs
in the ordinary picker, where it starts immediately.

## Catalog comes from mesh-llm's loader

remote_catalog is public in mesh-llm-host-runtime, so Buzz calls it rather than
fetching and parsing the dataset itself -- one implementation of the format.
Hand-parsing is a trap: the package discriminator is `type` on the wire and
`package_type` in Rust via serde rename, and reading the Rust name off the wire
produced a confident "zero models are split-capable" earlier in this work. A
test pins the wire spelling, and it turned out stronger than intended -- serde
rejects a wrong discriminator outright, so format drift surfaces as a parse
error rather than a silently empty tier.

Reads the on-disk cache first and downloads only when nothing is cached, in
spawn_blocking. No running node required: you choose what to share before you
start sharing it.

## Empty is four distinct sentences

Still loading, could not read the catalog, the catalog holds no packages, and
this machine is large enough that nothing needs splitting. The last is good news
and must not read as a failure. A test asserts all four differ.

Two upstream entries publish "30 layers" where bytes belong, which parses to
nothing -- and is why mesh-llm reports unknown_model_size for the model this
machine serves. Unknown size is never claimed to fit: offering the split path is
recoverable, promising a local start that then fails is not.

Also updates the Share compute description to say why it is worth doing rather
than restating the mechanism.

Signed-off-by: Michael Neale <michael.neale@gmail.com>
2026-08-05 15:22:24 +10:00
Michael Neale
57197bb906 fix(mesh): restore the status dot, and settle on one name
Two regressions from the previous commit.

The dot is the row's reason to exist -- hollow when nothing is known, slate when
there is capacity to join, green sharing, blue consuming -- and the last change
swapped it for a Share2 glyph in the cold state. That threw the signal away in
the one state that still had something to say, and made the row read as a
network widget rather than a status line. The dot is now unconditional in every
state, and Share2 sits beside the invitation it labels.

The same change also invented two names, "MeshLLM" cold and "Buzz MeshLLM"
otherwise, then failed to apply the second to the popover -- so the card kept
saying "Buzz MeshLLM" after the row had dropped it. There is now one constant,
"MeshLLM" (the "Buzz" qualifier adds nothing inside Buzz), and a test asserts
the label is identical across every state so it cannot drift again.

Signed-off-by: Michael Neale <michael.neale@gmail.com>
2026-08-05 14:27:38 +10:00
Michael Neale
390f0aa6ce feat(mesh): make the cold state name its action
With nothing shared anywhere, the row and popover were both status surfaces
with no status: a hollow dot beside "Buzz MeshLLM", and a "Mesh capacity"
heading over "0 sharing in this community". Nothing said what the switch does,
which is the one thing a first-time reader needs and the one thing a zero
cannot tell them.

The row now drops the "Buzz" qualifier -- "Buzz MeshLLM" plus an invitation
does not fit 256px, and the name is the part that can afford a word -- carries a
Share2 glyph where the status dot would go, and offers "Share your compute" in
the trailing slot. The dot returns the moment there is a real state to report,
which is the more honest signal there.

The popover's cold subtitle explains rather than reports: "Share your spare
compute with the Buzz community to run models." Its heading names the thing
instead of heading an absent number.

Only the cold state invites. A permanent nudge beside a real figure becomes
furniture, and a prompt derived from an unfetched snapshot would appear for one
poll and retract, reading as a glitch.

Also renames the popover's footer link from "Compute settings" to "Advanced
settings": it named the destination, where the useful thing to name is what is
there. The picker, custom model refs, and the memory cap are all advanced
concerns; the popover already covers the everyday ones.

Signed-off-by: Michael Neale <michael.neale@gmail.com>
2026-08-05 14:15:30 +10:00
Michael Neale
cb1323a2d3 feat(mesh): name the row Buzz MeshLLM, and offer the action when the mesh is empty
An empty mesh was the one state with nothing to show and nothing happening, so
the row sat inert with nothing explaining why. The action is real -- you can be
the first to share -- so the badge slot now carries the word "Share" where a
figure would otherwise go.

Static rather than throbbing. A community that never uses the mesh would
animate forever, and permanent motion earns nothing but annoyance; the invite
throb stays reserved for capacity that actually exists.

An unfetched snapshot still offers nothing at all. A prompt derived from no data
would appear for one poll and retract, which reads as a glitch rather than a
suggestion -- so "not asked yet" and "asked, nothing there" are now separate
branches instead of one condition.

Row label is "Buzz MeshLLM" rather than "Community mesh".

Signed-off-by: Michael Neale <michael.neale@gmail.com>
2026-08-05 13:26:26 +10:00
Michael Neale
d6dd453ae2 feat(mesh): capacity in the row, and throb when sharing has no consumer
Three changes to the sidebar row, all aimed at making it argue for itself.

The share Switch is gone from the row. It was scaled to 70%, unlabelled, and
sat where SidebarMenuBadge normally puts a number -- hard to see and ambiguous
about what it would do. The popover already had the same control with room to
label it, so the row now carries state and capacity only, and every control
lives in one place.

That frees the trailing slot for capacity in every state that has a number,
not just while sharing. When this machine is outside the mesh the figure *is*
the invitation: "92 GB" argues for joining better than any exhortation, and
unlike a nudge there is nothing to dismiss. Unknown capacity degrades to a
device count rather than printing 0 GB -- a count still says there is something
here.

Sharing with no agent set up to use the mesh now throbs. It deliberately does
not get a colour of its own: blue already means *consuming*, i.e. taking from
the mesh, and sharing-with-no-consumer is the opposite, so reusing blue would
make blue meaningless. The dot keeps stating what is true -- green, we really
are sharing -- while the existing invite throb says there is a step left, and
the tooltip names which. A loading agent list is treated as "assume set up",
so a slow query never flashes a nudge and retracts it.

Signed-off-by: Michael Neale <michael.neale@gmail.com>
2026-08-05 13:02:16 +10:00
Michael Neale
d23e3cf237 fix(mesh): state what is shared plainly, and scope the memory meter
Drops two captions that hedged or mislabeled facts we have.

"Join to see live detail" was wrong twice over: in that state the only thing to
join is sharing, and the field already shows what the community has published.
"Last reported by the community" annotated a real fact with doubt -- the notes
are current within 120s, and what we know is shared should be shown as shared.
The live-vs-relay difference is already carried by the geometry: relay nodes get
no synthetic RTT, and the centre is hollow with no spokes because adjacency is
what we cannot assert. A caption repeating that was noise.

The only surviving caption states something the drawing cannot: alone on the
mesh.

The memory meter is per-node and now says so -- "This computer · model uses 10
GB of 40 GB". It sits directly beneath a radar field showing the whole
community, so an unqualified figure read as mesh-wide utilization: a number
nobody has, since members publish their own capacity but nothing about each
other's live allocation. A test pins the scoping rather than the wording.

Signed-off-by: Michael Neale <michael.neale@gmail.com>
2026-08-05 12:51:57 +10:00
Michael Neale
2a2977795a feat(mesh): throb the row when there is compute to tap into
Shared compute in the community is worth noticing even when this machine is
neither using nor adding to it -- that is the moment to either tap in or
contribute, and a static slate dot said nothing.

Pulse is now a named signal rather than a boolean, because two different facts
want motion and a single flag would make a throb ambiguous:

  - activity -- real work is in flight on this node right now
  - invite   -- there is compute here, and we are outside it

They are deliberately unalike. Activity uses Tailwind's stock ping: quick and
insistent, because something is happening. Invite breathes on a new 2.8s
keyframe in slate and never reaches full opacity: noticeable on a second
glance, never competing with unread badges.

An empty or unfetched community still never throbs -- an invitation to join a
mesh that does not exist would be a lie told with animation.

Signed-off-by: Michael Neale <michael.neale@gmail.com>
2026-08-05 12:44:49 +10:00
Michael Neale
3376dbc665 fix(mesh): show the community mesh before joining it
The popover said "Turn on sharing to see who's on the mesh." That was not just
awkward copy, it was false: mesh_snapshot reads other members' relay status
notes and needs no local runtime, so who is sharing -- and how much -- is
knowable while our own node is off. The graph was gated on a local runtime for
no reason.

The radar field now renders from whichever source this machine has, and keeps
the two honestly distinct:

  - live gossip: reachable now, RTT known, adjacency to us is real
  - relay notes: last known within 120s, no RTT, adjacency unknown

So relay nodes get no synthetic RTT (they land mid-band rather than faking
proximity), spokes are omitted when we have not joined because a spoke asserts
a connection we do not have, the centre is drawn hollow for the same reason,
and a caption names which view is on screen. The replacement copy only appears
when the community genuinely has nothing, and names the actual next step.

Row label is now "Community mesh" rather than "Compute".

Signed-off-by: Michael Neale <michael.neale@gmail.com>
2026-08-05 12:35:04 +10:00
Michael Neale
36548e0bc3 feat(mesh): one-line Compute row with radar-field detail popover
Sidebar front-page space is premium, so shared compute is now a single menu
line under Agents instead of a ~120px footer card. The row carries one signal
-- a coloured dot -- and everything else moved into the popover it opens.

The dot distinguishes six states, and the pair that matters most is "nothing
here" vs "there is capacity you have not joined": mesh_snapshot reads other
members' relay status notes with no local runtime, so idle capacity is knowable
without starting a node or running a probe. Grey would have conflated the two,
and the second is the reason to look at the row at all.

The switch shows only while not sharing; once sharing, the state is legible
from the dot and Stop lives in the popover, which frees the trailing slot for
live pool capacity. It is never disabled while consuming -- a client runtime
can be replaced by a serve runtime at any time, so offering the switch is
honest.

The popover consolidates what were two overlapping surfaces (card + detail
popover) into one, and replaces the small radial SVG with a canvas-2D radar
field: self at centre, radius from RTT, node size from shared capacity,
per-node breathing seeded by id hash, real adjacency as spokes, and
non-participant members as a dashed outer band with no size and no capacity.
Visual language follows mesh-llm's own console, drawn in 2D rather than porting
its ~2,200 lines of WebGL -- a 288px popover does not justify shader plumbing.

Nothing animates along a spoke: mesh-llm's counters are node-local with no
per-edge attribution, so a travelling packet would be invented. Motion is
confined to node breathing and a centre pulse driven by real inflight work.

Row and popover share one useMeshComputeState hook rather than each calling the
polling hooks, which would double every poll and let the two disagree
mid-flight.

Signed-off-by: Michael Neale <michael.neale@gmail.com>
2026-08-05 12:27:35 +10:00
Wes
8342dfcc58 chore(release): release Buzz Desktop version 0.5.5 (#4809)
## Buzz Desktop release v0.5.5

- **Frozen main:** `25a9cf1be6d245fbd7373cb1160dbc790baf5bd5`
- **Reviewed candidate:** `8380c1f8ead8816bcf1f4ea9f66aa08e2441b15a`
- **Previous desktop release:** `desktop-v0.5.4`
- **Proposed immutable tag:** `desktop-v0.5.5`

This PR must be **squash merged** only after the Desktop Release
Candidate check passes. The branch must remain based directly on current
`main`; stale base, payload drift, incomplete notes, or an unauthorized
merge produce no tag.

The checked-in changelog accounts for every non-merge commit in the
release range. Publication remains bound to the immutable candidate tag.

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Release Automation <release-automation@users.noreply.github.com>
desktop-v0.5.5
2026-08-04 16:54:16 -07:00
Alessandro Joabar
6c5c78f5cb Merge remote-tracking branch 'origin/main' into micspiral/mesh-share-ux
* origin/main: (31 commits)
  feat: paste composer text without formatting (#4801)
  Revert "chore(release): release Buzz Desktop version 0.5.5" (#4808)
  chore(release): release Buzz Desktop version 0.5.5 (#4800)
  fix: reauthenticate databricks model discovery (#4008)
  Revert "chore(release): release Buzz Desktop version 0.5.5" (#4797)
  feat: Buzz entity links — rich preview cards + in-app navigation for repos, PRs, and issues (#4695)
  fix(desktop): serialize tray channel actions for frontend (#4762)
  chore(release): release Buzz Desktop version 0.5.5 (#4788)
  feat(projects): support multiple repositories (#4671)
  fix(ci): make desktop cache test version agnostic (#4791)
  fix(desktop): widen post-Enter timeouts in empty-edit-delete spec (#4792)
  fix(desktop): wait for terminal frame before splash (#4781)
  fix(desktop): integer-align custom reaction emoji (#4779)
  Polish Huddle voice controls (#4694)
  fix(local-archive): default both archive settings to enabled (#4750)
  fix(mobile): stop oversized read-state retry loop (#4595)
  fix(desktop): close reconnect gaps that previously required CMD+R (#4737)
  Dock Buzz Term within channel workspace (#4724)
  perf(relay): index channel-id lookups and skip trace-only reads (#4647)
  fix(agents): canonicalize stale persona harness pins (#4631)
  ...

Signed-off-by: Alessandro Joabar <sandro@squareup.com>
2026-08-04 19:33:37 -04:00
Alessandro Joabar
23d6c44176 share compute modal polish
Signed-off-by: Alessandro Joabar <sandro@squareup.com>
2026-08-04 19:33:37 -04:00
Kalvin C
25a9cf1be6 feat: paste composer text without formatting (#4801)
## Summary

- handle Cmd+Shift+V on macOS and Ctrl+Shift+V on Windows/Linux in the
message composer
- read plain text through the native Tauri/arboard clipboard path in
packaged builds, with a browser-only Clipboard API fallback
- re-enter ProseMirror's paste pipeline with populated `text/plain`
clipboard data so selection, undo, multiline behavior, and paste
observers remain intact
- cover both platform mappings with rendered composer E2E tests that
assert the native command path

## Testing

- `pnpm test` — 4,286 passed
- `pnpm check`
- `pnpm typecheck`
- `pnpm exec playwright test composer-selection-formatting.spec.ts
--project=smoke` — 26 passed
- `cargo check --manifest-path desktop/src-tauri/Cargo.toml --workspace
--all-targets --target aarch64-apple-darwin`
- `just desktop-tauri-test` — 2,206 core tests plus integration and
doc-test groups passed
- full pre-push hooks passed

## Manual verification

Physical packaged-app clipboard verification remains recommended on
macOS, Windows, and Linux. The automated E2E uses mocked Tauri IPC but
asserts the native `read_clipboard_text` command is invoked.

Signed-off-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz>
Co-authored-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz>
2026-08-04 16:16:14 -07:00
Wes
79c52166cf Revert "chore(release): release Buzz Desktop version 0.5.5" (#4808)
Reverts block/buzz#4800
2026-08-04 16:09:33 -07:00
Wes
a0ed13de14 chore(release): release Buzz Desktop version 0.5.5 (#4800)
## Buzz Desktop release v0.5.5

- **Frozen main:** `4a2305170eef565bf1836e2859247e67c030f8af`
- **Reviewed candidate:** `2d03d37b05b68186b2caad9da79080032be3ac72`
- **Previous desktop release:** `desktop-v0.5.4`
- **Proposed immutable tag:** `desktop-v0.5.5`

This PR must be **squash merged** only after the Desktop Release
Candidate check passes. The branch must remain based directly on current
`main`; stale base, payload drift, incomplete notes, or an unauthorized
merge produce no tag.

The checked-in changelog accounts for every non-merge commit in the
release range. Publication remains bound to the immutable candidate tag.

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Release Automation <release-automation@users.noreply.github.com>
2026-08-04 16:00:29 -07:00
Kalvin C
4a2305170e fix: reauthenticate databricks model discovery (#4008)
## Summary
- preserve Databricks catalog 401 responses as authentication failures
and retry discovery exactly once after silently refreshing the rejected
bearer
- preserve runtime OAuth recovery: when discovery has no usable OAuth
credential, `session/new` succeeds with only the trimmed configured
model so the first `session/prompt` can run the existing browser PKCE
flow
- reject a rejected configured `DATABRICKS_TOKEN` with actionable,
non-interactive guidance; static credentials cannot recover through PKCE
- use the configured-model fallback for non-auth discovery failures
without caching failed or fallback catalogs, so later sessions retry
discovery
- keep known Databricks v2 models only for authenticated empty-catalog
responses and mark their provenance
- resolve discovery before MCP spawn or session registration, preventing
failed discovery from leaking resources or consuming session capacity
- permit serialized interactive PKCE only from the explicit saved-agent
model picker; passive draft discovery never opens a browser

## Runtime flow
1. OAuth discovery attempts cached credentials and silent refresh
without opening a browser.
2. If no usable OAuth bearer exists, `session/new` advertises only the
configured model and succeeds.
3. The first `session/prompt` uses `TokenSource::bearer()`, which may
launch browser PKCE.
4. A later session retries discovery and caches only the authenticated
catalog.

## Regression coverage
- rejected-but-locally-fresh OAuth bearer performs one refresh and one
catalog retry
- OAuth mode with no cached token allows `session/new` and returns
exactly the trimmed configured model
- the OAuth fallback is not cached; a later authenticated session
retries discovery and caches the returned catalog
- rejected static tokens still reject `session/new`
- failed discovery does not consume the sole session slot or spawn the
supplied MCP process
- Desktop interactive/passive auth intent, static-token redaction, and
authenticated empty-catalog provenance

## Verification
- `cargo test -p buzz-agent`
- `cargo test --manifest-path desktop/src-tauri/Cargo.toml --lib
commands::agent_models`
- `cargo clippy --manifest-path desktop/src-tauri/Cargo.toml
--all-targets -- -D warnings`
- `cargo fmt --all -- --check`
- `git diff --check`
- full pre-push hooks

## Review
Adversarial review found and drove fixes for session/MCP resource
leakage, duplicate concurrent PKCE flows, sensitive error propagation,
incorrect 403 reauthentication, missing discovery-level coverage,
passive browser launch, and the Desktop file-size ratchet. The final
follow-up preserves the existing prompt-time OAuth flow while retaining
static-token rejection and pre-allocation discovery ordering.

---------

Signed-off-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz>
Co-authored-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz>
2026-08-04 15:20:49 -07:00