The in-chat markers read "member joined" / "visibility changed" — the relay's
raw payload type with the underscores swapped for spaces. Everything that makes
the line useful was parsed and then dropped: which member, who added them, and
what the setting changed TO.
Render the full sentence from the signed payload instead, with the pubkeys
resolved to the names the viewer knows them by and the avatar of whoever the
line is about:
member joined -> "Shawn was added by straycat" (actor != target)
-> "Vitor joined" (self-join, actor == target)
member removed -> "Bob was removed by Alice"
visibility changed -> "straycat made this channel open — anyone can find and join it"
-> "... made this channel private — invite only"
ttl changed -> "Alice set messages to disappear after 7 days" / turned off
topic/purpose -> the new value, or "cleared the topic" when blank
channel created/deleted/archived/restored, message deleted (+ public reason),
dm created -> named after their actor
The event is signed by the RELAY keypair, so the people come from the payload's
actor/target, never from note.author. Membership lines are about the member, so
that is whose avatar shows and whose profile the pill opens; everything else is
about the actor.
Also covers the neighbouring timeline narration, which had the same problem:
huddle joins/leaves now name the participant from the `p` tag instead of
"someone joined the huddle", job lines name the signer, and forum votes name
the voter. All of these move from hardcoded English into string resources.
Quartz's SystemMessagePayload was missing target_event_id, action_id,
reason_code, public_reason and participants — every field of the moderation
tombstone and the DM-created payload. The KDoc now carries the complete
vocabulary read off the relay's emit_system_message callers, and the type
strings are constants so the UI cannot typo a branch into dead code.
An unknown type still renders as "alice: some_new_type" rather than vanishing,
so a relay that grows new vocabulary stays legible.
Verified on emulator-5554 against nosfabrica.communities.buzz.xyz: the four
"was added by" lines, "straycat created this channel", "Matthias Debernardini
joined" and "straycat made this channel open" all render with the right subject
avatar, in the chat and in the Messages-list preview.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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
Link previews left " / " literal because replaceCharRefs only
whitelisted named entities. Parse terminated numeric refs as code points.
Fixes#3723
kind:39005 is claimed by two unrelated relay-signed addressable events, and
EventFactory mapped it unconditionally to NIP-29's GroupPinnedEvent, leaving
Buzz's ThreadSummaryEvent unreachable:
GroupPinnedEvent d = group id, e = pinned message ids, no h, empty content
ThreadSummaryEvent d = thread root id, e = that root, h = channel, JSON content
Nothing throws on the mismatch, so a summary would have parsed as a pin list and
pinnedEventIds() would have reported the thread root as a pinned message.
Discriminate inside the kind's block on the `h` tag, following the kind-20001
BitChat/Buzz presence precedent already in this file. `h` is a safe signal in
both directions: the whole NIP-29 relay-generated 39xxx family (metadata,
admins, members, participants, supported-roles, pinned) is addressed by `d`
alone and never emits `h`, and neither builder does either, so both classes
round-trip through the factory on outbound signing as well.
Buzz publishes summaries live to channel subscribers (not only over its HTTP
bridge), so this is what makes it safe for a channel-scoped filter to ask for
39005 at all.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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
Tor wedged across app restarts with ~87% of relay connections failing, and
neither existing recovery fired. Captured from the device:
guards.json default: 60 guards, 59 disabled, 1 unlisted -> 1 usable
Arti log AllGuardsDown { n_accepted: 0, n_rejected: 60 } x21,490
sockets 36,007 failures vs 5,276 successful opens
Everything above Tor degraded with it. Concord was the visible casualty: its
control-plane sync drained exactly 12 wraps on every launch and never folded, so
the Messages tab showed zero Concord rows for two restarts running.
Two recoveries exist and this state slipped between both:
- `ArtiGuardState.hasNoUsableGuards` required `usable == 0`. Its own KDoc claimed
"a single usable guard is enough to recover" — the device disproved it. One
survivor that is merely *unreachable* is useless for recovery but sufficient to
veto it, so the wipe never ran.
- `TorManager`'s watchdog only arms while status sits at Connecting. Tor had
bootstrapped: the SOCKS proxy was bound, `hasEverBootstrapped` was true, ~13% of
connections still worked, so status reached Active and the watchdog never fired.
It is built for "Tor never came up"; this is "Tor came up and its guards rotted".
Nothing observed the steady-state failure rate, so all three gates evaluated the
same way on every launch and `guards.json` carried the wedge forward forever.
1. Proportional disk rule. A sample of at least MIN_SAMPLE_TO_JUDGE_RATIO whose
usable guards fall under 1/USABLE_RATIO_DIVISOR of the total is wedged. Below
that size only the strict `usable == 0` rule applies, so a young sample Arti is
still filling is never wiped out from under a legitimate first bootstrap.
2. Runtime detector. `TorService` counts Arti's own AllGuardsDown log lines
(GUARDS_DOWN_THRESHOLD within GUARDS_DOWN_WINDOW_MS) and exposes
`TorBackend.guardsDownSignal`; `TorManager` routes it through the same
rate-limited self-heal to `resetWithCleanState()`. This is the general fix: it
believes Arti when it says every guard was rejected, so it fires even while
`guards.json` still looks healthy and status is Active — precisely the blind
spot between the two older heuristics. The count lives in the log callback
because that is the only place Arti surfaces it; the reset stays in TorManager,
which owns the cadence.
Verified on the wedged device. Next launch, unprompted:
W TorService: No usable Arti guards left on disk — wiping state to rebuild the guard sample
guard sample 60 total / 1 usable -> 20 total / 20 usable / 0 disabled
AllGuardsDown 21,569 -> 0
sockets 36,007 fail / 5,276 -> 444 fail / 232 open
Concord wraps 12, 12, 12 (pinned) -> 12 -> 37 -> 258
Concord rows 0 -> 4 and climbing
Tests cover the 59-of-60 field case, a small-sample false-positive guard, and a
healthy-majority sample. The pre-existing 22-guard "poisoned but not wedged"
fixture still passes, so the ratio does not regress the earlier variant.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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
Two halves of the same gap: a row could show its blue dot while nothing above it
agreed.
1. Bottom-bar envelope counted only DMs
------------------------------------------------------------------------------
`messagesHasNewItems` mapped each Messages row through `unreadPrivateChatRoute`,
which opens with `if (newestMessage !is ChatroomKeyable) return null`. Only
NIP-17/NIP-04 DM events implement that interface, so eight of the nine row types
were silently skipped — public chats, ephemeral rooms, geohash cells, Marmot
groups, NIP-29/Buzz channels, Concord channels, and both collapsed "grouped"
rows. Each of those rows already computed its own dot from its own last-read
route; the badge just never asked.
`rowHasUnreadFlow` now answers, per row, the same question the row composable
answers for itself, keyed off the note's gatherer (a Buzz channel and a Concord
channel can both carry a kind-9, so event kind alone can't tell them apart). It
returns a Flow rather than a (route, createdAt) pair because the two collapsed
rows fan in over every child channel — approximating those by their newest child
would miss an older channel that is still unread.
2. Buzz thread replies notified nothing
------------------------------------------------------------------------------
Buzz's clients thread with `["e", <id>, "", "reply"]` and only ever `p`-tag
@mentions, so a reply to my message names me nowhere. `isNotifiablePublicChatRep
ly` — the rule that lets a reply notify without a `p` tag — bails unless the
event is a ChannelMessageEvent (kind 42), so a kind-9 thread reply qualified
under nothing. Combined with thread replies now being kept out of the channel
timeline and its unread dot, a reply to my message in a Buzz channel had become
invisible on every surface.
`isBuzzThreadReplyToMyEvent` mirrors the fix already used for Buzz reactions
(`isReactionToMyEvent`): when a chat event carries no `p` tag, resolve the author
of its `root`/`reply` marked `e` targets instead of trusting a tag. Deliberately
only the MARKED targets — a bare `e` is WhiteNoise/Marmot's in-chat reply, not a
thread. It is OR'd into the same three gates the reaction case uses, so a reply
from a channel member I don't follow still notifies.
Also admits kind-40002 into NOTIFICATION_KINDS: nothing writes it any more, but
legacy Buzz thread replies exist and were being dropped at the kind gate before
any relevance check ran.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fills the on-disk translation gap for the recently merged Buzz workspaces,
Blossom file import and BOLT12 offers features, plus the channel-invite and
payment-notification strings.
Inserted per-locale (128 cs, 124 de-rDE, 126 sv-rSE, 128 pt-rBR) driven off
each file's own diff rather than a shared union block, since Crowdin strips
source-identical keys asymmetrically across locales.
All 7 new <plurals> are covered in every locale; Czech gets the full CLDR
one/few/many/other set. Source-identical entries (Canvas, Workspace, OK,
LIVE, Media, Social, Reposts, and the bare %1$d+ count formats) are
deliberately left out — Crowdin strips those on export and Android falls
back to values/strings.xml at runtime.
Verified: no duplicate keys, well-formed XML, and format-placeholder parity
with the English source across all four files.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T45dBdJ8GRBy8zw9yDQRPW
A minichat thread reply was showing as a group's "last message" on Messages, and
lighting its unread dot, even though the channel timeline correctly hides it.
Three surfaces disagreed about what counts as a message:
channel timeline ChannelFeedFilter !isMinichatReply(..) && isAcceptable
Messages preview newestChatNote isGroupChatContent() && isAcceptable
unread dot hasChatNewerThan isGroupChatContent() && isAcceptable
So a reply that the channel deliberately routes to its thread still became the
row summary, and still lit the dot — you would open the group, see nothing new,
and watch the dot clear. The Concord side already solved exactly this by sharing
one predicate (`isConcordTimelineMessage`) across feed, preview and badge; this
gives NIP-29 the same treatment:
isRelayGroupTimelineMessage = isGroupChatContent && !isMinichatReply && isAcceptable
Now used by:
- `newestTimelineNote` (replaces the local `newestChatNote`) for the row preview,
in both INLINE and GROUPED view modes
- both additive paths in ChatroomListKnownFeedFilter, so an arriving reply cannot
bump a row either
- `hasChatNewerThan`, which backs the per-channel dot (`relayGroupChannelHasUnread
Flow`, also used by the workspace channel list), the collapsed per-relay dot
(`relayGroupServerHasUnreadFlow` composes it), and transitively the Messages row
dot, which reads the createdAt of the note the preview picked
The bottom-bar Messages badge follows automatically: it derives from the feed rows
themselves. (It only counts private DMs today — `unreadPrivateChatRoute` returns
null for anything that isn't ChatroomKeyable — which is a separate, pre-existing
scope decision, untouched here.)
Verified on device by creating the case rather than waiting for it: posted a reply
into a thread so it became the newest event in the channel. The thread shows it
(chip 4 -> 5 replies), the channel timeline does not, and the Messages row still
previews the newest timeline event. Before this change that reply would have been
the row summary. The earlier screenshot only looked right because a system message
happened to be newer.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A reply written by any current Buzz client is a kind-9 carrying a NIP-10
`reply`-marked `e` tag. Amethyst rendered it as a flat row in the main channel
with the parent quoted above it, and it never appeared in the thread on its
parent — the exact inverse of where Buzz puts it. Observed on the wire:
parent kind 9 tags: [h, <channel>]
reply kind 9 tags: [h, <channel>], [e, <parent>, "", "reply"]
`isMinichatReply` is the single definition three consumers share — the channel
timeline filter drops these, the reply-count chip counts them, and the minichat
feed shows them — but it was type-gated to CommentEvent (1111) and
StreamMessageV2Event (40002), so a kind-9 ChatEvent fell through to `false`.
Every downstream behaviour followed from that one gap: the reply stayed in the
timeline, the parent showed no "N replies" chip, and the thread was empty.
Accepting a marked `e` on kind 9 is NIP-C7 compliant. C7 defines exactly one
reply mechanism for kind 9 — `["q", <id>, <relay>, <pubkey>]` — and never
mentions `e` at all, so a marked `e` carries no C7 meaning and is free to denote
a thread reply. Matching on the MARKER (never the bare tag) is what keeps
WhiteNoise/Marmot working: they thread kind-9 chat with a plain, unmarked `e`,
which is an in-chat reply and must keep rendering as a quote bubble. Tests pin
all four cases: marked direct, marked nested (root+reply), unmarked, and `q`.
Also stop writing kind-40002 for Buzz minichat replies. Nothing in Buzz writes
40002 any more: every send path in their mobile, desktop and CLI clients emits
kind 9, the ~50 remaining references are all reads (filter kind lists, feed
query sets, archive constants), and their NOSTR.md grades kind:9 as supported
against 40002's "Buzz-only — no standard NIP-29 client renders these". It is a
read-compat tail from the 10002 -> 40001 -> 40002 migration, and Amethyst was
the last active writer — so our replies threaded nowhere but our own client.
We now emit kind 9 with tags byte-identical to Buzz's `_buildReplyTags`
(direct -> one `reply` marker; nested -> `root` + `reply`), which is what
`buzzThread` already produced. Reading 40002 stays supported for events already
in the wild, including the ones we wrote.
Deliberately NOT changed: `computeReplyTo`'s ChatEvent branch still links every
`e` tag to the parent. That link is what populates `note.replies`, which the
thread and the chip read — discriminating there would unlink Buzz replies from
their parents. The marker distinction belongs in rendering, not in linkage.
Verified on device against a real thread: the reply now sits in the thread on
"howd you get bumble working?" with a "3 replies" chip on the parent, and is
gone from the channel timeline.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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
On a Buzz relay, channel membership is server-side: another member can add you,
the relay writes you into the kind-39002 roster, and you can read and post
immediately. The relay then addresses you a kind-44100 naming who did it.
Amethyst funnelled every 44100 into BuzzDmChannels — treating it as a DM — which
silently subscribed you to that channel's messages, while the Messages list
(which reads the self-published kind-10009) showed no row for it. A channel could
therefore be joined, streaming, and invisible at the same time: the channel
screen offered no Join button and accepted posts, the RelayGroups screen listed
it from the relay's 39000 directory, messages arrived — and Messages had nothing.
Nothing here is auto-accepted any more. 44100 carries `{"type","channel_id",
"actor"}`, and the relay emits the SAME kind for a self-join with `actor == you`,
so the actor is the only thing separating "I joined this" from "somebody put me
here". Channels are classified by the `t` tag on their 39000 (stream/forum/dm/
workflow — read through a dedicated accessor because on buzz the type shares the
tag name with real hashtags): only `t = dm` belongs in the DM list, everything
else becomes a pending invite that subscribes to nothing.
The prompt appears on both surfaces, driven by one state holder so they cannot
disagree — Notifications, in the same header slot as the missing-inbox-relay
prompt, and Messages > New Requests, beside the pending DMs it is the exact
analogue of. Rendered as a list row rather than a modal: these arrive in bursts
when somebody sets up a workspace, and a blocking dialog on cold start would be
miserable. It is also the spam surface, so Ignore stays cheap.
Three actions, and Ignore is deliberately not Leave:
- Show -> writes the group into kind-10009 (Account.follow), after which the
ordinary joined-group path owns it and it syncs to other devices.
No kind-9021: the relay already has you in the roster, so this
records only your decision to surface it.
- Ignore -> local, reversible display choice. You stay in the roster and can
still open and post.
- Leave -> kind-9022 LeaveRequestEvent, the one that actually removes you.
A kind-44101 removal now withdraws any pending prompt, so the relay taking the
membership away cannot leave a card offering an action that would fail.
The invites section is passed as the chatroom feed's header rather than stacked
beside it: the collapsing top bar draws over that area, so a header outside the
list renders underneath it. It shows in the empty state too, otherwise an account
with no pending DMs would have no way to reach the prompt.
Verified end to end on device: "straycat added you to personalized-knowledge-
graphs" rendered on both surfaces, and Show republished kind-10009 with the
channel appended.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Messages list showed a stale last message for every NIP-29 group while the
open chat screen stayed live. The joined-group tail batched every group on a
host relay into ONE filter carrying all their ids in `#h`. That is valid NIP-01
and relays answer it correctly for stored queries — which is what made it so
confusing: the boot backfill populated every group, so the list looked right
until it needed to change.
`block/buzz` indexes each live subscription under a single channel uuid resolved
from `#h`. When two or more distinct ids appear anywhere across a subscription's
filters, `extract_channel_id_from_filters` returns None and the subscription is
registered as *global* — and global subscriptions deliberately never receive
channel-scoped events, guarding against leaking private channel content to a
subscriber whose membership was not checked per channel. So the batched tail
backfilled at EOSE and then went permanently deaf.
Measured on device: same relay, same connection, same subscription id, only the
`#h` count changed — one value delivered live, two delivered nothing.
The resolver scans every filter of a subscription, so splitting into one filter
per group is not enough; each channel needs its own subscription. The EOSE
managers are therefore keyed on GroupId, and the preloads mount one subscription
per joined channel. Relays leave room for this: buzz allows max_subscriptions
1024 against max_filters 10, so this also sidesteps the filter cap for anyone in
more than ten groups.
Also folded into the same per-channel subscriptions:
- Group activity addressed to me (reactions/zaps/replies) moved off the
account-wide notifications subscription. That one also carries inbox filters
with no `#h` at all, and buzz forces a subscription global on the first
channel-less filter, so no reshaping there could ever have worked.
- Reactions and deletions (kinds 5/7/9005) now ride the channel's own `#h`
subscription, the shape Buzz's own client uses (`channelEventKinds`). Amethyst
otherwise learned about them only through the shared `#e` EventFinder query,
which carries no `#h` and is therefore global — so reaction chips appeared
only on a re-query, never as they happened. Kept out of the timeline kind set
so reactions cannot consume a history page's limit and walk the `until` cursor
past undelivered messages.
- A `limit = 1` preview filter with no time floor. The tail floors at now-7d to
keep recent chat warm; on its own that stranded any channel quiet for longer
on a "No messages yet" placeholder sorted to the bottom by createdAt 0. Every
other roster-driven protocol on that screen already bounds by count for this
reason (NIP-28 `limit = 1`, Concord `limit = 10`) — their row set comes from a
list event, unlike NIP-17/NIP-04 whose rooms are discovered from the messages
and so need the backward pagers.
The Messages row now says "Loading" instead of "No messages yet" until the fleet
settles, so an in-flight channel is not mistaken for an empty one. That required
the shared WindowLoadTracker to describe the whole joined fleet rather than
whichever channel rebuilt last.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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
The `if (canEditBuzz || canEditConcord)` guard already proves
`onWantsToEditChatMessage` non-null — both booleans are local vals whose
definitions begin with a null check, and K2 propagates that through them.
The `!!` compiled to an assertion that could never fire, and produced an
"Unnecessary non-null assertion" compiler warning.
No behaviour change: the smart-cast invoke is what was already happening.
If either guard is later loosened, this now fails at compile time instead
of becoming a runtime NPE.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WopdqNoZ9tYoMNppJG17BL
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