Add a PreToolUse hook (.claude/hooks/pre-push-spotless.sh, wired in
.claude/settings.json) that runs spotlessApply before a git-push subcommand
or the create_pull_request MCP tool. If spotless reformats tracked Kotlin,
or reports a non-autofixable lint, the call is blocked so the fix is
committed first -- turning CI's spotlessCheck failure into an in-session
block. Gradle infra/network failures warn and allow, leaving CI as the
backstop. Boundary detection tokenizes the command so the words appearing
in a commit message do not trip the gate.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nWg1Wwq5xCE7kPFzWY95o
New KDoc blocks on isHex/isHex64 already state the "~47ns" and
"~30% faster" perf notes, so the trailing EOL comments between the
KDoc and the function now trip ktlint's standard:no-consecutive-comments
rule ("an EOL comment may not be preceded by a KDoc"). Remove them.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BawgXifvcPidMMqJ719Ka2
The download/save-to-local button handed the raw content URL straight to
OkHttp. For BUD-10 `blossom:` URIs this failed with "expected scheme http
or https but was blossom" because OkHttp only speaks http/https.
Resolve `blossom:` URIs to a concrete server URL via BlossomServerResolver
(the same resolver the Coil/ExoPlayer pipeline uses) before downloading, in
both save entry points (AccountViewModel.saveMediaToGallery and the zoomable
dialog's save action). When no hosting server can be found, the save reports
an error instead of crashing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BawgXifvcPidMMqJ719Ka2
Same discoverability treatment as the Hex helpers: KDoc on the reusable,
heavily-used primitives external integrators and AIs kept missing, plus
skill coverage.
- TimeUtils: object + key-fn KDoc emphasizing Unix *seconds* (created_at)
vs the millisecond nowMillis() exception.
- RandomInstance: object + per-fn KDoc (secure random; use over kotlin.random).
- sha256(): KDoc pointing event-id work to EventHasher.
- EventHasher: object + fn KDoc on canonical id serialization / verification.
- StringUtils: KDoc on the allocation-free case-insensitive matchers + DualCase.
- Bech32: "use NIP-19 helpers unless you need a custom prefix" pointer; KDoc
on bechToBytes.
- quartz-integration skill: new "Everyday utilities" section (time/random/
hashing/bech32/base64) + quick-reference rows.
- nostr-expert skill: "Core Utilities" section.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011HM5uueF4a17umGpjC8wcz
AI agents integrating Quartz were re-implementing hex encoding or pulling
in third-party codecs because the built-in helpers weren't discoverable.
- Add KDoc to the `HexKey` typealias, its extension functions
(`toHexKey`/`hexToByteArray`/`hexToByteArrayOrNull`/`isValid`) and the
`PUBKEY_LENGTH`/`EVENT_ID_LENGTH` constants.
- Add KDoc to the `Hex` object and its public API
(`isHex`/`isHex64`/`decode`/`encode`/`isEqual`).
- Add a dedicated "Hex utilities" section + quick-reference rows to the
quartz-integration skill, and fix the wrong `HexKey.decodeHex(hex)`
snippet (no such API) to the real `hex.hexToByteArray()`.
- Add a "Hex Encoding" section to the nostr-expert skill.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011HM5uueF4a17umGpjC8wcz
IdleClock.bump() is called for every message the relay sends — the connection
listener bumps it per event, so a multi-million-event download bumped it millions
of times. It stored a ValueTimeMark into an AtomicReference, and since the value
class boxes when used as a generic type argument, every bump allocated a heap
object. That is needless GC pressure on the hottest path (and battery/jank on
Android).
Replace it with a single monotonic base mark taken once (stored unboxed) plus a
@Volatile Long of nanos-since-start updated on each bump — zero allocation per
bump, and only visibility (not atomicity) is needed since each relay's bumps come
from its single reader thread and the driver only reads. Behavior is unchanged;
negentropy + concurrency suites pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JmSyzdmKyiz3pPxUZ8Mg8Z
The fixed `timeoutMs` (default 30s) applied to every reconcile round, but the
FIRST round on a large relay is a legitimate long silence while the relay builds
its whole negentropy fingerprint — observed at ~63-68s for a 3.5M-event kind:0
set on a real relay. So the old default spuriously failed big syncs with
UNAVAILABLE ("reconcile round timed out"), even though the relay was working fine
and would have answered seconds later.
Replace it with `idleTimeoutMs` (default 120s): the maximum time the relay may go
COMPLETELY SILENT before giving up. It resets on every message the relay sends —
each NIP-77 round and every download EOSE/event — and on connect, so a genuinely
slow but progressing sync runs for as long as it needs; only true silence trips
it. Because the watchdog is fed by a connection-level listener that sees all of
the relay's traffic, download activity extends the reconcile deadline and vice
versa. `idleTimeoutMs = 0` disables it entirely (run until the socket drops); the
initial connect and each download batch keep their own finite bounds so an
unreachable relay or a single stuck batch still can't hang the pipeline.
Liveness of a dead/half-open socket does not depend on this: the WebSocket
keep-alive (ping/pong) detects it and the disconnect is already turned into a
clean NEG-ERR abort.
Verified against wss://wot.grapevine.network: the first round took 68.4s (the old
30s default would have thrown UNAVAILABLE) and the sync sailed through it under
the 120s idle window. Negentropy unit suite passes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JmSyzdmKyiz3pPxUZ8Mg8Z
Revert the fresh-subId-per-page workaround (ed5c25e2) now that the underlying
double-REQ race is fixed at the root in PoolRequests. Relays cap the number of
concurrent subscriptions per connection, so a single reused id — opened per page
with the page's `until`, closed before the next page — keeps the whole download
to one subscription slot instead of churning through a distinct id each page.
Safe because the pool now serializes the "send a REQ" decision: after a page's
EOSE, the auto-resend and the loop's unsubscribe+resubscribe can no longer both
fire a REQ for the same id (guarded by PoolRequestsConcurrencyTest). Unit suites
(negentropy paging scenarios, subscriptions) pass; full-scale real-relay
verification to follow before push.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JmSyzdmKyiz3pPxUZ8Mg8Z
Damus-inspired content filter that collapses notes abusing `t` hashtag
tags into a compact reveal-on-click placeholder. Ships default ON with a
threshold of 5 (adjustable 1–20 in Settings → Content Filters, or off).
Scope
- Pure check (`HashtagSpamCheck`) + settings interface
(`HashtagSpamSettings`) live in `commons/moderation/`, callable by
Desktop, `amy` CLI, and (future) Android.
- JVM-backed `PreferencesHashtagSpamSettings` writes to the shared
`java.util.prefs` node `com/vitorpamplona/amethyst/filters`, so `amy`
and Desktop observe the same value automatically.
- `CollapsedSpamNote` placeholder in `commons/ui/note/` takes only
primitive scalars so Android can adopt it without touching commons.
- Desktop wraps every `NoteCard` call site (FeedNoteCard, QuotedNoteEmbed,
BookmarksScreen, 5 SearchResultsList sites) with a shared
`SpamCheckedNoteRender` helper. Thread root notes auto-expand via
`forceReveal=true`; replies still respect the filter.
Exemptions
- Long-form articles (kind 30023)
- Authors in the follow list plus self
- Repost wrappers check the inner event's tags via precomputed
`note.replyTo`, falling back to `containedPost()`
Search UX fixes bundled in
- Removed the `#hashtag` → "Direct lookup" card. `QueryParser` already
extracts `#xxx` into the query's hashtag filter, so typing `#bitcoin`
now goes straight to filtered results.
- Search-result rows now trigger metadata loading via
`subscriptionsCoordinator.loadMetadataBatched(authors)` and observe
each user's metadata flow via a new `rememberDisplayData` helper, so
display names + avatars refresh when kind-0 arrives from index
relays. Same helper reused in Bookmarks.
Tests + docs
- 19 unit tests (check × 10, displayed-event unwrap × 4, prefs × 5),
all green.
- Manual testing sheet with 16 scenarios at
`desktopApp/plans/2026-06-29-hashtag-spam-filter-manual-testing-sheet.md`.
- Plan at `docs/plans/2026-06-29-feat-desktop-hashtag-spam-filter-plan.md`.
- Cross-client desktop feature backlog reference at
`desktopApp/plans/_desktop-feature-backlog.md`.
A subscription id is driven from two threads at once: the app thread (the
subscribe/unsubscribe path) and every relay's socket-reader thread (an EOSE
that triggers an auto-resend). Both read the subscription state and both can
decide "the filters changed, send a REQ", but the decision (read state) and the
send (mark state SENT in onSent) were not atomic. So the reader could observe
the pre-send state — filters still on the previous value — while the app had
already moved the desired filters forward, and both would send a REQ for the
same sub id.
Two REQs on one id race on the wire: the relay answers with duplicate EOSEs and
events, or — if a CLOSE interleaves — an empty result that silently truncates a
paged download. This is what intermittently broke fetchAllPages on large sets
(fixed at the call site in ed5c25e2 by using a fresh sub id per page); this
commit fixes the underlying race in the relay-client layer, which could equally
corrupt any subscription that spans multiple relays (several reader threads
mutate the same RequestSubscriptionState maps concurrently).
The fix:
- Add a tiny non-reentrant spin lock (withStateLock, same AtomicBoolean
primitive BasicRelayClient uses) guarding every access to the subscription
state machine. Listener callbacks and socket sends stay OUTSIDE the lock —
they re-enter this class via onSent, so holding it across them would deadlock.
- Fold the send decision into decideCommandLocked, which runs under the lock and
pre-marks the state SENT (+ filters) the moment it decides to send a REQ. A
concurrent decider then sees SENT/updated filters and declines, so exactly one
REQ is ever produced.
Verified with a deterministic A/B repro that pins the exact interleaving open:
pre-fix 300/300 episodes produced a duplicate REQ; post-fix 0/300 (max one REQ
per episode). Kept as PoolRequestsConcurrencyTest. Full relay test suite passes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JmSyzdmKyiz3pPxUZ8Mg8Z
fetchAllPages reused a single subscription id across all pages
(unsubscribe + immediately re-subscribe the same id). On a real relay that
caps REQ results, the rapid same-id CLOSE→REQ races on the wire: in-flight
events from the previous page's REQ bleed into the next page's listener.
Those stale events carry a created_at above the freshly-lowered `until`, so
`match()` rejects them, the page ends with pageCount == 0, and the whole
loop breaks — silently truncating the download.
Observed against wss://wot.grapevine.network: a full kind:0 download (~3.55M
events, per a concurrent negentropy sync) stopped at 89,500. A controlled
diagnosis paging the same data with a fresh subId per page vs a shared subId
reproduced it exactly: shared stalled at ~95k with in-page duplicates and
events above `until`; fresh advanced cleanly with no duplicates. After the
fix, the real-relay fetchAllPages sails past the old stall (100k+ and
counting).
Fix: allocate the subId inside the paging loop so each page is an
independent subscription.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JmSyzdmKyiz3pPxUZ8Mg8Z
Rework the negentropy download path from "reconcile fully → then download"
into a single back-pressured streaming pipeline so peak memory is independent
of the window size — built for multi-million-event syncs.
- reconcileStreaming drives the NIP-77 rounds directly (instead of via
NegentropyManager) and hands each round's ids to a bounded id-queue *before*
acking the next round, so the relay's id stream is paced to the downloader.
- Ids flow id-queue → bounded download worker pool → bounded delivery channel;
a slow consumer back-pressures the whole chain. The full id list is never
materialised.
- Drop the global event-dedup set (was O(set) ~ hundreds of MB at 4M): NIP-77
yields a distinct id set, so each event is requested once. Keep only a tiny
per-batch dedup (bounded by fetchBatch) to absorb a relay replaying a REQ.
- Pin the relay with a never-matching keep-alive subscription for the sync's
duration: a NEG-OPEN isn't a REQ, so during a reconcile round the pool would
otherwise see the relay as unwanted and disconnect it mid-sync.
- Document that timeoutMs must accommodate the relay's first-frame snapshot
latency on huge sets (a real strfry took ~73s for an unbounded kind:0 set).
Validated against wss://wot.grapevine.network: streamed 30k kind:0 events with
heap bounded at ~30-90 MB (not growing with the set) and zero duplicates. New
unit test forces many small reconcile frames to exercise the multi-round /
back-pressure path; existing windowing/cap/fallback tests still green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JmSyzdmKyiz3pPxUZ8Mg8Z
Per review: negentropySync should not silently switch transports. Plain
created_at paging is heavier and non-delta, and a caller who reached for
negentropy may prefer to know it failed (try another relay, narrow the
filter, abort) rather than get a surprise paged download.
So:
- negentropySync is now negentropy-only. created_at windowing on the
relay's max_sync_events cap stays automatic (it's still negentropy), but
a window that genuinely can't be reconciled — minimal window still over
the cap, or a relay with no NIP-77 support / disconnect / timeout — now
throws the typed NegentropySyncException (reason OVER_MAX_SYNC_EVENTS or
UNAVAILABLE, carrying the failing window) instead of paging. Dropped
NegentropySyncResult.fellBackToPaging.
- Added negentropySyncOrFetch (+ negentropySyncOrFetchEvents Flow form) as
the ergonomic "try negentropy, else page" combinator: runs negentropySync
and, on NegentropySyncException, falls back to fetchAllPages over the same
filter, deduping by id across both phases and honoring maxEvents. Returns
NegentropyOrFetchResult so callers can see which path ran and why.
Callers now choose explicitly: negentropySync to handle failure themselves,
negentropySyncOrFetch for automatic paging fallback.
Tests: over-cap relay with spread timestamps succeeds via windowing alone;
over-cap minimal window throws (and the caller can page); orFetch pages on
failure and uses negentropy when it works.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JmSyzdmKyiz3pPxUZ8Mg8Z
Replace the cumulative `negentropySyncAsFlow(): Flow<List<Event>>` with
`negentropySyncEvents(): Flow<Event>`, which emits each event individually
as it arrives. Rebuilding an ever-growing list per event was O(events²) in
both CPU and memory and pointless for a bulk download; the stream stays
O(1) in memory and hands the caller raw events to collect however they
like.
Events are buffered with Channel.UNLIMITED because negentropySync delivers
through a non-suspending callback — a bounded buffer would drop events when
the collector lags. Callers can apply their own buffer/conflate/
collectLatest downstream.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JmSyzdmKyiz3pPxUZ8Mg8Z
Add `INostrClient.negentropySync` / `negentropySyncAsFlow`, a high-level
NIP-77 accessory that downloads every event a relay holds matching a
`Filter` and delivers each (deduped) through `onEvent` — mirroring the
existing `fetchAllPages` accessory so downstream apps stop hand-rolling
the `NegentropyManager` dance.
It encapsulates the parts that make raw negentropy painful:
- reconciles the relay's matched set (empty local set) via NegentropyManager
- downloads the resulting ids through a bounded pool of concurrent REQs
(`maxConcurrentReqs` subs of `fetchBatch` ids each, refilled on EOSE)
- handles the relay-side cap (strfry `max_sync_events`,
`NEG-ERR "blocked: too many query results"`) by splitting the filter
into adaptive created_at windows; a minimal window that still can't
reconcile (or a relay that doesn't speak NIP-77) falls back to
`fetchAllPages` and reports it via `NegentropySyncResult.fellBackToPaging`
- caps delivery at `maxEvents`, dedupes through a single consumer, and
tears down all subscriptions + the neg session on completion/cancel
Scope is controlled entirely by the `Filter` (per maintainer guidance the
caller-supplied local-id delta interface is dropped in favour of a custom
Filter), so the common call is one line.
To drive NEG-OPEN on a single connection, add
`INostrClient.getOrCreateRelay(url)` (default throws; NostrClient delegates
to the pool). Because NEG-OPEN is a one-shot command that — unlike a REQ —
is never replayed on reconnect, the accessory connects and waits for the
relay to be ready before opening the session.
Tests (quartz jvmAndroidTest, in-process relay): full download, maxEvents
cap, clean teardown / no leaked subs, the Flow variant, and window-split +
paging fallback against a relay that rejects the full reconcile.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JmSyzdmKyiz3pPxUZ8Mg8Z
Mine should download the user's own events from their outbox (NIP-65 write),
private-storage, local and proxy relays. The previous filterXMine path used
account.outboxRelays, which also included broadcast relays — write-only blast
targets that don't serve reads — so it queried the wrong set.
Add AccountMineRelayState (a sibling of AccountOutboxRelayState with broadcast
swapped for proxy) and feed it into the shared TopFilter.Mine resolver.
MineFeedFlow now pins authors=[me] to that fixed relay union via
AuthorsByProxyTopNavFilter — no per-author outbox resolution needed, since the
only author is the user and their relays are known from account state. Both the
DAL (liveXFollowLists) and the relay sub-assemblers (liveXFollowListsPerRelay)
consume it, and the per-relay flow re-emits when the mine relay set changes.
Add MineFeedFlowTest covering author scoping, the empty-relay case, and
re-emission on relay-set change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YWUwciqZiXTkbYVSyiF2P4
The shared FeedTopNavFilterState.loadFlowsFor() mapped TopFilter.Mine to
AllFollowsFeedFlow, so "Mine" silently fell back to all-follows. Every screen
that offered the Mine chip (badges, communities, music tracks/playlists, git
repositories, nApplets, nSites, and the browser app rows) had to special-case
TopFilter.Mine on both the relay sub-assembler and the local DAL / display
filter to scope content to the user.
Introduce MineFeedFlow, which mirrors AllFollowsFeedFlow's outbox/proxy split
but pins the author set to the logged-in user's own pubkey. Because both
liveXFollowLists (DAL) and liveXFollowListsPerRelay (relay) derive from this
flow, the generic author paths now narrow to the user, so the per-screen Mine
branches are pure redundancy and are removed.
This also makes outbox-change invalidation automatic: the Mine relay set now
comes from liveXFollowListsPerRelay, an OutboxLoaderState over the user's own
outbox, so a NIP-65 update re-emits through the existing followsPerRelayFlow
collector without a screen-specific trigger.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YWUwciqZiXTkbYVSyiF2P4
Adds a repo-root cross-module roll-up that stitches the 10 per-folder
plans/ indexes into one view: totals, a per-module status table, and a
"live work" section listing every in-progress / queued / abandoned plan
with links. Shipped plans stay in each folder's archive/ and are linked
via the per-module README.
142 plans: 122 shipped, 9 in-progress, 8 queued, 3 abandoned.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016hpUivtmq4pgzqRbY6MYrA
Audited all 143 plan files across the 10 plans/ folders. Each plan now
carries a Status header (shipped | in-progress | queued | abandoned)
backed by codebase evidence, and every folder has a README.md index
grouping plans by status.
Shipped plans were moved into a per-folder plans/archive/ (via git mv,
history preserved) so each plans/ folder surfaces only live work:
shipped (archived): 122 in-progress: 8 queued: 7 abandoned: 4
docs/plans/ is the frozen legacy folder; its plans were stamped and
indexed in place (48 of 52 archived) but it remains closed to new plans.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016hpUivtmq4pgzqRbY6MYrA
Adds a Follow Packs experience to Amethyst Desktop:
- New "Discover" sidebar destination with featured-pack hero, hashtag chips
driven by NIP-12 `t` tags, a 3-up "From the pack" notes feed, and a
right-rail of mini pack thumbnails.
- New "Follow Packs" launchable column (App Drawer + Discover "Browse all")
with multi-field search across title, description, creator name/npub,
and `t` tags.
- Pack detail overlay (read-only) with per-member Follow/Unfollow buttons
that reflect the live kind-3 state, plus pack-level Follow all /
Unfollow all with a dedupe-aware confirm dialog ("Follow N new (M
already followed)").
- Bulk follow / unfollow batched into a single kind-3 publish via new
`FollowActions.buildUnfollowBatch` and `Kind3FollowListState.follow/
unfollow(users: List<User>)`. The mutating call sites are Mutex-
protected against concurrent races.
- naddr → 39089 references in notes render as a rich inline card with
avatar stack + Follow all CTA. Cache miss triggers a one-shot
subscription; empty / deleted packs render minimal states.
- Shuffle button rotates both the featured pack and the gallery,
excluding the last 5 shown.
- Pack image fields render via Coil `AsyncImage` with a deterministic
gradient fallback.
Protocol additions:
- Quartz: `FollowListEvent.hashtags()` convenience accessor.
Bug fixes wrapped into the feature:
- `DesktopLocalCache.consumeContactList` now also loads the event into
`addressableNotes` so `Kind3FollowListState.getFollowListEvent()`
returns the user's actual kind-3. Without this, every bulk follow
silently replaced (rather than appended to) the contact list.
- Added Material Symbols `Shuffle` codepoint and regenerated the
bundled subset font (still 432 KB).
Add a direct link to each running app's editable Connected Apps
permission-detail screen from its top pull-down sheet, so users can
change the trust level and per-capability grants as they navigate.
Covers every top pull-down rendering:
- Embedded napplet/nsite and web-app tabs (Compose TopControlSheet),
keyed by the napplet `pubkey:dtag` coordinate or the web client's
`browser:<origin>`.
- Full-screen sandbox host and direct-browser activities (native
NappletControlSheet), via a new MSG_OPEN_PERMISSIONS IPC: the host
(which can't state its own coordinate) sends its launch token or
visited origin, and the main-process broker resolves the trusted
coordinate and opens MainActivity through a `connectedapp?coordinate=`
deep link added to uriToRoute.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019dXhUL8To3qVXJVBZGCmhU
Refine the app-handler card rendering:
- NIP "Implements" bottom sheet now shows a wrapping row of clickable NIP
chips that open each spec in the browser, instead of a list of raw URLs.
- "Handles" gets the same "+N" overflow -> bottom sheet treatment as
"Implements", listing every handled kind.
- Related (`a`-tag) references now render as the referenced event's author
avatar + its real name with a short kind label, since these are vouched
for by the handler's author. Software Application shows the app name
(not the package-id d-tag) and is labelled "App"; Git repositories and
NIP-text events use their name/title, falling back to the d-tag until the
event loads.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FLfM7JUTr2vNiNUi4Ukmqn
Pressing Back on the "Connect to Nostr" first-connect dialog resolves to
AppConnectResult.Cancelled, which added the app's coordinate to an in-memory
`sessionCancelled` set. That set suppressed every future connect prompt for the
entire broker lifetime, so a later request — e.g. the user re-clicking "login"
in the in-app browser — was silently denied and the dialog never reappeared.
Because a Cancel persists nothing, the app also never showed up in Connected
Apps, leaving the user with nothing to clear to recover.
Replace the permanent suppression with a short, self-clearing cooldown
(`cancelledUntil` map): a Cancel suppresses re-prompts only briefly so the
burst of requests a page/napplet fires on load doesn't relaunch the dialog per
request, while a deliberate retry seconds later prompts again. The clock is
injectable for deterministic tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDcts4HzwSff4fy6oSVA6D