Buzz workspaces expose no public group directory (membership is server-side, no
kind-10009 join), so the generic "browse a relay" directory comes back empty for
them. Add a membership-scoped importer: from Find groups, enter the Buzz relay
you're already on and tap "Import your channels". It warm-authenticates the relay
(NIP-42), reads the relay's kind-44100 member-added notifications addressed to you,
fetches each channel's 39000 metadata, and lists your non-DM workspace channels
with Add / Add all.
Adding calls account.follow(channel) — a public kind-10009 group tag — so the
channel then flows through the existing relay-group machinery for free: it shows in
the Messages list (inline or grouped-by-community per relayGroupViewMode) and loads
at boot via the always-on relay-group subscription. No separate registry needed.
Entering the importer also joins + pre-approves NIP-42 for the relay so discovery
is served.
New: Route.BuzzRelayImport, BuzzRelayImportViewModel, BuzzRelayImportScreen, a
Find-groups entry point, and strings.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
The DM inbox and Agent Console both query only your joined/dialect workspace
relays (BuzzWorkspaces + BuzzRelayDialect), so with zero workspaces they have no
relays to hit and can only ever be empty — yet the hub showed both as prominent
top-level cards, implying they're global features when they're workspace-derived
(a DM is a per-community NIP-29 group; the console aggregates the fleet on your
community relays). Gate both behind having >=1 workspace so the empty state is
just the "join a workspace" prompt; once you have one they sit above the list as
the cross-workspace inbox / fleet view.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
Renames buzz_workspaces_title, which names the tab in the drawer + bottom bar
and the screen's top bar. "Workspaces" alone was ambiguous (Concord also uses
that word); "Buzz Workspaces" makes the feature clear.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
It was an ExtendedFloatingActionButton (icon + "Attest" label), which Material 3
renders as a rounded-rectangle pill — visually out of step with the circular
FloatingActionButton(shape = CircleShape) used by the sibling chat/relay-group/
concord create actions. Switch to the same circular icon FAB, keeping the label
as the icon's contentDescription for accessibility.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
NavBarItem.BUZZ was added to the catalog and the bottom-bar settings picker but
never to DrawerFeedsItems, the list the left navigation drawer's Feeds section
renders. So Buzz Workspaces could be pinned to the bottom bar yet was missing
from the drawer alongside Relay Groups and Concord. Add it between them, matching
the chats grouping order.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
Kind 20001 is claimed by both BitChat's GeohashPresenceEvent and Buzz's
PresenceUpdateEvent. EventFactory's flat when(kind) could only route one, so
Buzz presence never materialized (parsed as a geohash event) in either
direction — this was the deferred "EventFactory collision".
Disambiguate inside the shared 20001 branch by BitChat's required `g` (geohash)
tag: present -> GeohashPresenceEvent, absent -> PresenceUpdateEvent. Verified
against the Buzz Rust ground truth (buzz-sdk build_presence_update + the relay's
synthesize_presence read form): Buzz presence carries the status in content plus
a `status` tag (client) or a `p` tag (relay-synthesized), never a `g` tag, and
BitChat presence always carries `g` with empty content. Both inbound parse
(EventDeserializer) and outbound signing (EventAssembler) route through this
factory, so one guard fixes both.
Make it usable, not just parseable: add BuzzPresenceState (process-wide latest
online/away/offline per subject, mirroring BuzzTypingState), a
PresenceUpdateEvent.subjectPubKey() accessor (the `p` tag or the author), and a
LocalCache branch that records presence and drops the ephemeral without storing
it. Tests cover both Buzz wire shapes, the BitChat guard, and latest-wins.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
- Auto-authenticate relays for Buzz workspaces the user explicitly joined:
their read-only #p=me channel/DM discovery is otherwise not first-party, so
the p-gated 44100/30622 reads were never served and workspaces stayed empty.
- Invite screen: two-state hand-off — join + pre-approve NIP-42, launch the
in-app window.nostr browser, then point back to the workspaces hub.
- DM inbox: add-member action (npub/hex dialog → kind-41011) alongside hide.
- Workspaces hub: leave-workspace overflow on each header.
- Elevate the Buzz surface with a shared BuzzBrand gradient design kit — hero
masthead with live workspace/channel stats, cohesive across screens.
- Drop the dead kind-41001 DM-conversation path: the deployed relay never
emits a queryable 41001, so BuzzDmRegistry is trimmed to the 30622 hidden
set and LocalCache stores DmCreatedEvent without registry bookkeeping.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
openBuzzDm reads the relay-assigned channel id from the DM-open OK response, but
a bare publish to an auth-required Buzz relay on a cold (not-yet-authenticated)
connection is rejected `auth-required` — the write path doesn't re-send after the
async NIP-42 AUTH lands (proven against the live relay via the amy CLI). The app's
persistent, proactively-authed connection usually masks this, but opening a New DM
before discovery has connected that relay would race it.
Port the amy fix: on an `auth-required` ack, warm the connection with a
pendingOnAuthRequired read (lets the AuthCoordinator finish the handshake), then
retry the publish and re-read the channel id from the OK.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
The NIP-89 client tag says "this app composed this event", so it belongs only
on templates Amethyst authored. NostrSignerWithClientTag was applied at the
account level, which meant it also fired on every event we sign on behalf of
an external client.
That is wrong twice over. It misattributes the event, and — because the tag is
appended before signing — it rewrites the exact bytes the caller is about to
have hashed into an id. NIP-07 callers routinely re-check the returned event
against the template they submitted, and block/buzz compares tags outright
(web/src/shared/lib/nostr-signer.ts):
JSON.stringify(actual.tags) === JSON.stringify(expected.tags)
so joining a Buzz community from the in-app browser failed with "The NIP-07
extension returned an invalid signed event". Probed live over the WebView
devtools protocol: kind, created_at, content and pubkey all round-tripped
intact and only tags differed, by exactly the ["client","Amethyst"] we append.
Add NostrSigner.withoutClientTag() and use it at the two boundaries where the
template belongs to someone else:
- the napplet broker, covering napplets, nSites and web apps over NIP-07
- Nip46SignerState, where we act as another client's bunker — the same defect,
and quieter, since that client never learns why its event changed underneath
it
Amethyst's own events are untouched and still carry the tag. Unwrapping keeps
everything layered below (metering, NIP-13 mining) and leaves pubKey alone.
There is no NIP-55 provider surface to fix; we are only ever the client there.
Verified against Buzz's own four acceptance conditions after the change:
pubkey matches, sameUnsignedEvent true, id and sig present — and the invite
join then succeeded.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The earlier discovery layer read the NIP-29 joined list (kind-10009) and
kind-41001 — neither of which the deployed relay uses, so a joined workspace
rendered nothing. Rework it to the model live testing confirmed.
Enabling layer — persist joined workspaces:
- commons BuzzWorkspaces: process-wide set of joined workspace relays (Buzz
membership is server-side, so there's no join event to rebuild from). Joining
also marks the relay a Buzz dialect. Unit-tested.
- BuzzWorkspacePreferences: device-global DataStore that restores the set at
startup (so the app connects + authenticates + discovers on cold start) and
mirrors changes. Eager init in AppModules. BuzzInviteScreen now `join`s.
Quartz:
- BuzzChannelMetadata: read the relay's `t` channel-type tag ("stream"/"forum"/
"dm") and a DM's inlined `p` participants off kind-39000.
Discovery (both hubs now source from the relay's real signals):
- BuzzWorkspacesViewModel: fetch + live-subscribe kind-44100 member-added
notifications (#p=me) across joined relays → my channels; fetch each channel's
39000 metadata; keep the non-DM ones. BuzzWorkspacesScreen unions this with the
NIP-29 joined list.
- BuzzDmListViewModel: same 44100 discovery, kept where 39000 `t`=dm (participants
from the metadata `p` tags), minus the 30622 hidden set — replaces the dead
kind-41001 path.
- Account.openBuzzDm returns the relay-assigned channel id from the OK response
(`response:{channel_id}`); BuzzNewDmViewModel opens the chat from it instead of
polling 41001.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
asadmansr/android-test-report-action@v1.2.0 is a Docker action whose
Dockerfile is `FROM ubuntu:18.04` + `apt-get install python` (Python 2).
Ubuntu 18.04 (bionic) is end-of-life, so its apt archives are now
unreliable and Python 2 has no installation candidate — the image rebuild
runs on every CI invocation, takes ~8 minutes, and has started hard-failing
the test-and-build-android job (`E: Package 'python' has no installation
candidate`). The action was last released in 2020 and is unmaintained, and
it was referenced by a movable tag rather than a pinned commit.
Swap it for mikepenz/action-junit-report (actively maintained, Apache-2.0,
JS action — no Docker rebuild), pinned to the v6.4.2 commit SHA. Use
annotate_only so it needs no `checks: write` permission and keeps working on
pull requests from forks; fail_on_failure keeps the job red when a unit test
fails, matching the previous step's behavior.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HMtpSuyr8FBh2ZQ22BV4ZP
Both the Concord and Buzz invite checks re-scanned the word for "/invite/".
Fold them under one guard — the two shapes stay disjoint (Concord carries a
`#` fragment, Buzz does not), so only one branch decodes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
Lower the quartz module's Kotlin jvmTarget for both the JVM and Android
compilations from JVM_21 to JVM_17, broadening the range of runtimes that
can consume the published library.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V1Hn61gQJ1joUznESzUHU4
A Buzz workspace invite (`https://<host>/invite/<token>`) is redeemed over HTTP,
and the Buzz web app already drives that flow (policy consent + NIP-98 claim)
through `window.nostr`. Amethyst's existing in-app browser (NappletBrowserActivity
via FavoriteAppLauncher.launchUrl) injects an origin-scoped window.nostr backed by
the user's signer into any https origin — so routing Buzz invites there lets the
SPA claim membership as the user's key, with no native re-implementation of the
consent UI.
Mirrors the Concord invite intercept, at all three entry points, keeping every
other link external by default:
- Deep link: AndroidManifest intent-filter for *.communities.buzz.xyz/invite/ +
a `buzzInviteRoute()` branch in MainActivity.uriToRoute.
- Tapped in-content link: a BuzzInviteLinkSegment classified in RichTextParser
(commons) + a ClickableBuzzInviteLink that routes to Route.BuzzInvite.
- Search bar: a branch in SearchBarViewModel.directRouteResolver.
Route.BuzzInvite → BuzzInviteScreen confirms the workspace (host + role parsed by
the quartz BuzzInviteLink), marks its relay as a Buzz dialect, and opens the
in-app browser at the invite URL to finish joining. The matcher is host-agnostic
(BuzzInviteLink.parse), so self-hosted Buzz invites intercept via tap/search even
without a manifest filter.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
A live stream opened for the first time drew ~90px of black above and below
the picture. The video surface itself was correct 16:9; the box enclosing it
was not. Measured on a Pixel 9 emulator: container [0,274][1080,1062] (788px
= StreamingHeaderModifier's 300.dp cap) holding a TextureView of
[0,364][1080,972] (608px = 16:9 at 1080 wide), centred, so (788-608)/2 = 90px
per side.
Two independent causes, both needed fixing:
ContentWarningGate takes a `modifier` but drops it for anything not flagged
sensitive — the non-sensitive path emits `content()` bare. ZoomableContentView
was routing mediaSizingModifier() through exactly that parameter, so for
ordinary media the sizing never reached the layout at all. With no height
constraint the player stretched to whatever ceiling enclosed it and
letterboxed the frame inside. Apply the sizing to the inner Box, which is
always emitted.
Even applied, the ratio was unknown on a first play: a NIP-53 stream carries
no imeta `dim`, and MediaAspectRatioCache is only filled once the decoder
reports a size. The miss was frozen for the whole visit because the cache was
a plain LruCache read during composition, which triggers no recomposition when
it later fills — hence the bars vanishing only on a *second* visit to the same
stream. Back cache entries with snapshot state so a composition-time read
updates, and default an unknown video to 16:9 so the first layout already
lands in the right place.
VideoView keeps reading the cache inside remember() on purpose, with a comment
explaining why: making it observable there flips the ratio mid-playback, which
both adds an aspectRatio and emits an extra Spacer, and restructuring children
around a live AndroidView strands the player on a stale surface — the video
redraws at native size in the corner while layout bounds still look correct.
Verified on a cold cache: container and TextureView are both
[0,274][1080,882], against a header ending at 274 — zero gap. Feed image and
video layouts unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
MediaCodec instances are a per-process resource with a hard per-device
ceiling — the Android emulator's c2.goldfish.h264.decoder declares
`concurrent-instances max="4"`. Past that, MediaCodec.start() fails with
NO_MEMORY, MediaCodecRenderer reports "Failed to initialize decoder", and
the video surfaces to the user as "can't load". Opening a live stream after
scrolling a few feed videos reproduced this reliably; killing the process
made the same stream play, since that released every held codec.
The device ceiling was already computed by SimultaneousPlaybackCalculator,
but only reached ExoPlayerPool as `poolSize`, which governs how many idle
players are *retained*. The acquire path was uncapped
(`coldPool.poll() ?: builder.build(context)`), and MediaSessionPool held a
hardcoded LruCache(10) of sessions, each pinning a checked-out player. So a
4-decoder device would happily hold 10.
Enforce the budget where players are handed out:
- Track live decoders process-wide, counting checked-out and warm players
(cold ones have been stop()'d and hold none). The counter and the pool
registry are global because PlaybackService builds one pool for direct
traffic and another for Tor-proxied traffic; a per-pool budget let the
app hold twice the ceiling.
- Before a cold or fresh player is handed out, reclaim headroom by demoting
warm players to cold — own pool first, then siblings. Warm entries are a
scroll-back cache, so they are the right thing to give up under pressure.
- Size the session cache from the same device budget, keeping the previous
10 as an upper bound so capable devices are unaffected.
Verified on the emulator: 9 codec allocations across a session with zero
NO_MEMORY and zero decoder-init failures, where allocation #5 previously
died.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Validated against the production relay wss://amethyst.communities.buzz.xyz
by joining and running a full DM round-trip. Adds the join primitive and
fixes the interop gaps that live testing surfaced.
- quartz: BuzzInviteLink — parse `https://<host>/invite/<token>` (relay-signed
base64url payload → community/role/expiry). A Buzz invite is NOT a NIP-29
code; it is redeemed over HTTP against the tenant host. Unit-tested with a
real token; rejects the Concord `/invite/<naddr>#…` shape (no collision).
- cli: `amy buzz join <invite-url>` — the real 3-step claim: GET /api/join-policy,
POST /api/invites/accept-policy, then NIP-98-signed POST /api/invites/claim.
Proven live (status: joined, role: member).
- cli: Context.publish now authenticates-then-retries on an `auth-required`
relay (warm the connection with a pendingOnAuthRequired REQ, then re-publish)
— the write path had no NIP-42 handling, so every Buzz write was rejected.
- cli: Buzz reads (dm list / read / console / personas) use the auth-aware
drain (pendingOnAuthRequired).
- cli: `dm open` surfaces the relay's synchronous OK `response:{channel_id}` —
the authoritative DM channel id (the relay assigns it; it is not polled).
- cli: `dm list` rewritten to the relay's actual discovery — kind-44100
member-added notifications (#p=me) filtered to the kind-40099 `dm_created`
channels. The deployed relay does NOT emit kind-41001.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
A Buzz DM is a relay-authoritative NIP-29 group whose h/id is a
relay-generated UUID, so its timeline reuses the whole relay-group chat
stack unchanged. This adds the missing discovery + product layer:
- commons: BuzzDmRegistry — process-wide registry fed by LocalCache from
the relay-signed DmCreatedEvent (41001) and per-viewer DmVisibilityEvent
(30622); tracks conversations (channel id -> participants/relay) and the
viewer's hidden set. Unit-tested.
- LocalCache: record 41001/30622 into the registry on consume (was
store-only).
- Account: openBuzzDm (41010), hideBuzzDm (41012), addBuzzDmMember (41011).
The relay assigns the channel UUID and confirms via 41001 — we never
mint it.
- Android: BuzzDmListViewModel (two-phase fetch: discover 41001/30622 #p=me,
then fetch each DM's 39000-39003 roster so the shared composer's member
gate passes), BuzzDmListScreen (inbox), BuzzNewDmScreen (publish 41010,
await the 41001, jump into the shared RelayGroupChatScreen). Reached from
a Direct Messages card on the Workspaces tab.
- CLI: amy buzz dm list/open/hide/add-member, mirroring buzz-cli.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
- private monitor, and correct the commit() KDoc
- replace atomics handshake with a single monitor
- flag userFinder re-entrancy assumption in commit() KDoc
`activeSubscriptions` was a plain LinkedHashSet iterated (`SetsKt.minus`) and
mutated (`clear`/`addAll`) with no synchronization, while `invalidateFilters()`
ran synchronously on whatever thread called subscribe/unsubscribe.
Those callers are genuinely concurrent: `ComposeSubscriptionManager` invokes
`invalidateKeys()` after releasing its own lock, and
`LifecycleAwareSubscription`'s 30s grace-period unsubscribe fires on a
`Dispatchers.Default` worker while composition subscribes from elsewhere.
Hence the reported `ConcurrentModificationException` on
`DefaultDispatcher-worker-70`.
This is the only member of `EventFinderFilterAssembler.group` that implements
`IEoseManager` directly; its two siblings extend `BaseEoseManager`, whose
`invalidateFilters` hands off to `BundledUpdate` and is therefore never run on
the caller's thread nor concurrently with itself.
Fix, matching that existing pattern and avoiding locks:
- Route through `BundledUpdate`. `BasicBundledUpdate` holds `isProcessing`
under a Mutex, so only one body runs at a time — the concurrent
iterate-vs-mutate window is gone by construction. It also moves the
`allKeys()` scan and per-stub `getOrCreateUser` off the caller thread, which
`ComposeSubscriptionManager` documents as "called by main. Keep it really
fast."
- Hold the state in an `AtomicReference<Set<...>>` of immutable snapshots
swapped with `exchange()`, plus an `AtomicBoolean` teardown flag, mirroring
the `AtomicReference` + CAS idiom in `FilterIndex`/`BanStore`.
- `bundler.cancel()` cannot stop a body already executing (no suspension
points), so `destroy()` flags first and an in-flight body compensates by
releasing what it just acquired. Double-unsubscribe is a no-op.
No locks are introduced; the hot path is strictly cheaper than before.
Tested: the new concurrency test reproduces the exact production failure
against the pre-fix code (`ConcurrentModificationException` alongside the
overlap detector) and passes after. Full :amethyst suite green (941 tests).
Note the pre-existing `UserFinderQueryState` identity-equality churn is
deliberately NOT addressed here: the set-diff never converges because each run
allocates fresh wrappers. It widens this race window but is an independent
defect needing its own design decision.
- Workspace shell: BuzzWorkspacesScreen now groups joined Buzz-dialect groups
BY RELAY into workspace→channels (a Buzz workspace IS a relay/tenant, per
buzz-core relay_url_authority), each an expandable section with its channels
and a + to the relay's directory (Concord-style community→channels).
- Forum composer (45001): the Threads-tab FAB opens BuzzForumPostScreen on a
Buzz relay, publishing a ForumPostEvent (mirrors build_forum_post) instead of
the vanilla kind-11 thread a NIP-29 relay uses.
- Held-attestation persistence: BuzzAttestationPreferences mirrors
BuzzHeldAttestations to the device DataStore and reloads at startup,
re-verifying each credential against its agent key (drops tampered entries).
Deliberately NOT built: job/huddle composers — jobs (43xxx) and huddles (48xxx)
have no builder in buzz-sdk (reserved kinds), so a composer would encode an
unconfirmed schema. Presence (20001) skipped per request (EventFactory collision
with GeohashPresenceEvent).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
Adds full NIP-88 poll support to Amethyst Desktop and a search content-type
filter for polls.
Polls (DesktopPollCard):
- Render kind-1068 polls in feed + thread (and reposted/boosted polls) as an
interactive card via NoteCard's bottomContent slot.
- Vote (single-choice radio / multi-choice checkbox), re-vote ("Change vote")
seeded with the prior selection; hide-until-voted with a "View results" opt-in.
- Tallies reuse commons PollResponsesCache; responses are fetched from the
poll's OWN declared relays (NIP-88 relay tags) unioned with connected relays,
so the full tally loads regardless of the viewer's relay set. Votes are
likewise published to the poll's relays (not just broadcastToAll).
- Result row marks the viewer's own choice (border + check), tap a row to see
its voters, footer shows distinct-voter count + deadline/ended state, and the
voter gallery draws the viewer front-most with a ring.
- Create polls from the composer (options, single/multi, optional deadline);
the dialog content scrolls with a pinned Cancel/Publish row; a poll requires
a question and >=2 options.
Wiring:
- DesktopLocalCache.consume for kind 1068/1018 (response links into pollState).
- DesktopFeedFilters + FilterBuilders surface polls; feed/thread interaction
subscriptions fetch kind-1018 responses.
- Thread + profile pass myPubKeyHex so the viewer's vote-state renders.
Search "Polls" facet:
- KindRegistry preset + alias for kind 1068 (auto-renders the filter chip and a
NIP-50 kind filter); SearchResultsList renders poll results interactively and
SearchScreen fetches their responses.
Also:
- Read-only accounts see results instead of dead vote controls.
- Cold-start: the response subscription re-evaluates as relays connect.
- Pull the upstream fix for the pre-existing RelayLatencyTracker.sweep
ConcurrentModificationException (synchronized(pending)) so relay-health
reclassify no longer crashes the UI during search.
Ripple/shaping: clickable elements clip to their shape for bounded ripple.
Tests: commons PollResponsesCache (dedup/tally/WoT sort) + DesktopLocalCache
response-linking.
Deferred (noted in review): wall-clock re-check of a poll expiring mid-view;
mention-dropdown now inside the composer scroll.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Thin assembly over quartz + commons (no new protocol in cli/):
- buzz post RELAY GID <text> — publish a kind-40002 stream message (h-scoped)
- buzz read RELAY GID — drain the recent human-visible timeline (9/40002/40099)
- buzz attest AGENT — sign a NIP-OA OwnerAttestation offline, print the auth tag
- buzz console [--relays] — drain kind-44200 turn metrics (#p=me), NIP-44-decrypt,
and aggregate via the shared commons AgentFleetAggregator
- buzz personas [--relays] — list my kind-30175 personas (newest per slug)
Join/leave/create reuse 'amy relaygroup' (Buzz workspaces are NIP-29 groups). Wired
into Main dispatch + usage; README command table + ROADMAP updated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
Gives Buzz a first-class navigation identity instead of hiding inside the generic
Relay Groups list. NavBarItem.BUZZ (Route.BuzzWorkspaces) is a pinnable bottom-nav
destination (added to NavBarCatalog + BottomBarCategories + the preloader when).
BuzzWorkspacesScreen is a modern hub: an Agent-Console hero card up top, then the
user's joined groups filtered to Buzz-dialect relays as cards with a colored
monogram avatar, live name/host/member-count and an unread dot, plus an inviting
empty state that routes to Browse groups. It reuses the always-on relay-group state
subscription (no per-screen fetch) and the same RelayGroupChannel the chat opens.
Also fixes the open-chat-tail test for the added 20002 typing kind
(RELAY_GROUP_OPEN_TAIL_KINDS), and documents typing + the hub in the buzz README.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
Brings Buzz to typing-indicator parity with Concord, verified against buzz-core
kind.rs (KIND_TYPING_INDICATOR=20002, requires_h_channel_scope=false).
- commons BuzzTypingState: process-wide, lock-guarded channel->typist->heartbeat
registry with stale pruning + future-clamp (6 unit tests).
- LocalCache records 20002 heartbeats into it (still no feed row; own typing
filtered in the UI).
- RELAY_GROUP_OPEN_TAIL_KINDS requests 20002 on the open channel's live tail only
(ephemeral, scoped to the room on screen, never the joined fleet).
- Account.sendBuzzTyping fires a throttled heartbeat to the host relay; the
composer sends it on text change (gated to Buzz relays).
- BuzzTypingIndicator: an animated three-dot '… is typing' row above the composer
that slides in/out and ages typists out on a timer.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
Earlier text wrongly said Buzz has no reaction kind. buzz-core/src/kind.rs ALL_KINDS
includes KIND_REACTION(7), KIND_DELETION(5), KIND_NIP29_DELETE_EVENT(9005)/GROUP(9008),
KIND_REPORT(1984), and emoji list/set (10030/30030); requires_h_channel_scope(7) is
false. So the shared chat action sheet's react/delete/report already work against Buzz.
Only zaps (no 9734/9735) aren't relay-stored (payment still works). Typing/presence are
accepted by Buzz but not yet rendered by Amethyst.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
Closes the edit loop: Amethyst already renders 40003 edit overlays but could
not create them. ChannelNewMessageViewModel gains a Buzz edit mode
(editBuzzMessage/clearBuzzEdit) — the next send publishes a kind-40003 edit
targeting the original instead of a new 40002, kept minimal to mirror Buzz's
build_edit (h + e + content). An 'Edit' action on the message sheet is gated to
my own 40002 messages (the kind alone marks it Buzz) and threads to the composer
via an optional onWantsToEditBuzz callback through the shared chat feed
(default-null, so no other chat surface is affected). EditFieldRow shows an
editing banner with a cancel.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
Adds a read surface for the Buzz canvas, which was consumed into overlay state
but had no UI. BuzzWorkspaceState gains a canvasUpdates flow; BuzzCanvasScreen
(Route.BuzzCanvas) renders the newest 40100 markdown for a channel and recomposes
when a newer revision lands. Entry point is a Dashboard icon in the relay-group
top bar, shown only on a Buzz-dialect relay once a canvas has arrived.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
Tier 1 connect path. BuzzHeldAttestations (commons) stores the OwnerAttestations
this device received, keyed by the agent pubkey each authorizes (verify-passing
only). AuthCoordinator.buzzAugmented appends the owner-signed auth tag to an
account's NIP-42 AUTH event when that account's key has a held attestation and
the relay speaks the Buzz dialect — and only that account's AUTH, never the
Concord stream-key AUTHs sharing the template, and never on non-Buzz relays.
So an un-enrolled agent key gets virtual membership while its owner stays a member.
AgentAttestationScreen gains a 'Hold an attestation' section (paste the auth tag
JSON, verified against the current account, stored/removed) alongside the existing
owner-side issuance. Store is in-memory for now — persisting per-account is a
follow-up. 4 store tests added.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
Tier 3 write flow. AgentPersonaEditScreen + AgentPersonaEditViewModel publish
a Buzz Agent Persona (NIP-AP kind:30175) to the workspace's Buzz-dialect relays
(falling back to the owner outbox). The Personas tab gains a 'New persona' row
and each card is tappable to edit.
On edit the existing PersonaEvent is loaded from LocalCache so fields the form
does not expose (name pool, respond-to allowlist, parallelism) are preserved
rather than dropped on republish; the slug (d tag) is locked once chosen and
validated against the relay's grammar (^[a-z0-9][a-z0-9_-]{0,63}$).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
Read-path interop. Two changes:
1. RelayGroupFilterBuilders: BUZZ_RELAY_GROUP_TIMELINE_EXTRA_KINDS now also
requests canvas (40100), forum posts/votes/comments (45001-45003), agent
jobs (43001-43006), and huddle lifecycle (48100-48103) — all h-scoped, so
the same #h group REQ returns them. Previously only 40002/40003/40008/40099
were requested, so LocalCache could consume these kinds but they never
arrived for a group feed.
2. RenderBuzzNotes: kind-aware rows dispatched from ChatMessageCompose —
RenderBuzzDiff (40008: monospace, horizontally-scrollable diff with a
repo/commit/file header instead of prose-wrapped gutters), RenderBuzzActivityRow
(job + huddle lifecycle as centered system narration — huddles MUST be caught
here since their content is JSON), and RenderBuzzForumVote (45002). Forum
posts stay as plain chat bubbles (their content is already plain text).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
Adds AgentAttestationScreen, reached from an "Attest" FAB on the console.
The owner enters an agent pubkey (npub/hex) and optional AttestationConditions
(kind, created_at before/after), and OwnerAttestation.sign() produces the
signed auth tag, displayed for copy to hand to the agent operator out-of-band.
Entirely offline (nothing is published) and gated on a raw private key: the
NIP-OA signature covers a hashed commitment rather than a Nostr event, so
NIP-46 bunker / NIP-55 external-signer accounts get an explanation instead of
the form. Inputs are validated (kind 0-65535, unix bounds 0-u32, npub/hex key,
no self-attestation) with human-readable errors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8