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
"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
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
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
fix(cache): keep LargeCacheAddressableFilterTest mocks strongly reachable
LargeSoftCache stores values as WeakReferences, so the cache alone does
not keep the mock AddressableNotes alive. Hold each note in a companion
strongRefs list for the lifetime of the test class, so a GC between
class-load and the read can no longer clear them.
Audit of the compressed-proof work found one real defect and one coverage gap.
Defect: a hostile BOLT12 proof/offer can carry a 9+ byte `invoice_amount`
(or any tu64 field) that parses as a valid TLV. `TlvStream.tu64` then called
the strict `Bolt12Values.tu64`, which throws `require(size <= 8)`. On the
`amy bolt12 verify` path (`Bolt12ZapActions.validate`, no surrounding catch)
that surfaced as an uncaught exception and abnormal exit instead of a clean
`Invalid`; the Android ingest path was already contained by LocalCache's broad
catch. Make the nullable stream accessor `TlvStream.tu64` return null for an
over-8-byte value so every amount read (invoice_amount, invreq_amount, offer
amount) degrades to a clean rejection. Regression-tested at the codec level.
Coverage: the writer's `proof_note` (1005) branch and the `with_note` vector's
note were never exercised. Add a `Bolt12PayerProof.proofNote()` reader and
thread the vector's note through the writer round-trip so 1005 is asserted.
The forged-proof, DoS, and reconstruction-accounting paths were reviewed and
found sound (the reconstructed root is only ever a BIP-340 message; the NIP
offer-binding gate still pins invoice_node_id to the offer's issuer).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3
The type/label chips that sit beside a room name on the Messages screen — the
NIP-28 "Public Chat" pill (HeaderPill), the NIP-29 relay-host chip
(RelayNameChip), and the Concord community chip (ConcordCommunityPill) — could
grow with a long relay URL or community name and crowd the room name out.
Cap each at ChatLabelMaxWidth (140.dp, ~half a phone row) via widthIn(max); the
room name stays weighted so it keeps whatever the capped chip doesn't take, and
each chip's label truncates with a middle ellipsis (TextOverflow.MiddleEllipsis)
so the informative head and tail both survive. RelayNameChip switches from a
plain end ellipsis; ConcordCommunityPill drops its char-count truncation
(maxChars) for width-based truncation.
Also give the Concord chip the NIP-29 chip's highlighted look — secondaryContainer
background / onSecondaryContainer content (a gray on the dark theme) — so both
"which server/community does this room belong to" chips read the same.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KQ9Cz2QjLMvemzVyJS1f5V
The File Sync / Import flow (and the mirror-on-upload fan-out) copy blobs
across the user's Blossom servers with BUD-04 `PUT /mirror`, but not every
server implements that endpoint. Blossom has no capability-discovery
mechanism, so a target without /mirror just answered 404/405/501 and the
whole copy was silently counted as failed.
Detect the "endpoint absent" statuses (404/405/501) as a typed
BlossomMirrorUnsupportedException — distinct from a mirror the server
understood but rejected (400/403/413/…) — and add BlossomClient.mirrorOrUpload,
which falls back to downloading the blob and re-uploading it (PUT /upload)
when mirror is unsupported. The downloaded bytes are verified against the
expected sha256 before re-upload, since a Blossom server is untrusted and
could substitute content, and the same t=upload auth is reused.
Wire every mirror path through mirrorOrUpload: the app-level
BlossomMirrorQueue (sync-all + import sweep), the blob manager's per-blob
mirror (including the paid-mirror retry), and UploadOrchestrator's
mirror-on-upload. Task now carries the descriptor content-type so the
fallback upload preserves the MIME.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168TWLTgrMxUR6yjjCCiBLS
multiRelayPoolReturnsContentFromEachRelay flaked with
"expected:<from-b> but was:<null>": the SubscriptionListener wrote the
per-relay results into a plain HashMap/HashSet, but each relay delivers
its EVENT/EOSE on its own InProcessWebSocket scope (Dispatchers.Default)
and PoolRequests dispatches the listener callbacks outside any lock. Two
relays therefore call `received[relay] = ...` concurrently, and a
HashMap.put racing a rehash can drop an entry, leaving a relay's value
null and failing the assertion.
Use ConcurrentHashMap and ConcurrentHashMap.newKeySet() for the shared
collections. Reproduced within 7 runs before the fix; 80 stress runs
clean after.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HeAjLDNBvGPjb5bfViU3ad
Real BOLT12 wallets emit selective-disclosure payer proofs: `invreq_metadata`
is always withheld and other invoice fields may be elided for privacy, with
`proof_omitted_tlvs` / `proof_missing_hashes` / `proof_leaf_hashes` carrying
enough to rebuild the invoice signature's merkle root. The verifier previously
reported these as unsupported (cryptoVerified = false), so a zap paid through a
real wallet never counted locally.
Implement the lightning/bolts#1346 reader:
- Bolt12Merkle.reconstructRoot rebuilds the invoice root from the disclosed
LnLeaf hashes + supplied nonce leaves (proof_leaf_hashes) + omitted-field
markers + missing subtree hashes (consumed post-order DFS, smallest-to-largest).
Add emitMissingHashes as the writer dual, unify both on one tree builder.
- Fix two latent interop bugs the vectors exposed: the nonce leaf hashes the
record's type bytes (not the full encoded TLV), and the payer proof signs
under fieldname `proof_signature` (not `signature`).
- Bolt12PayerProof gains marker/leaf/missing accessors and the invoice-field
range predicate; the verifier reconstructs on every proof (type 0 is always
the implied first omitted leaf) and drops the Unsupported result.
- Add Bolt12ProofBuilder to mint spec-compliant proofs (tests + future interop),
and rewire Bolt12ProofFixture onto it.
Validated byte-for-byte against the draft's own conformance suite
(bolt12/payer-proof-test.json): all 5 valid vectors verify, all 23 invalid are
rejected, and the writer reproduces every vector's compression fields exactly.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3
markAllChatNotesAsRead only enumerated public chats (IsInPublicChatChannel)
and DMs (ChatroomKeyable), so every newer room type fell through the when()
with no branch: NIP-29 relay groups, Concord communities, Marmot groups,
geohash chat, and ephemeral relay chat. Their unread dots on the Messages
screen could only be cleared by opening each room — "mark all as read" left
them lit. The collapsed per-server rows (RelayGroupServerRoomNote,
ConcordServerRoomNote) were skipped too.
Extract markRoomNoteAsRead(account, note), mirroring ChatroomEntry's type
dispatch so each row's last-read route is resolved the same way its unread
dot reads it: synthetic grouped rows first (fanning out to every joined
group on the relay / every channel in the community), then gatherer-attached
channels (Marmot, NIP-29, Concord, geohash), then the h-tag group fallback,
then the raw event type (public chat incl. ChannelCreateEvent, ephemeral,
DM, and drafts wrapping those). markAllChatNotesAsRead now just maps the
visible notes through it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KQ9Cz2QjLMvemzVyJS1f5V
The message-preview second line on every Messages-screen row (channels,
groups, and DMs) rendered at the ambient bodyLarge (16sp), matching the
bold title above it. Drop it to bodyMedium (14sp) so the title and the
muted preview read as two tiers instead of one block of same-size text.
Covers both renderers all rows funnel through: ChannelName (public
chats, ephemeral/geohash chats, Marmot/NIP-29 groups, Concord) and
LastMessagePreview (NIP-17/NIP-04 DMs), including the
"event not found" fallback line.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KQ9Cz2QjLMvemzVyJS1f5V
A Concord community pinned to the bottom bar folded only a fraction of its
channels — Soapbox showed 1 of 12. The live subscription collapsed a
community's Control + Guestbook + rekey + every channel plane into ONE
kind-1059 filter per relay, and the channel list is folded from the Control
Plane. On an AUTH-gated relay that caps a REQ per filter (measured ~100
events/filter on relay.dreamith.to), the chatty Guestbook plane crowded the
channel-defining control editions out of the cap, so only a fraction of the
channels folded. On the strict relay.ditto.pub the collapsed multi-author
filter is refused wholesale until every author is authenticated.
- Split the Control Plane into its OWN filter, apart from the Guestbook /
rekey / channel planes (ConcordSubscriptionPlanner.controlIsolatedFilters),
so it gets an isolated per-filter budget. Both filters still ride the same
per-relay REQ (no extra socket).
- Add a COMPLETE-mode Control-Plane sweep (Account.syncConcordControlPlanes):
re-fetch the whole plane with no `since`, paging past the per-filter cap via
fetchAllPagesFromPool, so a forward cursor can never hide an edition and the
cap can never truncate the fold. Mounted account-wide, fired on load +
membership/held-epoch change + relay reconnect (not a wall-clock poll — the
persistent live subscription keeps a connected relay complete).
Mirrors Armada's plane-sweep design (one filter per plane scope, COMPLETE-mode
control). Verified on-device: Soapbox now folds all 12 channels.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cross-backend defects in the kotlinx (native/iOS) NWC-321 parsers, found by the
audit and empirically reproduced. Jackson (JVM/Android) writes null-valued keys,
so a native peer parsing that output hit two bugs:
- parsePay/parseReceive crashed on `metadata: null` — `?.jsonObject` doesn't
short-circuit on JsonNull (a non-null element). Use `as? JsonObject`.
- parsePaySuccess/parseReceiveSuccess (and parsePay's string fields) read an
explicit JSON null as the literal string "null" via `?.jsonPrimitive?.content`.
Use `contentOrNull`.
Only affects the kotlinx path (Android/JVM use Jackson), but violates the KMP
mapper-interchangeability contract. Adds Nip47KotlinSerializationNullTest hitting
the kotlinx serializers directly so it's covered regardless of platform actual.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3