Address composer feedback:
- The annotation field is now the short-note composer's rich MessageField
(@-mention + custom-emoji autocomplete, inline previews). On publish it becomes
the highlight's `comment` tag, with the mentions (`p`), emoji, URLs (`r`),
hashtags and quotes it references emitted as their own tags — via
NewMessageTagger + a new tag-builder initializer on HighlightEvent.build().
- A nostr source now renders as a reply-style preview card (NoteCompose, quoted
style) instead of showing a URL field; the URL field appears only for web
sources. The source note is resolved from the a/e coordinate carried in the
route.
Verified with :quartz:jvmTest and :amethyst:compileFdroidDebugKotlin.
- Add a "Highlight" action to the note action menu (3-dot menu + chat long-press
sheet). It opens the NIP-84 composer pre-tagged with the note as source — an
`a` reference for an addressable article, else an `e` reference, plus the
author `p` — with the passage left for the user to type/paste. Prose kinds
only (text notes, long-form), and never a private rumor (a public highlight
would leak an e-tag of the unsigned rumor). This is the in-app entry point for
"post with the source as an event".
- Remove the live preview from the New Highlight screen per review.
- Add two real-world regression tests for the shared-highlight parser: Chrome
"copy link to highlight" with a prefix/suffix fragment (incl. an encoded
hyphen inside the prefix) and a long start-only fragment with encoded commas.
Verified with :quartz:jvmTest and :amethyst:compileFdroidDebugKotlin.
Redesign the New Highlight composer as a pull-quote you craft: a rounded tonal
hero card with a quotation-mark watermark, a highlighter-amber accent bar, and
the passage in large type, plus a live highlighter-pen preview (reusing the feed's
HighlightedQuote) and icon-led source/note fields.
Also extend HighlightEvent.create()/build() to emit a/e/p tags, so the builder
now covers every NIP-84 source — a web page (r), a nostr article (a), a nostr
note (e), each with optional author attribution (p) and context — not just web
highlights. Route.NewHighlight and the composer ViewModel carry the nostr source
(address/event/author) and context through so a future in-app "highlight this
passage" action can open the composer for a nostr article/note.
Covered by a new builder test for the a/e/p tags; verified with :quartz:jvmTest
and :amethyst:compileFdroidDebugKotlin.
Adds a browsable Highlights feed (NIP-84 kind:9802) modeled file-for-file on
the Git Repositories feed pattern, since highlights are announcement-like
regular events with the same follow-list top-nav filtering.
New feed stack under ui/screen/loggedIn/highlights:
- dal/HighlightsFeedFilter — AdditiveFeedFilter scanning LocalCache.notes for
kind 9802 (regular events, so notes rather than addressables), gated by the
selected top-nav follow list via FilterByListParams.
- datasource/ assembler + compose subscription + PerUserAndFollowList sub-
assembler + makeHighlightsFilter dispatcher, with per-relay subassemblies for
follows / authors / muted / global / hashtag / geohash. Communities aren't a
highlight concept, so those sets fall through and aren't offered.
- HighlightsScreen (DisappearingScaffold + shared RenderFeedContentState; the
existing RenderHighlight card handles rendering) with a HighlightsTopBar
FeedFilterSpinner and a NewHighlightButton FAB into Route.NewHighlight.
Wiring into the shared plumbing mirrors gitRepositories exactly: Route.Highlights
+ AppNavigation, NavBarItem enum/catalog/drawer lists (FormatQuote icon, already
in the subset font), AccountFeedContentStates (feed state + update/delete/trim),
RelaySubscriptionsCoordinator, TopNavFilterState.highlightsRoutes,
AccountSettings.defaultHighlightsFollowList + changer, Account live follow-list
flows, LocalPreferences persistence, ScrollStateKeys, and the bottom-bar
preloader. Verified with :amethyst:compileFdroidDebugKotlin.
The URL trailing-punctuation trim now leaves a closing bracket in place when
it is balanced within the token, so a shared Wikipedia link like
`.../wiki/Mercury_(planet)` keeps its `)` while a wrapping `(https://…)` still
loses the paren the surrounding prose added.
Adds a "New Highlight" share target and composer so a user can select text in
a browser, hit Share → Amethyst, and publish it as a kind:9802 highlight.
- Manifest: a text/plain-only ShareAsHighlightAlias activity-alias, matched at
runtime by ShareIntentRouting.isShareAsHighlight (mirrors the Send-as-DM
alias pattern).
- AppNavigation: both the launch-intent and warm onNewIntent parse blocks now
branch on the highlight alias, run the shared text through
SharedHighlightParser, and open Route.NewHighlight pre-split into
quote/url/prefix/suffix.
- NewHighlightScreen + NewHighlightPostViewModel: a trimmed composer (passage,
source URL, optional note — no polls/zaps/media/scheduling) that publishes via
account.signAndComputeBroadcast(HighlightEvent.build(...)).
Also adds HighlightEvent.build(), the unsigned EventTemplate counterpart of
create(), for the sign-and-broadcast pipeline. Verified with :quartz:jvmTest
and :amethyst:compileFdroidDebugKotlin.
Adds a shared-highlight parsing layer under nip84Highlights/parse that turns
the free-form text a browser hands Amethyst on "Share selection" into the
pieces of a kind:9802 highlight:
- SharedHighlightParser normalises selection-only, selection+URL, URL-only and
"link to highlight" (#:~:text= fragment) shares into a SharedHighlight.
- TextFragmentParser decodes/strips WICG text-fragment directives (prefix,
start, end, suffix), leaving literal '+' verbatim.
- UrlTrackerCleaner strips utm_*/fbclid/etc. from the source URL per NIP-84's
"clean the URL from trackers" guidance, preserving path and fragment.
Also adds a HighlightEvent.create() builder overload that assembles the r,
textquoteselector, context and comment tags from parsed data, centralising
what the desktop publish action does by hand.
Covered by commonTest suites for each piece plus a builder round-trip.
A kind:9802 highlight that mentions users before its author rendered the
wrong name and a stray "published by …" caption.
- HighlightEvent.author() took the first p tag regardless of role, so a
highlight with leading "mention" p tags was attributed to a mention
instead of the "author"-marked one. Prefer the NIP-84 author marker,
falling back to the first p tag for highlights (including Amethyst's own)
that omit the marker.
- DisplayEntryForNote used the source note's title/subject/alt as a caption.
For a kind-1 note there is no title/subject, so it fell to the NIP-31 alt
tag — which clients like Jumble fill with a generic "This event was
published by https://jumble.imwald.eu." line. Drop alt from the lookup and
name the source by its event kind instead, kept clickable to the note.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LJh3QYonEa9hw6mJcnrmve
The NIP-84 highlight comment was passed to TranslatableRichTextViewer with
an empty tag list, so custom emoji (:shortcode:) never resolved against the
event's `emoji` tags and rendered as raw text.
Thread the highlight event's tags through to the comment's rich-text viewer
so the emoji map is built from them, matching how regular text notes render.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FyyodHWxSHeiY6pndPxGSb
blossomHashOrNull runs on every media URL the feed loads. Quartz's unrolled
Hex.isHex64 is the fast path for validating a 32-byte hex id (~30% faster
than isHex, far faster than a regex match). It only checks the first 64
chars and doesn't verify length, so keep an explicit length == 64 guard to
reject longer segments.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ao9w26c2gAm4gjJdhgvLyp
The retry-on-401 interceptor re-probed anonymously on every blob, so each
image from an auth-gated host (e.g. a Buzz community feed, where nearly all
media is on one host) paid a wasted 401 round trip before the signed retry.
Remember hosts that answered 401 and attach the cached token up front on
their subsequent blobs — one round trip instead of two in steady state. The
first blob per host still costs the probe; the learned set falls back to an
anonymous request when no signer is available so a logged-out user never
loops.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ao9w26c2gAm4gjJdhgvLyp
A private/closed NIP-29 group serves its content (kind-9 chat, kind-11
threads, …) only over a NIP-42-authenticated connection. Amethyst's
group-content reads are all `#h`-scoped with all authors, so they never
name the user's pubkey, and a group host relay (e.g. wss://chat.wisp.talk)
is not in any of the NIP-65/DM/search/… lists that feed
`account.trustedRelays`. The per-account first-party AUTH gate therefore
returned false and Amethyst never AUTHed, so the relay refused the content
with `auth-required` — the group's public 39000 metadata still loaded, so
the group appeared but showed no messages.
Treat a NIP-29 relay group the user explicitly joined (their kind-10009
list) as a first-party reason to authenticate with its host relay,
mirroring the existing `BuzzWorkspaces.isJoined` carve-out. Reads of a
group the user has not joined (e.g. browsing a relay's public directory)
still do not AUTH.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018puT2YnevbLHYG2PdCLAeh
Buzz's private media relay (*.communities.buzz.xyz) gates blob downloads
behind BUD-01 read auth, returning `401 {"error":"authentication failed"}`
to anonymous GETs. Amethyst loaded every media URL anonymously through
Coil/OkHttp, so those images (and their thumbnails) never decoded.
Quartz already had BlossomAuthorizationEvent.createGetAuth but nothing in
the app ever called it. Wire it into the media HTTP client:
- BlossomReadAuthInterceptor: on a 401 for a GET whose URL last segment is
a Blossom sha256 filename (covers `<hash>.png` and `<hash>.thumb.jpg`),
retry once with a signed `Authorization: Nostr <event>` header. Narrowly
gated so unrelated 401s never trigger a second request or any signing.
- BlossomReadAuthTokenProvider: bridges the suspend signer synchronously
(runBlocking + timeout so a slow remote/external signer can't pin the
OkHttp thread) and caches one server-scoped token per host, which also
covers derived blobs like thumbnails.
- BlossomAuth.createGetAuth exposes the read-auth builder to the app layer.
Public Blossom/NIP-96 hosts stay zero-overhead: they answer 200, so no
token is ever signed for them.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ao9w26c2gAm4gjJdhgvLyp
Add a "Content in the feed" section to the Home settings screen with a
switch per event-kind group (text notes, reposts, comments, articles,
polls, voice, live activities, chess, ...). Disabling a group both drops
its kinds from the always-on home relay filters (the assembler) and hides
them from the New Threads / Conversations / Everything tabs (the DAL).
- HomeFeedType: single source of truth mapping each toggleable group to
its Nostr kinds, with stable codes + encode/decode for persistence
(mirrors the existing ChatFeedType pattern for Messages).
- AccountSettings.enabledHomeFeedTypes (+ setHomeFeedTypeEnabled),
persisted per-account as the set of disabled codes so new groups
default on.
- Assembler: HomeOutboxEventsEoseManager strips disabled kinds from every
home relay filter at the single choke point, and re-arms on toggle.
- DAL: HomeNewThreadFeedFilter / HomeConversationsFeedFilter reject
disabled kinds; AccountFeedContentStates rebuilds the home feeds when a
toggle flips.
Also extracts the inbox / relay-auth / feed-type preference reads out of
LocalPreferences' account-load lambda into a helper: that lambda was
already at the JVM per-method bytecode limit and the new field tipped it
over ("Method too large").
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L576BhgU3c638PkGHL8YUS
Add a CONTINENT(1) level to GeohashChannelLevel so the coarsest selectable
geohash channel is a single character (~5000 km, one of 32 cells for the
globe), previously floored at REGION(2). Every precision picker iterates
GeohashChannelLevel.ordered, so the map picker, Teleport, "Near me" list and
New-geohash-chat all pick it up automatically; GeohashChatsScreen now labels a
1-char cell "Continent" instead of showing no level.
Adds the "Continent" label and "~5000 km" chip subtitle, and extends the
GeohashChannelLevel test coverage. REGION..BUILDING (2-8 chars) still mirror
the Bitchat channel levels; CONTINENT is an Amethyst extension beyond them.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vtvo2iNSnG3M21QwbFPznU
Audit follow-ups on the highlight context window:
- Blank lines sitting at the very start/end of a `context` tag were passed
through untouched when a side was short enough not to be trimmed, so a
highlight whose context began or ended with blank lines still rendered
empty space above/below the quote. Trim the outer edges of the windowed
passage (re-basing the marked range accordingly).
- `locate` enumerated every occurrence of the quote — a full context scan
plus a list allocation — even in the common no-prefix case where only the
first match is needed. Short-circuit to a single indexOf there.
- Clamp the returned marked range to the trimmed text length so it can never
point past the end (e.g. a quote ending in trimmed whitespace).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApuEseGcFjUFqYoLhCuR91
A kind:9802 highlight can carry a huge `context` tag — a quote pulled from
the middle of a long article may ship several paragraphs of surrounding
text. Rendered whole, that fills the feed card with paragraphs around a
one-sentence highlight.
Trim the context in `HighlightQuote.of` to at most ~160 characters on each
side of the marked quote, snapping the cut to a whole-word boundary and
marking it with an ellipsis. The quote itself is always kept in full and
the marked range is re-based onto the trimmed text, so the in-context
marker still lands exactly on the highlighted passage. Short contexts are
left untouched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApuEseGcFjUFqYoLhCuR91
Posts were publishing a flood of bogus `r` tags — the v1.13.0 release note
shipped 11 junk references such as `https://.deb/`, `https://window.nostr/`,
`https://kind:30166/` and `https://crowdin.pretended462/`.
The reference extractor `findURLs` fed the raw, scheme-less UrlDetector output
straight into `r` tags. That detector is deliberately eager: to let the
rich-text renderer linkify a bare `example.com`, it also reports every
`word.word`, `word/word` or `word:port` token with no real-TLD whitelist.
Prose is full of those (`.deb`, `.rpm`, `[database].backend`,
`nostr-wallet-connect/nwc`, `~2.5x`, `@mentions`), so each one became a
reference on the published note. The rich-text side already guards against
this via UrlParser (TLD validation + scheme separation); the tag path never
got the same guard.
Require an explicit http/https scheme and a valid TLD before a detected URL
becomes a reference. Rendering is untouched (separate parser), so bare domains
still show as links — they just no longer pollute the tags.
Adds regression coverage over the exact fragments from the v1.13.0 note plus
checks that real, explicitly-schemed links are still extracted.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L8KXx8UQ6mgyxjBSWW6sQV
A kind:9802 highlight with no `context` tag falls back to reconstructing
the surrounding passage from the W3C `textquoteselector` prefix/suffix.
Those fragments are scraped from the source web page, so they carry the
page's block-boundary whitespace (runs of newlines/spaces between DOM
nodes). Glued in verbatim as `prefix + content + suffix`, they render as a
stack of blank lines above the marked quote.
Collapse each whitespace run in the prefix/suffix to a single space (and
trim the outer edges) in `HighlightEvent.contextOrReconstructed()`. The
highlight's own `content` is left verbatim so its offsets inside the
reconstructed context stay exact for the in-context marker.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApuEseGcFjUFqYoLhCuR91
EventListMatchingFilter had the same mutable-sort-key defect as
NoteListMatchingFilter: it stored Notes in a ConcurrentSkipListSet ordered by
the live created_at, so a newer replaceable version (which mutates the shared
AddressableNote in place) stranded its node and let the same instance be
inserted twice — the emitted event list then carried the same event twice. It
hadn't surfaced as a crash only because its consumers (app recommendations,
relay groups, room reactions) happen to dedup downstream.
Apply the same capture-key + idHex-dedup + per-key compute design, but preserve
EventListMatchingFilter's update-reflecting semantics: an addressable update
re-emits (the snapshot reads the refreshed event live off the note) rather than
being ignored. It keeps the entry's captured position instead of re-sorting —
re-sorting via remove+add let two entries with different captured keys for the
same note transiently coexist and both read the same live event, duplicating it.
Also harden both filters' emission: a ConcurrentSkipListSet iterator is weakly
consistent, so under concurrent add/remove churn a single traversal can
momentarily surface a key twice. snapshot() now dedups by idHex so the emitted
list — the LazyColumn's source of keys — is always unique, regardless of
transient internal states. Corrected the over-claimed "can never hold two"
docstrings accordingly.
Adds EventListMatchingFilterTest mirroring the note tests: update-reflection,
version-note re-emit, sorted order, remove-after-mutation, and two concurrency
stress tests (with/without limit) that failed before this fix.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ah1aCniyjnzc27x4pwq2Df
Wire up the existing kind-9008 DeleteGroupEvent, which was parsed on
receipt but never sent. Admins/owners can now delete a whole channel
(Buzz) or group (NIP-29) from the channel overflow menu.
- Account.deleteRelayGroup: sends the kind-9008 delete-group to the
channel's relay(s) and drops it from the local list.
- AccountViewModel.deleteRelayGroup: signer-dispatched delegate.
- RelayGroupTopBar: admin-only "Delete channel" / "Delete group" item
(error color), gated on RelayGroupMembership.ADMIN — the same
authorization as Edit — behind a confirmation dialog that pops back
off the deleted channel's screen.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bfd77tUsZ8h6ywcgwY7VVB
canPop() read the globally-current back-stack entry via
currentBackStackEntryAsState(). A pop commits the instant it is accepted
— most visibly during a predictive back-swipe, whose exit animation is
long and finger-driven — so controller.currentBackStackEntry flips to the
destination while the screen being dismissed is still on screen, sliding
out and still composing its top bar. Evaluating canPop against the
incoming destination there dropped the back arrow (and re-showed the
bottom bar) before the outgoing screen had finished leaving, which is
exactly the flicker seen when back-swiping to Home or a bottom-nav root.
Evaluate poppability against the screen's own NavBackStackEntry — the one
the NavHost provides to each destination via LocalViewModelStoreOwner —
which is intrinsic to that screen and never changes for the life of its
composition. The arrow now stays put until the screen itself is gone.
Callers outside a NavHost destination (shell chrome, drawer) see the
account-scoped owner instead of an entry and fall back to the previous
globally-current behavior.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011matx1mGJ6ZyS5dS77EQUo
The `buzz_dm_hide` string key exists only in translation files but has no
entry in the default `values/strings.xml` and is not referenced anywhere in
code. This triggered the lint ExtraTranslation error and failed the
`:amethyst:lintFdroidBenchmark` build. Removed the orphaned key from all 7
locale files.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ksnCb8x45HnL7nFPzxh4B
Add coverage for a real consumeBaseReplaceable call the suite was missing:
observers are also notified with the "version" note (getOrCreateNote(event.id),
a regular Note carrying the AddressableEvent), which the addressable-list guard
must drop while still listing the AddressableNote for the same event.
Confirmed the concurrency the stress tests exercise is real, not theoretical:
relay events are verified+consumed inline on per-relay socket dispatchers, so
distinct relays drive new()/remove() on the same note instance concurrently.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ah1aCniyjnzc27x4pwq2Df
Raise every ResourceUsageAlerts threshold to 2x so the "this app is
consuming too much" report prompt only fires at twice the previous
consumption levels, cutting false positives on heavy-but-normal days:
- background mobile data: 50 MB -> 100 MB / day
- background mobile relay-connection time: 12 h -> 24 h / day
- notification wakelock: 30 min -> 60 min / day
- app process starts: 75 -> 150 / day
- completed relay (re)connections: 5000 -> 10000 / day
Tests reference the constants symbolically, so the alert suite stays green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R2efhyiQHKFoNjQo6WnGRH
The recent-posters facepile in a Buzz community's channel rows drew each
poster with ObserveAndDrawInnerUserPicture — a bare cropped image wrapped
in a surface-coloured ring, overlapped into a deck. That omitted the
following badge and trust-score tag every other user avatar in the app
carries.
Draw each poster with ClickableUserPicture (the regular BaseUserPicture
path) instead, so the facepile shows the following icon (top-right) and
trust-score tag (bottom-centre). Lay the avatars out with a small gap
rather than an overlapping stack so those badges stay readable.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013f3JZdoChYEEDtqTArFAxn
"Leave" meant three different things and the actions were scattered across
each channel's own screen, so they were hard to find from Messages. Make the
vocabulary consistent and reachable:
- "Leave" now always means "renounce membership / you're out" — the kind-9022
LeaveRequestEvent for NIP-29/Buzz groups, and the kind-13302 self-list removal
for Concord (its only exit).
- "Remove from Messages" is the single soft action: take it off my list but keep
membership. For a joined relay group this drops the kind-10009 entry without a
9022 (I stay in the roster) and dismisses the invite so a Buzz kind-44100
re-announce can't bounce it back. The Buzz DM "Hide conversation" reuses the
same label.
Surface both on the Messages rows via long-press (previously only reachable
inside each group/community screen):
- Relay-group row: "Remove from Messages" + "Leave".
- Concord row: "Leave" (reuses the existing confirm dialog; a community has no
soft/hard split since the list entry is the whole membership).
Split the relay-group top-bar menu into the same two actions, thread an optional
onLongClick through ChannelName, and consolidate the buzz_dm_hide string into the
shared remove_from_messages string.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NRifAJ75U4zWbg3V3Y3g8g
The Buzz relay group (community) top bar and its sub-screens (members,
threads, browse, channel list), the Buzz canvas, geohash chat/new/teleport
screens, the single-URL and single-geohash viewers, and the Marmot group
chat all forced FontWeight.Bold (geohash chat also titleMedium) on their
top-bar titles, while every sibling channel header — DM rooms, public
chats, ephemeral chats, live activities — and the shared TopBar wrappers
render the Material3 titleLarge default at normal weight.
Drop the bold (and size) overrides so all top nav bar titles share one
consistent treatment.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019N7b8D4vBvafhG9eU7M9iJ
Audit follow-up. The previous fix used two independent concurrent structures
(ConcurrentSkipListSet + ConcurrentHashMap) coordinated with putIfAbsent, but
observer callbacks fire from multiple consume threads at once (relay ingest +
UI-side justConsume). A new()/remove() interleaving for the same idHex could
desync the two structures — remove() clears byId and no-ops on the sorted set
before new() has added the entry — leaving an orphan that a later new()
duplicates, reintroducing the duplicate-key crash.
Keep it lock-free (this observer is used everywhere and needs the throughput):
every write to the sorted index for a given idHex now happens inside that key's
ConcurrentHashMap.compute critical section, so the sorted set and membership map
move together. ConcurrentHashMap stripes per key, so same-idHex ops serialize
while different keys stay fully parallel. Invariant: an entry is added to the
sorted set only while its key is absent from byId, and every path that frees a
key removes its sorted entry first, so the set can never hold two entries for
one idHex.
Adds concurrency stress tests (with and without a relay limit) that fan out 8
threads hammering new/remove while created_at churns; both fail against the
non-atomic version and pass here.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ah1aCniyjnzc27x4pwq2Df
Address review feedback: keep the incrementally-maintained, created_at-sorted
structure (a feed must stay sorted like a relay) instead of re-sorting a hash
map on every emission.
The root cause is unchanged: there is one Note instance per id/address
(LocalCache owns creation), but a note's sort key is mutable — a newer
replaceable event swaps the event on the SAME AddressableNote instance,
changing created_at in place. A sorted set ordered on that live value corrupts:
the moved node leaves the add()/remove() search path, so the same instance is
inserted twice and the emitted list carries a duplicate idHex, crashing the
App Recommendations LazyColumn (keyed on idHex).
Fix: snapshot the sort key into an immutable Entry when the note first enters,
order a ConcurrentSkipListSet on that snapshot (never read live again), and
index entries by the stable idHex (ConcurrentHashMap + putIfAbsent) so
membership stays unique and removal is reliable regardless of later created_at
changes. Ordering and "new versions do not update the list" are preserved.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ah1aCniyjnzc27x4pwq2Df
Three top nav bar titles overrode the Material3 titleLarge default with
titleMedium, rendering ~16sp while every other top bar title inherits
~22sp. Drop the titleMedium override so the Calendar event detail and Git
repository (home + sub-screen) titles match the rest of the app.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019N7b8D4vBvafhG9eU7M9iJ
Sweep of every imePadding() in the app for the same nav-bar-over-IME
double-count fixed for the chats. Two more bare Material3 Scaffold screens
applied the scaffold content padding (which carries the nav-bar inset) and
then imePadding() without consuming in between, so the nav bar stacked on top
of the IME while the keyboard was up:
- NewGeohashChatScreen (geohash create form)
- MarmotGroupInfoScreen (has the add-member search field)
Both now consumeWindowInsets(pad) before imePadding(), matching the idiom the
other forms already use. Every other imePadding() call was verified fine:
DisappearingScaffold-based screens are handled by the scaffold's own
reservation, dialogs/bottom sheets carry no nav-bar content padding, and the
rest either apply imePadding() alone at a root or already use the
navigationBarsPadding()/consumeWindowInsets union.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012dT2RLbPZzan2hULL2cmc6
NoteListMatchingFilter (backing LocalCache.observeNotes) stored notes in a
ConcurrentSkipListSet ordered by CreatedAtIdHexComparator. AddressableNotes are
mutable: when a newer replaceable event arrives, LocalCache swaps the event on
the SAME note instance (consumeBaseReplaceable -> loadEvent), changing its
createdAt in place, then re-notifies observers. A sorted set cannot survive a
member's sort key mutating underneath it — the moved node is no longer found on
the add() search path, so the same note gets inserted a second time and the
emitted list carries a duplicate idHex.
The App Recommendations screen keys its LazyColumn on note.idHex (an
AddressableNote's address, e.g. 31990:<pubkey>:nostr-dvm-labeler), so the
duplicate crashed with IllegalArgumentException: "Key ... was already used".
Dedupe by the immutable idHex instead of a createdAt-ordered set; ordering is
computed fresh on each emission. Adds a regression test reproducing the
multi-item corruption path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ah1aCniyjnzc27x4pwq2Df
A dissolved community (owner-signed kind-3308 tombstone) is sealed
read-only per CORD-02 §9: held keys still open history, but nothing new
is honored. The `dissolved` flag was folded in quartz but ignored by the
write gates, so members — and the CLI — could still post to a dissolved
community.
- commons: ConcordChannel now tracks `dissolved` from the folded state
and `canPost()` returns false when set, so the Android composer (which
gates on it) is hidden. The self-delete carve-out is unaffected — it
runs through the note context menu, not the composer.
- amethyst: show a read-only notice where the composer would be so the
seal is explained rather than silent.
- cli: `amy concord send` folds the community and refuses with a
`dissolved` error before building/publishing; `amy concord channels`
surfaces the `dissolved` flag.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HateTjrutJ23wAEttEwHQA
The Browser home "Discover nsites/napplets" sections render each followed
manifest in a LazyVerticalGrid keyed by "ns:"/"np:" + coordinate. The
observed store (NoteListMatchingFilter, backed by a ConcurrentSkipListSet
ordered by created-at/id) can surface the same addressable note twice when a
replaceable manifest gets a new version — mutating the note's sort key inside
the set breaks dedup. Both copies map to the same coordinate, producing a
duplicate grid key and crashing with IllegalArgumentException.
Collapse the mapped list by coordinate so each app appears once and grid keys
stay unique.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9TomvuP9YnPUDCZuiQae6
While the keyboard was up, the public / relay-group / live-activity /
ephemeral chats — and the Concord channel + minichat views — left an extra
navigation-bar-height gap between the composer and the keyboard. WindowInsets.ime
is measured from the bottom of the screen, so it already spans the nav-bar
band; reserving the nav bar again on top of it double-counts. NIP-17 DMs were
unaffected because their scaffold reserves the nav bar with the consuming
navigationBarsPadding(), which excludes the already-consumed IME inset (a
union), while the bottom-bar chats reserved it as a plain, non-excluding pad.
- DisappearingScaffold: when the bottom-bar slot renders empty it now reserves
max(0, navigationBars - ime) instead of the full nav-bar inset, so the
reservation drops to zero while the keyboard is up (the root imePadding has
already lifted the scaffold above it). Covers every bottom-bar chat at once.
- ConcordChannelScreen / MinichatScreen: use the standard
padding(padding).consumeWindowInsets(padding).imePadding() union instead of
summing a plain padding(padding) with imePadding().
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012dT2RLbPZzan2hULL2cmc6
Leaving a chat with the keyboard up via a back gesture left a large IME
padding stranded at the bottom — and, because WindowInsets.ime is a single
app-wide holder, on every other screen too — until some later inset pass
happened to rebalance it. It only reproduced on release builds.
Root cause: the chat composers install a BackHandler that flushes the draft
and pops the screen. When that pop runs while the keyboard is still visible,
the predictive-back window animation races the IME's close animation. On an
optimized release build the window animation wins, and the IME
WindowInsetsAnimation is cancelled before its terminal (zero) frame reaches
Compose, so the shared insets holder stays "animating" and every
imePadding() in the app freezes at the keyboard height. Debug and benchmark
builds are slow enough that the IME animation completes first, which is why
they were unaffected.
Fixes:
- New KeyboardAwareBackHandler: while the keyboard is on screen it does not
consume back, so the system dismisses the keyboard first (its animation
completes cleanly); the next back runs the original handler. The top-bar
back arrow remains an always-available exit. Adopted in the DM, public-chat
and new-group-DM composers.
- Rederive keyboardAsState() from WindowInsets.ime instead of the
pre-edge-to-edge getWindowVisibleDisplayFrame/OnGlobalLayout heuristic,
which under enableEdgeToEdge() could itself latch Opened after the keyboard
closed. Now it tracks the same inset that drives imePadding().
- Concord channel chat and the minichat thread view used a bare Material3
Scaffold whose content insets ignore the IME; add imePadding() so the
composer rides above the keyboard while typing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012dT2RLbPZzan2hULL2cmc6
- Rename LocalCache Dao overrides to match supertype parameter names
(getOrCreateUser: pubkey->hex, getOrCreateNote: idHex->hex,
getOrCreateAddressableNote: key->address) so named-argument calls stay safe.
- Replace deprecated MenuAnchorType with ExposedDropdownMenuAnchorType in the
Buzz dropdown composables.
- Remove the buzzChannelType() extension in BuzzChannelMetadata, now shadowed by
the equivalent (stricter) member on GroupMetadataEvent, and drop its unused
imports.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018VBP6HGj9M2kzA4WKAivjC
Move the bottom navigation bar entry out of App Settings and into the
Account Settings section of the All Settings catalog, since its state is
stored per account (AccountNavigationPreferencesInternal.bottomBarItems in
the account-synced settings blob).
While auditing the App Settings section for other per-account entries, two
more were purely per-account and moved alongside it:
- Reactions settings (reaction row actions) writes account-synced
reactionRowItems and now sits next to the existing "Reactions" entry.
- Messages settings writes account-scoped chat feed toggles and view modes
(account.settings.enabledChatFeeds / relayGroupViewMode / concordViewMode).
The remaining App Settings entries stay put: UI preferences, Home tabs,
Profile UI, Calendar reminder, OTS, Namecoin and Resource usage are all
device-global, and Compose/Notification settings are mixed (they hold
genuine app-global preferences alongside a few per-account toggles).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SH88gCbbdTfdzp9wrr7buJ
Persisting a bottom-bar edit went through `launchSigner { account.change... }`,
which dispatches on a multi-threaded pool. Because the reactive StateFlow emit
happened inside that coroutine, two quick edits (e.g. tapping Add on several
catalog rows) could complete out of order: the older list would win the flow,
and the settings screen — which re-seeds its editable list from the flow via
`LaunchedEffect(savedItems) { syncFrom(...) }` — would visibly revert the newer
edit and publish the stale list in the NIP-78 event.
Apply the change to the in-memory flow synchronously on the caller (UI) thread
via `Account.applyBottomBarItems` (a non-suspending emit + local save, both
non-blocking) so rapid edits stay strictly ordered, and run only the
sign/encrypt/publish off-thread. Restores the ordering guarantee the previous
synchronous `tryEmit` had.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJiPHArXZ7P5EvZa7GN9fP
Replace the hand-rolled RobohashFallbackAsyncImage + observeUserInfo +
CreateTextWithEmoji in the background-accounts list with the app's standard
user components: LoadUser resolves the User behind each npub, and
ClickableUserPicture / UsernameDisplay render the picture and name. This
reuses the shared metadata observation, robohash fallback, custom-emoji
rendering, per-user nickname handling, and npub fallback instead of
duplicating that logic here.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvB5t1ittHpw4PdJQYZYfp
The bottom nav row configuration was an app-global setting stored in the
shared DataStore, so every account shared one bar. Move it into the
per-user NIP-78 app-specific data event (AppSpecificDataEvent) so each
account keeps its own bar and it syncs across the user's devices.
- Add `navigation.bottomBarItems` to AccountSyncedSettingsInternal (the
serialized/encrypted synced-settings blob) and mirror it as a StateFlow
in AccountSyncedSettings (seed / toInternal / updateFrom).
- Add AccountSettings.changeBottomBarItems, Account.changeBottomBarItems
(republishes the NIP-78 event), and AccountViewModel.changeBottomBarItems
/ bottomBarItemsFlow().
- Remove bottomBarItems from the app-global UiSettings / UiSettingsFlow /
UiSharedPreferences (including the now-unused encode/decode migration
helpers).
- Point the live bar, navigation rail, preloaders, subscriptions and the
Bottom Bar settings screen at the per-account flow.
No migration from the previous app-global setting: accounts start from the
default bar, matching the requested behavior.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJiPHArXZ7P5EvZa7GN9fP
The background-accounts list in the Notification Settings screen showed
each account by its npub only. Resolve the User behind each account and
observe its live metadata so the row displays the profile picture and
best display name (with the npub as a secondary line), falling back to
the short npub while metadata loads.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvB5t1ittHpw4PdJQYZYfp
DesktopLocalCache stores users in LargeSoftCache, which holds each User
via a WeakReference. FindUsersTest populated the cache but kept no strong
reference to the created users, so a GC between consumeMetadata and
findUsersStartingWith could collect them — LargeSoftCache.forEach then
skips (and evicts) the cleared entries, making the search return fewer
results. multipleUsersWithMetadata allocates the most objects and tripped
this most often (AssertionError at FindUsersTest.kt:115).
Verified with a forced-GC probe: without a strong reference all three
users were evicted (found=0); holding a reference kept all three (found=3).
Pin each test's users in a strong-reference list that stays reachable
through the assertions, mirroring how Notes/Account/follow lists keep
authors alive in the real app.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X4BThbs8tz9egmFDYv2RPB
Break the newly added BOLT12, Buzz Agent Work board, Blossom, Cashu,
NWC, search-indexing, and geode bullets into short verb-first sentences,
removing em-dash run-ons to match the changelog house style.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017hmbpUJPxrsN4m85Kwemcw
LocalCache already has getOrCreateUser/getOrCreateNote/getOrCreateAddressableNote,
so make it implement Dao and default NewMessageTagger's `dao` to LocalCache
itself — no wrapper object, no interface-default methods. Dao drops the
`suspend` modifiers (LocalCache's lookups are synchronous) so the object
satisfies the interface; AccountViewModel's delegating overrides follow suit.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RcWSCNMTVcvKMXo7xA2hue
Move the LocalCache calls off the Dao interface (back to a pure abstract
contract) and into the implementations: AccountViewModel keeps its own
overrides, and the default Dao used by callers without a ViewModel is an
anonymous implementation on NewMessageTagger's `dao` parameter.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RcWSCNMTVcvKMXo7xA2hue
Replace the standalone LocalCacheDao object with default method
implementations on the Dao interface itself, backed by LocalCache. The
NewMessageTagger `dao` parameter now defaults to a bare `object : Dao {}`,
so callers with no AccountViewModel (model-layer sends, the notification
receiver) just omit it, while UI callers keep passing their AccountViewModel
(which resolves to the same LocalCache calls).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RcWSCNMTVcvKMXo7xA2hue
The lightweight reply paths built their events from raw text, so a person
cited with `@name`/`nostr:` was neither resolved into a `nostr:` reference
nor tagged with `p`. Run NewMessageTagger on these paths too, backed by a
new LocalCache-based Dao so they work without an AccountViewModel:
- New `LocalCacheDao`: a `Dao` delegating to `LocalCache`, for mention
resolution outside a ViewModel (model-layer sends, background receivers).
- `Account.sendMinichatReply` (kinds 9 + 1111): resolve mentions and emit
`p` tags for cited users across the public-chat, Buzz, and NIP-29 group
branches (parent author excluded to avoid a duplicate).
- `NotificationReplyReceiver.sendPublicReply` (kinds 1 + 1111): same
resolution for the system notification quick-reply.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RcWSCNMTVcvKMXo7xA2hue
Extends the mention p-tag fix to the shared chat/thread composers, which
resolved `@`/`nostr:` mentions via NewMessageTagger but then dropped the
cited users from the signed event:
- kind 9 (ChatEvent, NIP-29 group chat): the reply path tagged only the
parent author and the plain build path tagged no one. Emit `p` tags for
every cited body user (reply path keeps the parent's relay-hinted tag and
excludes it from the extra set to avoid a duplicate).
- kind 1111 (CommentEvent) minichat reply: replyBuilder auto-tags the
parent/root author but body mentions were dropped. Notify the other
cited users (parent excluded to avoid a duplicate).
- kind 11 (ThreadEvent, NIP-29 group thread): the ShortNote thread branch
added no `p` tags at all; emit them from the cited body users, matching
the sibling Poll/ZapPoll branches.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RcWSCNMTVcvKMXo7xA2hue
Adds NegentropyMultiRelayLiveTest (gated NEG_MULTI=1): runs negentropySyncOrFetch
against 30 reachable public relays and asserts none hangs. Live run: 0 hangs,
0 errors — 10 reconciled via native negentropy, 20 fell over to paging, across
9+ relay softwares (strfry, ditto, purplepag.es, nostr.wine, nostr-rs-relay,
NFDB, rockstr, wot-relay, nostrcheck).
Confirms both fallback paths: the NOTICE fast-path (~1-4s) for relays whose
refusal names negentropy / unknown-envelope, and the idle-watchdog backstop
(~20s) for the rest (e.g. damus silently ignores NEG-OPEN, snort answers
"Unknown message type: NEG-OPEN") — now reliable because NOTICE/CLOSED no longer
reset the watchdog. Matrix recorded in the plan doc.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6ZVTixuc1ef8eGB6MQHRn
Audit follow-ups on the NIP-77 client, found while reviewing the notice-rejection fix:
- isOverflow was too broad. A bare "too many"/"too large" match meant a
NON-shrinking error ("too many requests", "too many concurrent subscriptions")
was read as a set-too-large overflow. Such an error doesn't shrink with the
window, so every created_at split re-triggers it and reconcileWindows walks
toward 1-second leaves, queueing up to ~2^31 Filters (OOM + relay hammering).
Tightened to result-set-qualified phrases (too many records / too many query
results / result set too large / max_sync_events); rate/quota errors now fail
over to paging.
- Added a MAX_WINDOWS (100k) backstop in reconcileWindows: a wording-independent
guard that bails to paging if a split ever fails to converge, so no novel
overflow-looking-but-non-shrinking error can storm.
- Window split dropped future-dated events. On overflow the upper child was
copy(until = hi) with hi = until ?: now(), so once any split happened, events
with created_at > now() (clock skew) were excluded though the un-split path
included them. The upper child now keeps the window's original until (may be
null = unbounded); the split math still uses now() so it converges.
- Hardened the NOTICE rejection matcher. isNegentropyRejectionNotice matched
bare "envelope"/"NEG-OPEN"/"NEG-MSG"; since a NOTICE has no subId and every
connection listener sees it, an unrelated notice on a shared connection could
abort a healthy reconcile mid-handshake. Narrowed to "negentropy"/"unknown
envelope"; the now-un-defeated idle watchdog is the wording-independent backstop.
- NegentropyStoreSync up-direction memory: haveBatches was an UNLIMITED channel
drained by a single network-bound uploader, so a first push of a large store
buffered O(local-set) ids. Bounded it like needBatches to back-pressure the
reconcile.
- Docs: flagged negentropySyncOrFetch's O(delivered) cross-phase dedup memory
(steer bulk mirrors to negentropySync/negentropyReconcile); corrected the
stale onEvent "reader thread" note (it runs on the delivery consumer).
Tests: NegentropyErrorClassificationTest pins both wording classifiers;
NegentropyRejectionFallbackTest adds a rate-limit NEG-ERR case asserting paging
after exactly one NEG-OPEN per phase (no split storm).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6ZVTixuc1ef8eGB6MQHRn
The Buzz composers dropped `p` mention tags for people named in a
message body, so a member cited with `@name` was neither notified nor
linkable and the relay couldn't resolve the `nostr:` reference:
- Stream chat (kind 40002): only the reply-parent author got a `p` tag;
body mentions from the tagger were ignored. Emit `p` tags for every
cited user (dedup covers the reply author).
- Stream edit (kind 40003): carried no mentions at all — a mention added
by an edit lost its `p` tag. Emit them from the edited text.
- Forum reply (kind 45003): never ran NewMessageTagger, so `@`-mentions
stayed literal and only the reply-parent author was tagged. Run the
tagger to resolve mentions and emit their `p` tags.
- Forum post (kind 45001): the root composer built the event from raw
text with no mentions. Run the tagger there too.
Mirrors the mention p-tag handling every other chat kind already does.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RcWSCNMTVcvKMXo7xA2hue
Relays that advertise NIP-77 in NIP-11 but refuse it at runtime answer a
NEG-OPEN with a connection-level NOTICE (which carries no subId) instead of a
subId-addressed NEG-ERR:
- strfry with negentropy disabled: "ERROR: bad msg: negentropy disabled"
- purplepag.es (no NEG envelope): "failed to parse envelope: unknown envelope label"
reconcileStreaming only routed NegMsg/NegErr for its exact subId into the driver
channel, so the NOTICE was dropped and the driver blocked in receiveWithinIdle
with no terminating frame. Worse, the connection-level idle watchdog was bumped
by every relay message, so unrelated refusal chatter (a rejected keep-alive REQ
being re-CLOSED on re-sync) reset it forever and it never fired. Net effect:
negentropySync/negentropyReconcile hung against relay.primal.net and
purplepag.es, and negentropySyncOrFetch never reached its paging fallback.
Fix, in reconcileStreaming's connection listener:
- route a CLOSED for our NEG subId into the driver as a terminal failure;
- treat a negentropy-refusal NOTICE as terminal, bound to this session by
phase (before the first valid NEG frame) + wording (isNegentropyRejectionNotice),
so an unrelated NOTICE on a healthy relay mid-reconcile cannot abort a
progressing sync;
- stop bumping the idle clock on NOTICE/CLOSED so refusal chatter can no longer
keep a dead sync alive; it now advances only on real progress (this session's
NEG frames and the download REQs' events/EOSEs).
The refusal now surfaces as NegentropySyncException(UNAVAILABLE); negentropySync
throws promptly and negentropySyncOrFetch pages the same filter (both relays
answer ordinary REQs fine). Verified live: primal 0-event hang -> pages 11.7k
events in 6.4s; purplepag.es 0-event hang -> pages continuously.
Tests:
- NegentropyRejectionFallbackTest: offline, deterministic; a scripted fake
relay answers NEG-OPEN with each observed NOTICE and asserts the sync throws
UNAVAILABLE fast and negentropySyncOrFetch sets pagedFallback.
- NegentropyStallRepro: gated live repro against the three real relays.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6ZVTixuc1ef8eGB6MQHRn
ContactCardEvent.indexableContent() already indexed the public summary
and topics; add the public petName() tag alongside them so a card can be
found by the nickname its author gave the target user. petName()/summary()
read the public tag array, so any petname kept in the NIP-44 encrypted
content stays out of the index (privacy preserved).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WHSTACrFHaRX1o6C5cEk6N
Collapses the operator setup from an 8-flag command + two hand-written scripts to
essentially two commands, without removing any of the safety.
- `buzz workflow run` gains `--accept-from-channel` (parity with the job
scheduler): scope intake to the channel's kind-39002 roster instead of pasting
every teammate key. `--worktree` now defaults to the current directory.
- Ship the gated reference wrappers (tools/buzz-agent/workflow-agent.sh →
agent+commit; workflow-ship.sh → push+PR after the gate), split around the
approval gate the way agent-exec.sh is the one-shot ungated version.
- `buzz agent up RELAY --repo DIR --approver NPUB` — one command: resolves the
channel (the relay's only one, or --channel), defaults worktree/intake, extracts
the bundled wrappers to ~/.amy/buzz-agent, and delegates to `workflow run`. The
only thing it can't default is the human approver.
- `buzz agent doctor [--repo DIR]` — preflight that turns the security checklist
into a green/red report: gh authenticated, token can write to the repo, default
branch protected against force-push, worktree clean. Exits non-zero if not.
- cli build: set duplicatesStrategy on processResources (the explicit
resources.srcDir re-adds the default root, which now doubles the bundled scripts).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011mApqAbr8vkLC7gUDjavu6
Massive review of every Event subclass in Quartz that carries
human-authored plaintext at rest but was not participating in the NIP-50
full-text index (SearchableEvent).
Add SearchableEvent to 15 kinds whose content is plaintext searchable
text a user would look for:
- 9737 Bolt12 zap intent (comment) — mirrors 9734/9736
- 5302 / 5303 NIP-90 content / people search request (search query)
- 31871 / 31872 attestation / attestation request — sibling family was
already searchable
- 1315 roadstr road-event report (free-text comment)
- 45001 / 45003 Buzz forum post / comment (body)
- 48106 Buzz huddle guidelines
- 30176 / 30175 / 30177 / 10100 Buzz team / persona / managed-agent /
agent-profile (name, description, system prompt)
- 30620 Buzz workflow definition (name + YAML)
- 3302 Concord chat edit (replacement message text) — mirrors kind-9 chat
Widen indexableContent() on 8 kinds that already implemented
SearchableEvent but dropped natural-language text carried in tags:
- 30020 auction — category hashtags were written by build() but never
indexed
- 12473 Birdex — species names
- 30382 contact card — public topics
- 9002 NIP-29 group-metadata edit — hashtags
- 30054 Podcasting-2.0 episode — topics
- 38192 PS1 save — region name
- 1111 comment / 1311 live-activity chat — hashtags
Encrypted-at-rest (NIP-04, giftwraps, MLS/marmot, NWC, cashu), purely
structural (relay/follow lists, reactions, deletions, moderation/presence
signaling), and ephemeral events were reviewed and deliberately left out.
The NIP-31 alt tag was also left out: it is frequently kind-level client
boilerplate and would dilute relevance.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WHSTACrFHaRX1o6C5cEk6N
Collapses the two near-identical agent backlogs (jobs 43xxx, workflow runs
46xxx) into a single board that speaks one vocabulary, so users stop having to
tell two protocols apart. The human-approval gate is no longer its own screen —
it's the top-sorted, glowing card state (NEEDS_APPROVAL).
- AgentWork.kt: AgentWorkState {NEEDS_APPROVAL,WORKING,QUEUED,SHIPPED,CLOSED} +
AgentWorkItem, with mappers from WorkflowRun and JobView and a merge() that
sorts needs-approval first, then working, then the upvote-ranked queue, then
shipped/closed.
- AgentWorkBoardViewModel: runs both subscriptions (jobs+upvotes, and workflow
base + by-author decisions) off subscribeAsFlow and folds them via the two
existing aggregators into one List<AgentWorkItem>.
- AgentWorkBoardScreen: one list; inline approve/deny on the gate card, View PR
on shipped, upvote/cancel on jobs, and a "New task" sheet whose single
"Require approval before it ships" toggle picks the protocol — on → a gated
workflow run, off → a direct job (no workflow definition required).
- Nav: the channel top bar's two board glyphs (Backlog + Workflow runs) collapse
to one "Agent work" entry (Route.BuzzAgentWork). The standalone JobBoard and
WorkflowRunBoard screens/routes stay as reference for now.
Prototype scope: strings inline pending adoption; the standalone board's
confirm-before-approve dialog and full localization are the carry-overs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011mApqAbr8vkLC7gUDjavu6
Watches, Strava, and auto-pause often split one long effort — a 5-hour run
with breaks — into several back-to-back exercise sessions. The New Workout
carousel offered each fragment as its own suggestion.
Add WorkoutMerger, which walks the detected sessions in start-time order and
folds any run of same-type sessions less than an hour apart into a single
DetectedWorkout: distance, calories, steps, elevation and duration are summed,
heart rate is duration-weighted, max heart rate is the max, and sessionCount
records how many were combined. Gaps are tracked per exercise type so a brief
cross-training block mid-run doesn't split the two run segments. The carousel
shows the combined count so the user sees it's one thing.
HealthConnectManager.readNewWorkouts now applies the merge before returning, so
the composer offers the whole effort as one post.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018UHCr6tW2xAXPJUVZjZPvA
Resolve all `:quartz:compileTestKotlin*` warnings without changing any
test's behavior:
- Drop redundant bare `Secp256k1Instance` "force crypto lib load"
statements (and their now-unused imports). JVM object initialization is
thread-safe and lazy, and every one of these tests exercises crypto, so
the lib loads on first use regardless — the eager reference was a no-op
the K2 compiler flags as an unused expression.
- PairingEventTest: use `assertIs` instead of `assertTrue(x is T)` + cast.
- MergeQueryCorrectnessTest: drop `!!` that smart-cast already made moot.
- PodcastCommentScopeTest / MintExceptionTest: widen the declared type so
the `is` checks are genuine runtime checks rather than always-true.
- FetchAllIdleTimeoutTest / NostrConnectSignerServiceTest: opt in to
`ExperimentalCoroutinesApi` at the class level.
- GiantReqStreamTest: name the `WebSocketListener` overrides' parameters
to match the supertype.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GyXakbYay4zNJ4cwoF4j7N
Drop the CORD-02 §6 banner hero that showed when a community was expanded
to OPEN in the Concord home screen, so opening a community and viewing its
channels no longer displays the banner. Removes the now-unused CommunityBanner
composable and its ContentScale/height imports.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6rz1csKDADKc5nr7R4a1b
Two bugs found auditing the P2PK redeem path:
- Hex case: a lock's `data` pubkey is sender-formatted and NUT-11 doesn't
mandate a case, but our key index is keyed by lowercase x-only (Hex.encode
is lowercase). An uppercase/mixed-case lock we actually hold the key for was
falsely rejected as unredeemable. Normalize the lock to lowercase before the
lookup, and compare identity-key locks case-insensitively.
- Multi-mint partial redeem: callers redeem one mint-group at a time, each
swapping + publishing. An unsignable P2PK lock in a later group threw only
after earlier groups were already spent + published, leaving a half-redeemed
state the user was told had failed. Add `firstUnsignableP2pkLock` /
`requireP2pkRedeemable` and pre-flight every group before redeeming any,
mirroring the existing unknown-mint pre-check (wallet ViewModel + amy CLI).
Also document that P2PK.signWitness's `["P2PK"` prefix guard is load-bearing
for safety (prevents cross-protocol signature reuse when signing with the
identity key), not just for parsing. Adds tests for case-insensitive matching
and the pre-flight helper.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QKeRaX749TYnJ7oR8UpqA4
Follow-up to the P2PK redeem support:
- Gate the redeem signing-key gathering behind an actual P2PK lock. The
wallet P2PK key is decrypted from kind:17375 via the signer — a network
round-trip on a NIP-46 bunker (and a possible approval prompt). The common
case (a plain, unlocked token) needs none of it, so only fetch keys when
`anyP2pkLocked()` is true. Applies to both the wallet ViewModel and the amy
CLI token-redeem path.
- Fast-reject in `P2PK.parseSecret`: NUT-10 well-known secrets are JSON
arrays, so bail before the throwing JSON parse when the string isn't one.
Redeem parses every proof's secret once, so this drops a thrown+caught
exception per plain proof (also benefits the nutzap redeem path).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QKeRaX749TYnJ7oR8UpqA4
The workflow run board, the attestation screen, and the persona editor
hardcoded English. Move their user-facing copy to values/strings.xml under the
existing buzz_ naming convention and read it via stringRes, so the surface is
translatable through Crowdin like the rest of the app.
- Snackbar/section/def-editor strings are resolved in composable scope into
vals (the coroutine lambdas and LazyListScope.section can't call stringRes).
- pillContent returns string res ids resolved by StatePill; a runHeadline()
composable centralizes the task/workflow-id/placeholder line.
Not extracted (documented): near-unreachable non-composable validation strings
in buildAttestation/parseHeldAttestation, the WorkflowDefOption id fallback, and
the kind/model/provider/runtime option *values* (data, not prose).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011mApqAbr8vkLC7gUDjavu6
- Attestation time conditions echo the entered epoch as a readable UTC time
in supporting text (mirrors the kind field) — no more eyeballing unix seconds.
- Agent-key picker shows an invalid-paste error inline on its own field
(isError + supportingText) instead of far down the form.
- WorkingLine pulse animates via graphicsLayer (draw phase) instead of
Modifier.alpha read in composition, so active cards redraw rather than
recompose each frame.
- Hoist the shipped-result URL Regex to a process-level val.
- LazyColumn items carry contentType so sub-compositions are reused across scroll.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011mApqAbr8vkLC7gUDjavu6
The boards derived their state from LocalCache.filter(kinds = 46xxx / 43xxx),
but LocalCache.filter only matches notes whose kind.isRegular() (< 10_000).
Every run/lifecycle/job kind is >= 43001, so the filter returned nothing and the
boards never displayed a single run/job against live data (verified with a probe:
a consumed 46020 matched 0, a 30620 def matched 1). Definitions (30620,
addressable) were the only thing that showed.
Aggregate straight off subscribeAsFlow, which accumulates the channel's stored +
live events (deduped by id) and re-emits the list — the data the aggregators
need. This also removes the per-batch whole-cache rescans.
- WorkflowRunBoardViewModel: base #h subscription + a nested by-author decisions
subscription (rebuilt only when the approver set changes via distinctUntilChanged)
so grant/deny now arrive live for every observer, not just once at open. Drop
46004/46011/46012 from the fetch set — the aggregator can't correlate them.
- JobBoardViewModel: aggregate jobs + kind-7 upvotes from the one #h subscription.
- Real success/failure feedback: trigger/approve/deny/defineWorkflow return a
result; snackbar only on confirmed publish; the sheet stays open on a failed
trigger; the definition editor shows an error + a Publishing… state instead of
hanging open and inviting duplicate 30620s.
- Gate write actions on isWriteable(): a read-only login no longer sees a false
"Approved" success, and the New-run FAB is hidden.
- WorkflowRunAggregator.fold: parse each event's JSON content once.
- Empty-state hint in the New-run sheet when no definitions exist yet.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011mApqAbr8vkLC7gUDjavu6
Pasting a P2PK-locked cashu token (NUT-11) into the wallet sent the proofs
to /v1/swap with no witness, so the mint rejected them with an opaque
`witness is missing for p2pk signature` 400. Only the NIP-61 nutzap path
signed witnesses; the generic redeem path had no P2PK support at all.
- quartz: add `signP2pkWitnesses` (pure, resolver-driven) + the
`P2PKUnredeemableException` it throws when a locked proof's key is
unknown, and a `CashuMintOperations.redeemToken` that signs then swaps.
- commons: `CashuWalletOps.redeemToken` now takes the wallet P2PK key and
(local-signer-only) identity key, indexes them by x-only pubkey, and
routes through the P2PK-aware path. Add `describeRedeemError`, which tells
a user whose token is locked to their own identity key (e.g. Bey Wallet's
P2PK send) — but who is on a bunker/external signer that can't sign a raw
witness — to import their nsec elsewhere to claim it.
- amethyst: `CashuWalletState.redeemSigningKeys()` surfaces both keys
(identity key only for a local NostrSignerInternal); the wallet ViewModel
wires them in and reports via `describeRedeemError`.
- cli: `amy cashu receive token` passes the same keys and reports a distinct
`p2pk_locked` error code.
Adds P2PKRedeemTest covering pass-through, x-only + compressed locks,
verifiable witnesses, and the unredeemable case.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QKeRaX749TYnJ7oR8UpqA4
Forum channels store their posts as threads (a separate store), not in the
chat notes the activity preview reads — so a forum row was opening a kind-9
chat warmup subscription that returns nothing and could never render a
last-message preview, facepile, or unread badge.
Add a `showActivityPreview` flag (default true) to BuzzImportRow that gates
the warmup, the notes-flow collection, and the preview/facepile/unread reads.
The forum section passes false, so a forum row skips the useless subscription
and shows a member-count summary instead.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FHnm6G9YytnLfs1ycYfK89
Replace raw-code text fields in the Buzz agent flows with pickers, matching
the workflow-definition picker and the app's existing people-typeahead.
- Agent Attestation "Agent public key": a single-agent people picker
(name typeahead over the local user cache + removable chip), with
npub/hex paste kept as the Enter escape hatch for keys that aren't
contacts yet.
- Agent Attestation "Restrict to kind": an editable dropdown of common
named kinds (1 Text note, 7 Reaction, …), free numeric entry preserved,
with the resolved name shown as supporting text.
- Agent Persona edit "Model / Provider / Runtime": editable dropdowns of
well-known values (claude-*, gpt-*, anthropic/openai/…, goose/…), free
entry preserved since these stay free-form on the wire.
- New reusable EditableSuggestDropdown (BuzzOptionDropdown.kt) backing the
open-ended pickers.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011mApqAbr8vkLC7gUDjavu6
Replace the free-text "Workflow id" field in the New Run sheet with a
dropdown of the channel's published workflow definitions (kind-30620),
shown by name, plus an inline editor to define a new one.
- Account.publishBuzzWorkflowDef: sign + publish a 30620 with a minted
workflow UUID, name, and YAML recipe; returns the new id.
- WorkflowRunBoardViewModel: fetch + watch the channel's 30620 defs
(#h-scoped) alongside the runs, expose them name-sorted as
WorkflowDefOption, and drive defineWorkflow(name, yaml).
- NewRunSheet: WorkflowPicker dropdown + DefinitionEditor; a just-created
definition auto-selects once it lands in the channel list.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011mApqAbr8vkLC7gUDjavu6
Approving publishes a signed grant that makes the runner push code and open
a PR — too consequential for a single stray tap. Tapping Approve (or Deny)
now raises a confirm dialog that states the boundary plainly ("Approving
never merges or deploys — that still happens on GitHub"); denying warns the
work is discarded. On confirm, a snackbar gives immediate, truthful feedback
("Approved — the runner is opening a pull request"), and the card moves to
Working/Approved, then genuinely lands in Shipped with the PR link when the
runner's 46005 arrives — rather than faking "PR opened" at tap time.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011mApqAbr8vkLC7gUDjavu6
The BOLT12 zaps feature was built against a placeholder NIP identifier
("nipXX" / "NIP-XX", and "NIP-2421" in one plan). The number NIP-B1 has
now been assigned, so update the naming across every module:
- rename the Quartz package `nipXXBolt12Zaps` -> `nipB1Bolt12Zaps`
(commonMain + commonTest) and every import referencing it.
- KDocs/comments: `NIP-XX` -> `NIP-B1` in quartz, commons, amethyst, cli.
- wire binding prefix: `nostr:nipXX:` -> `nostr:nipB1:`
(Bolt12ZapValidator.NIP_URI_PREFIX, NIP-47 pay `payer_note`, tests).
- KindNames: Bolt12 Zap / Bolt12 Offers NIP number "XX" -> "B1".
- plan doc references `NIP-2421` -> `NIP-B1`.
Leaves the unrelated `nipXXPodcasting20` package and the audio-rooms
draft (also placeholder "NIP-XX") untouched. quartz main + test compile
and spotless is clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012P3krSe92wicpswBr2CP9s
"Approve & ship" over-promised — approval pushes the branch and opens a PR;
it never merges or deploys (merge stays a human action on GitHub). The
clearer label states the actual boundary.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011mApqAbr8vkLC7gUDjavu6
Design polish pass on the Buzz boards, focused on the moment that matters:
a human authorizing an AI to ship code.
- The approval gate is now the board's centerpiece: an elevated, softly
pulsing card (glow border + gradient) with a filled circular gavel badge,
a headline-sized task, and a dominant "Approve & ship" action (Deny stays
a quiet text exit). Non-approvers see a "waiting on <approver>" pill.
- Real depth: state-tuned shadow elevation on every card, soft-filled status
pills, and section headers as an uppercase label + count chip.
- Motion that reads as "alive": an indeterminate progress bar on actively
running work (both boards), alongside the existing working-line pulse.
- Matching pass on the job board so the two surfaces stay one visual system.
No behavior change; pure presentation. Amethyst compiles clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011mApqAbr8vkLC7gUDjavu6
Phase 2 of the Buzz workflow support: a per-channel mobile surface for the
source-confirmed workflow primitive, mirroring the existing job board but
built around the human-approval gate.
- WorkflowRunBoardScreen + WorkflowRunBoardViewModel (per channel, entered
from RelayGroupTopBar on Buzz relays via a new Route.BuzzWorkflowBoard).
Folds the workflow kinds via the shared WorkflowRunAggregator, groups runs
by state with "Needs your approval" pinned first, and lets the named
approver grant/deny a paused run inline (46030/46031). A shipped run shows
its PR; merge stays on GitHub.
- Account: triggerBuzzWorkflow / approveBuzzWorkflowRun / denyBuzzWorkflowRun
(same sign → local-echo → publish-to-group-relay contract as the job
helpers).
- RelayGroupFilterBuilders: subscribe the #h-scoped workflow kinds (46020,
46001-46007, 46010-46012) on every group REQ. The client-signed
grant/deny (46030/46031) carry no h tag, so the board fetches them by
author — every 46010 gate names its approver in a p tag — matching the CLI.
- NotificationFeedFilter: a workflow approval gate (46010) addressed to me
notifies and is push-eligible (added to NOTIFICATION_KINDS + an
acceptableEvent early-return gating on approver()==me).
The quartz EventFactory registration and LocalCache ingest for these kinds
already existed, so no protocol/ingest changes were needed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011mApqAbr8vkLC7gUDjavu6
Switch the agent-support-channel prototype from the speculative agent-job
kinds (43001-43006, reserved with no upstream builder) to Buzz's real,
source-confirmed workflow primitive: 30620 def / 46020 trigger / 46001-46007
lifecycle / 46010 approval gate / 46030-46031 grant-deny (pinned against
buzz-relay's command_executor.rs). This bakes the human-approval gate into
the protocol — anyone in the channel can drive a run, but a human grants
before anything is pushed.
- commons WorkflowRunAggregator folds trigger + lifecycle + grant/deny into
per-run state (TRIGGERED/RUNNING/AWAITING_APPROVAL/APPROVED/COMPLETED/
FAILED/DENIED), correlating by run id (= trigger event id = approval token);
8-case test.
- cli `amy buzz workflow` — trigger/list/show/approve/deny plus the `run`
runner: per new trigger it does the agent work in a fresh worktree+branch,
posts the 46010 gate, and on a later poll runs --on-approve (push + PR) and
emits 46005 completed; a deny discards the worktree (run is DENIED).
On a real Buzz relay the relay executes the workflow YAML; self-hosted on
geode there is no engine, so amy is the runner and emits the lifecycle events
itself (documented divergence). Two store realities, both verified against
geode: decisions are fetched by author (quartz's store serves #d only for
addressable kinds, and 46030/46031 are regular), and the runner is
restart-safe (runs at the gate are rebuilt from the deterministic run id).
Also hardens the exec helper against a broken pipe when --exec doesn't read
stdin, and fixes worktree teardown to run git against the owning repo.
End-to-end headless harness (cli/tests/buzz/workflow-loop.sh) covers the
grant and deny paths through an embedded geode relay: 14/14 green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011mApqAbr8vkLC7gUDjavu6
Finished/failed jobs now land in the Notifications tab, addressed to the requester.
1. NotificationFeedFilter: an early-return branch accepts a JobResultEvent (43004) or
JobErrorEvent (43006) when it p-tags me (the requester) and isn't my own event —
mirroring the existing Buzz-DM branch, since I don't "follow" the workspace bot and
the job kinds aren't in the generic relevance path. It maps to the generic NoteCard,
so it renders the result (PR URL) / error text.
2. JobErrorEvent now carries the requester as a `p` tag (new requester() accessor + a
`requester` param on build, mirroring JobResultEvent); the scheduler passes
job.requester on both error paths. Previously a failed job wasn't addressed to anyone,
so a failure could never notify. Tests updated.
Relay sourcing (verified): a job outcome reaches LocalCache via the always-on `#h`
joined-group chat tail on the workspace relay (RELAY_GROUP_ALL_TIMELINE_KINDS includes
43001-43006), so for a shared channel the team has joined, results are pulled continuously
and now notify. A channel you haven't joined (or a job event with no `#h`) would still need
a dedicated `#p`=me subscription (mirroring BuzzDmDiscovery) — not added, since the
support-channel model always has members joined.
App compiles (fdroidDebug); quartz + commons tests green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011mApqAbr8vkLC7gUDjavu6
A thin server-side policy so `amy serve --buzz` hosts a private, agent-authorized
Buzz workspace on one JVM process — no Block Rust relay + Postgres/Redis/MinIO.
- quartz: BuzzMembershipPolicy : FullAuthPolicy (buzz/relay/). Runs the NIP-42
handshake, then layers Buzz's two authorization rules: (1) only members (the team)
may read/write; (2) NIP-OA virtual membership — an un-enrolled agent key is granted
membership for its connection when its AUTH event carries an owner-signed `auth` tag
whose owner is a member and whose signature authorizes that agent. An unauthenticated
read is told `auth-required` (not `restricted`) so the client runs NIP-42 and retries.
Deliberately does NOT emit relay-signed 39000-39003 metadata or run workflows (a
policy can't emit events; the job channel doesn't need them). 8 unit tests.
- cli: `amy serve --buzz [--members npubs]` composes the policy into geode's RelayEngine.
Members = admins + --members.
Verified end-to-end against a live `amy serve --buzz`: a member writes (published:true),
an outsider is rejected (restricted: not a workspace member); plus the 8-case unit suite
(member/non-member/unauth/reads/allowed-kinds/NIP-OA-agent/non-member-owner/tampered).
Plan doc + cli README updated with the two relay options (amy serve --buzz vs the Rust
stack) and when you'd need each.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011mApqAbr8vkLC7gUDjavu6
tools/buzz-agent/agent-exec.sh — the last mile from the scheduler to a live channel.
Honors the `amy buzz agent serve --exec` contract: reads the task on stdin, runs a
coding agent (Claude Code by default, or any $AGENT_CMD) inside the job's git worktree,
verifies a diff exists, commits, pushes the `claude/job-*` feature branch, opens (or
reuses) a PR, and prints the PR URL as the job result (kind-43004); any failure exits
non-zero → job error (kind-43006). It never touches the default branch and never
force-pushes — merge stays a human action on GitHub.
README documents the load-bearing guardrails, since Buzz enforces none of them: a
PR-only fine-grained token (Contents + Pull requests write, nothing else), branch
protection on the default branch (require PR + review + CI, block force-push/deletion),
and scoped intake (--accept-from) + agent tool allowlist.
Verified end-to-end against a throwaway repo with a stubbed gh + agent (happy path
pushes the branch and prints the PR URL; no-change path errors cleanly). Plan doc
follow-up #3 marked done.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011mApqAbr8vkLC7gUDjavu6
Second batch of audit fixes (an independent review confirmed C1 and surfaced these).
Scheduler (BuzzAgentCommands):
- H1: the runForever poll ran directly in supervisorScope, so a transient relay
error/drain-timeout thrown by selectPending killed the unattended daemon. Wrap each
poll in try/catch (rethrow CancellationException) and keep looping.
- H2: worktrees + branches leaked on Ctrl-C/kill (coroutine finally doesn't run) and a
leftover branch made a job permanently un-runnable on rerun. Add a JVM shutdown hook
that force-removes still-active worktrees/branches, and use `worktree add -B` so the
branch is reset-or-create (idempotent).
- M2: worktree lifecycle moved inside the try so the finally always cleans up, including
the failed-setup early return.
- M3: runOnce isolated each job in a try/catch so one throw can't cancel the batch.
App:
- M1: fileBuzzJob/upvoteBuzzJob/cancelBuzzJob now cache.justConsumeMyOwnEvent(signed)
before publish, so the board reflects the action immediately (publish only sends to
relays; the event wasn't in LocalCache yet, making the optimistic reload a no-op).
- L2: RelayStatusBar derives this relay's booleans with derivedStateOf, so global relay
churn no longer recomposes the whole bar.
- L4: the upvote reaction now carries NIP-25 `p` (job author) and `k` (kind) tags.
- L5: JobBoardScreen DisposableEffect keyed on (channelId, relayUrl).
Verified: cli/tests/buzz/job-loop.sh 11/11 green; app compiles (fdroidDebug).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011mApqAbr8vkLC7gUDjavu6
Audit fixes.
- BuzzAgentCommands.runExec: the responder read the child's stdout fully and THEN
its stderr, which deadlocks a chatty child (a coding agent easily fills the ~64 KB
stderr pipe while we block on stdout) and let the child hang past --exec-timeout
(readBytes has no timeout). Now stdout/stderr are drained on their own coroutines
and stdin is fed on another; the timeout is enforced by waitFor, and on expiry
destroyForcibly closes the pipes so the readers finish. Same concurrent-drain fix
applied to the git() helper. Verified: cli/tests/buzz/job-loop.sh 11/11 green.
- JobBoardScreen: bucket the backlog into a JobGroups holder under remember(jobs)
so the four filter/sort passes run once per new backlog, not on every recomposition
(e.g. each isLoading toggle).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011mApqAbr8vkLC7gUDjavu6
Make the shared backlog modern, alive, and transparent.
- RelayStatusBar: a live, tappable header that makes ALL of the workspace relay's
state observable in context — connection (client.connectedRelaysFlow), NIP-42 auth
phase (authCoordinator.authStateFlow, with a pulsing dot + lock icon), Buzz-dialect
marker, and an expandable NIP-11 panel (software/version, supported NIPs, limitations,
description, latency). Every signal is a real StateFlow/produceState, so it tracks
reality live. Reusable across agent surfaces.
- JobBoardScreen redesign: "Working now" is the visual hero (primaryContainer, a
breathing pulse on the streaming progress line, elapsed time); queued cards carry a
colored status rail and reorder by upvotes with animateItem; shipped cards are a
success tone with a prominent "View PR" (extracts the PR URL from the result);
closed cards mute. Real avatars + display names (UserPicture/UsernameDisplay/LoadUser)
instead of hex. Upvote chip animates its count; the composer is a friendly
ModalBottomSheet with example chips; a warmer empty state.
App compiles (fdroidDebug). No font regen (reused existing MaterialSymbols).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011mApqAbr8vkLC7gUDjavu6
Evaluate placement, then add the first interactive agent surface in the app.
Placement: the existing agent screens (AgentConsole/Attestation/PersonaEdit) are
owner-global telemetry entered per-relay and buried behind a channel-list footer.
A Buzz job is h-scoped to a channel, so the backlog is per-channel — it belongs
where Canvas/Forum already live (RelayGroupTopBar, gated by BuzzRelayDialect.isBuzz),
not inside the owner Console. Keep the two surfaces separate.
- JobBoardScreen + JobBoardViewModel (ui/screen/loggedIn/buzz/): the shared backlog
of one channel. Reads the job kinds (43001-43006) + their kind-7 upvotes scoped to
the channel h, folds via the shared BuzzJobAggregator, groups by lifecycle state
(In progress / Queued-by-upvotes / Done / Closed), live via subscribeAsFlow. Every
member sees the same board.
- Three write actions via new Account helpers: fileBuzzJob (43001, FAB → New task
dialog), upvoteBuzzJob (kind-7 "+" e-tagging the job, h-scoped so the scheduler +
board count it), cancelBuzzJob (43005, own jobs only). Merge is deliberately NOT
here — a done job's result is its PR; merge happens on GitHub.
- Route.BuzzJobBoard(channelId, relayUrl) + AppNavigation wiring + a Checklist entry
in RelayGroupTopBar next to the Canvas action.
Reuses the AgentConsoleViewModel fetch/watch pattern and existing MaterialSymbols
(no font regen). App compiles (fdroidDebug).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011mApqAbr8vkLC7gUDjavu6
Evolve `amy buzz agent serve` from a sequential responder into a scheduler that
manages a shared feature-request backlog by itself — the model where a whole team
drives an AI in one channel, not a 1:1 chat.
- Parallel execution with isolation: `--parallel N` runs up to N jobs at once,
each in its own `git worktree` + branch (`--worktree REPODIR`, off `--base-ref`,
named `<branch-prefix><jobid>`) so concurrent autonomous runs never clobber one
working tree. `--parallel > 1` requires `--worktree`; worktree add/remove is
mutex-serialized while the agent work runs concurrently. Branch/worktree/base-ref
are exported to `--exec` (BUZZ_BRANCH/WORKTREE/BASE_REF) so it commits, pushes the
branch, and opens the PR. Merge stays on GitHub — never here.
- Group-driven priority: BuzzJobAggregator now folds kind-7 upvotes (distinct
reactors, dislikes excluded) into JobView.upvotes, and `byPriority` orders the
backlog most-upvoted-first, oldest-first tiebreak. The stack reprioritizes itself
as the channel reacts. `buzz job list/show` surface upvotes.
- Channel-as-allowlist: `--accept-from-channel` obeys any member of the channel's
kind-39002 roster ("anyone in the channel can drive"), union with explicit
`--accept-from` npubs.
- Harness cli/tests/buzz/job-loop.sh gains a parallel case (3 jobs, --parallel 3,
one branch/worktree each, cleaned up); aggregator gains upvote + priority tests.
11/11 harness checks + all unit tests green.
Fits entirely inside amy: amy is the scheduler, the coding agent is whatever
`--exec` points at, GitHub owns merge. Plan doc updated with the model + the
"can this live in Amy" architecture note.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011mApqAbr8vkLC7gUDjavu6
Prototype the "human drives an AI coding agent to develop Amethyst" support
channel on top of the existing block/buzz integration, all through amy.
- commons: BuzzJobAggregator (BuzzJobs.kt) — a pure, tested folder that
correlates the Buzz agent-job kinds (43001-43006) by their `e` request
reference into JobView records with a REQUESTED→ACCEPTED→IN_PROGRESS→
COMPLETED/FAILED/CANCELLED state machine (newest terminal wins). Shared so a
future mobile Jobs board reuses one correlation path. 9 unit tests.
- cli: `amy buzz job request|list|show|cancel` (requester side) and
`amy buzz agent serve --exec CMD` (the responder loop): polls for REQUESTED
jobs targeting my key, gates intake on `--accept-from` (allowlist) and
`--channel`, then accepts (43002) → progress (43003) → runs `sh -c CMD`
(task text on stdin; BUZZ_JOB_ID/REQUESTER/CHANNEL/RELAY/AGENT in env) →
result (43004) or error (43006). Point `--exec` at a coding agent to drive it.
- cli/tests/buzz/job-loop.sh — self-contained headless harness over an embedded
`amy serve` relay; asserts the full loop AND the permission gate (an allowlist
excluding the requester handles nothing).
- Design doc cli/plans/2026-07-25-buzz-agent-support-channel.md: the three-layer
permission model (Buzz scopes by identity, not capability flags — so
"can't merge/destroy main" lives in GitHub branch protection + the `--exec`
credential, not the relay), the MVP architecture, and the prioritized mobile
app gap list (approvals inbox, jobs board, diff/PR review, …).
Schema caveat: kinds 43001-43006 are reserved in Buzz with no upstream builder;
the tag layout is Quartz's best-effort model, to be reconciled upstream.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011mApqAbr8vkLC7gUDjavu6
Redesign the Buzz workspace community view to feel closer to the Concord
server view — richer, more informative channel and DM rows, with actions
tucked behind overflow menus so the list reads cleanly.
- Title bar now uses a middle ellipsis on the community name so both ends
stay visible instead of truncating the tail.
- Channel cards (BuzzImportRow) now show a last-message preview (author +
snippet, or the Buzz activity summary for system/diff/job rows), a
recent-posters facepile, an unread-count badge, and the last-activity
time — reusing the Concord facepile/unread-badge composables. Each card
warms its recent messages while visible so previews fill in ahead of a tap.
- Pin/Unpin and Add-to-my-list move off the channel row into a per-channel
3-dot overflow menu.
- DM rows gain the same last-message preview line.
- Add-all and Agent Console move from the inline header button / footer card
into the community's top-bar 3-dot overflow menu.
- Add relay-group timeline helpers (newestTimelineNote, recentAuthorHexes,
relayGroupChannelUnreadCountFlow) mirroring the Concord ones.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FHnm6G9YytnLfs1ycYfK89
Align the discovery row's last-message preview with the pattern Concord's
list previews use: compute the newest chat message from the channel's note
cache reactively (keyed on the notes flow) instead of reading
Channel.lastNote. For relay groups the two are equivalent today — kind-11
threads live in a separate collection and kind-1111 comments aren't
attached to the group's notes — so this is a consistency/robustness change
that keeps the preview, its timestamp, and the message count all derived
from the same source, not a behavioral fix.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KAFP7qqYNxqRpxtofGWYsq
A NIP-29 relay pinned to the bottom bar navigates to Route.RelayGroupServer
(RelayGroupChannelListScreen), but that screen always drew a back arrow and
never rendered a bottom bar — so tapping the pinned relay icon dropped the
bottom nav and showed a back arrow, unlike every other bottom-nav root.
Mirror the norm the analog Concord server screen already follows: read
nav.canPop() once, show the back arrow only when pushed (drawer / another
screen), and add an AppBottomBar keyed to the relay's own route. AppBottomBar
hides itself on a bottom-nav root, so the relay now behaves both ways — a
bottom-nav tab (bar visible, no arrow) when tapped from the bar, and a pushed
detail (arrow, no bar) when opened from the drawer or elsewhere.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BU7StQsshfjcaLDXTBtnB2
Add a compact "2h"/"3d" last-activity timestamp to each discovery row's
name line, so the preview reads as recent activity with a clear recency
signal rather than an implied live feed.
On the discovery screen most groups are ones you haven't joined, and the
app's always-on live chat tail is joined-groups-only — a non-member isn't
streamed a group's new messages — so the last-message line is a snapshot
of recent public activity that fills in and refreshes as the row loads,
not a real-time ticker. Reword the code comment to match.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KAFP7qqYNxqRpxtofGWYsq
Update the on-device GenAI prompt dependency (play flavor only) from
1.0.0-beta3 to 1.0.0-beta4, the latest in-track release.
All other catalog entries are already at their latest stable versions.
appfunctions could not move to alpha10 because appfunctions-service
only publishes up to alpha09 and the three artifacts share one version.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZJ8ekaDDihWWhcJ6X2Fjz
The message-activity badge used the accent (violet) color, which reads as
an unread indicator elsewhere in the app. Render it as a muted
onSurfaceVariant chat-icon + count instead, so it clearly signals
activity volume rather than unread messages.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KAFP7qqYNxqRpxtofGWYsq