Add `amy nsite serve` and `amy napplet serve <author> [--d ID] [--port N]`:
fetch the manifest and serve its content over a local HTTP server, resolving
each request through quartz StaticSiteResolver (blob downloaded from Blossom
and sha256-verified per request, same as the device host) with SPA fallback to
index.html. Lets you open a published site/napplet in a browser to confirm it
loads and routes. (Static content only — a napplet's window.napplet.* runtime
still needs the Amethyst host; documented in the command + harness README.)
Implemented as thin cli glue (StaticSiteServe) over the resolver + commons
BlossomClient + the JDK HTTP server.
Also retire tools/napplet-test/publish.sh now that `amy napplet publish` is the
single source of truth; the harness README documents publish + serve via amy.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
Add `amy nsite publish <dir>` and `amy napplet publish <dir>` so a static-site
or napplet directory can be shipped to Nostr in one command, building on the
new CLI/Blossom infrastructure.
- commons (jvmMain) StaticSitePublisher: the reusable "upload a tree" half —
walks a directory (or single file), content-addresses each file, BUD-02
signed-uploads it via BlossomClient, and maps it to an absolute web path
(/index.html, /assets/app.js, …). Returns the NIP-5A path→sha256 tags.
- cli StaticSitePublish: thin shared flow — uploads via the commons publisher,
hands the path tags to a kind-specific builder, signs with the account key,
and broadcasts. nsite builds 15128/35128 (+ x aggregate); napplet builds
15129/35129 (aggregate + requires already added by the quartz builder).
- nsite/napplet `publish` verbs wired into their routers.
Test harness README now recommends `amy napplet publish tools/napplet-test`,
keeping publish.sh as a no-amy fallback. Unit test covers the path mapping;
cli + commons build green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
A self-contained napplet (tools/napplet-test/index.html) that calls every
window.napplet.* API and renders each result on screen, for verifying the
NIP-5D host end to end on a real device — including the new
identity.getList/getZaps/getBadges, identity.onChanged, keys.onAction, and
resource.bytes nostr: paths.
publish.sh uploads it to a Blossom server (BUD-02) and publishes the NIP-5D
named-napplet event (kind 35129) via nak; README documents the flow and a
per-feature verification checklist. Tooling only — no app code or deps.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
Upstream reworked kind:10019 to advertise the account's NIP-65 inbox (read)
relays as nutzap-receiving relays (was outbox). Realign amy:
`cashu wallet create` now defaults nutzapRelays to a new
Context.nip65ReadRelays() (kind:10002 read relays, falling back to outbox)
instead of outboxRelays(), so amy's kind:10019 matches the Android wallet's
again. The event's publish destination (anyRelays / sendLiterallyEverywhere)
is unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
Re-introspected the real nak binary: 34 functional commands. Fixes the
stale count — adds nsite to full (amy has NsiteCommands), corrects missing
to 7, and clarifies group/nip29 is an intentional MLS/Marmot divergence,
not a gap. Drops the stale "validate" cheap-win (key validate shipped).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
The advertised `relay` tags in our own kind:10019 now copy the NIP-65
inbox relay list only, rather than inbox + DM. Inbox relays are the
canonical "where others reach me" set, so they are the natural default
for "where to send me nutzaps". DM relays stay in the wallet's inbound
subscription as a safety net, but don't belong in the public kind:10019.
The publish destination of the kind:10019 event itself is unchanged — it
still broadcasts to our outbox via sendLiterallyEverywhere; only the relay
tags inside the event changed.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JHcZ2gv8ro9Q2bEiTSiHD8
Complete the NIP-65 outbox-model alignment for NIP-61: the `relay` tags in
our kind:10019 are, per spec, the relays where the recipient *reads*
incoming token events — i.e. inbox-side relays others publish to. We were
advertising our outbox (write) relays there.
Both publish paths (initial wallet creation in CashuWalletViewModel and
P2PK key rotation in CashuWalletState.recreateNutzapKey) now advertise the
union of our NIP-65 inbox + DM relays. This mirrors the relay set the
wallet subscribes to for inbound kind:9321, so senders following our
kind:10019 publish exactly where we listen.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JHcZ2gv8ro9Q2bEiTSiHD8
Inbound NIP-61 nutzaps (kind:9321) are messages other people send *to*
the user, so per the NIP-65 outbox model they must be read from the
user's inbox-side relays, not their outbox. The Cashu subscription used a
single relay set (outbox) for both the user's own NIP-60 events and
inbound nutzaps, so a sender following NIP-61 correctly (publishing to
the relays advertised in the recipient's kind:10019, or to the
recipient's NIP-65 inbox) could be missed.
Split the subscription relay sets per filter:
- own NIP-60 wallet/token/history events keep reading from outbox,
where the user published them (needed to restore on a fresh device);
- inbound kind:9321 nutzaps now read from the union of the user's
NIP-65 inbox + DM relays + the `relay` tags in the user's own
kind:10019. The last one is NIP-61's source of truth for "where to
send me nutzaps" and may be written by another client to a relay set
unrelated to our NIP-65 lists, so we listen there too.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JHcZ2gv8ro9Q2bEiTSiHD8
Update the amy docs to reflect the new command surface:
- cli/README.md — add the Cashu (NIP-60/61), Relay management (NIP-86
admin), and Run-a-relay (serve) sections; document fetch's nip19/nip05
code mode and key validate.
- cli/DEVELOPMENT.md — add cashu.json to the on-disk layout and pin the
command-family --json contracts (cashu keys/error codes + pointer to the
cashu plan, admin {relay,method,result}, serve startup object).
- .claude/skills/amy-expert/SKILL.md — extend the "where things live" tree
with AdminCommand/ServeCommand/cashu/, the commons/cashu + relayManagement
shared modules, and the allowed :geode dependency.
- .claude/CLAUDE.md — note cli may depend on :geode (for serve), never on
:amethyst/:desktopApp.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
Bind a napplet's registered keyboard/command actions to real hardware-key
combos and fire them back as keys.action pushes, so napplet.keys.onAction
actually triggers (previously registration was acked but never fired):
- protocol: RegisterAction / ActionRegistered carry the key combo (binding,
from the SDK's action.defaultKey); the codec decodes defaultKey and echoes
binding in the result; encodeKeysAction push envelope added.
- broker: registerAction returns the honored binding (still no key access for
the applet; KEYS stays a declared-only, no-prompt capability).
- NappletKeyActions (host): a registry that parses combos like "Ctrl+Shift+S"
/ "Cmd+Enter" / "F2" and matches them against KeyEvents.
- NappletHostActivity: binds an action only after the broker authorizes it
(from the keys.registerAction.result), unbinds on keys.unregisterAction, and
overrides dispatchKeyEvent to turn a matching combo into a keys.action push
via the shell bridge. Unmatched keys fall through to the WebView, so the
applet's own text inputs keep working. Touch-only devices simply never match.
shim already passed the full action (incl. defaultKey) and wired onAction to
the keys.action push, so no shim change was needed. Conformance test now
covers the defaultKey decode + binding round-trip; all napplet suites green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
Wire napplet.identity.onChanged end to end so an applet is notified when the
active user's public key changes (account switch / connect / disconnect):
- shim: onChanged registers a handler and opens a watch (identity.watch) on the
first handler; closing the last one stops it (identity.unwatch). identity.changed
pushes are dispatched to the handlers with the new pubkey.
- router: identity.watch (gated on the IDENTITY declaration) / identity.unwatch
become WatchIdentity / UnwatchIdentity outcomes — a push subscription, like
relay.subscribe, that never reaches the broker.
- NappletIdentityWatch (host): collects the active account's pubkey from the
session manager and pushes identity.changed on each subsequent change (the
current value is dropped — the applet already has it via getPublicKey). Torn
down on unwatch and on service destroy.
- codec: encodeIdentityChanged push envelope.
Router unit tests cover watch (declared/undeclared) and unwatch; commons tests green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
Fill in the identity reads that previously fell through to Unsupported, and
add nostr: resolution to resource.bytes — both reading from what Amethyst
already has locally, matching the @napplet/nap shapes.
identity (AccountIdentityReader):
- getList(listType): public tag values (e/a/p/t/word/r/emoji) of the user's
NIP-51 replaceable list of that type (bookmarks 10003, pins 10001, mute
10000, interests 10015, communities 10004, channels 10005, emojis 10030).
- getZaps: ZapReceipt[] {eventId, sender, amount, content?} from kind-9735
receipts p-tagging the user in the cache.
- getBadges: Badge[] {id, name?, description?, image?, thumbs?, awardedBy}
from kind-8 awards p-tagging the user, resolved against their kind-30009
definitions in the cache.
resource.bytes (NappletResourceFetcher):
- nostr: URIs (NIP-19) resolve to the referenced event's JSON
(application/json). nembed carries it inline; note/nevent/naddr resolve
from the cache then a bounded relay fetch; npub/nprofile resolve the
author's kind-0. Still no direct network for the applet.
The wire codec already routed these (generic IdentityRead + identityResultField,
ResourceBytes), so no protocol change was needed. Commons napplet tests stay green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
- `key validate PUBKEY` — nak's `key validate`: parse an npub or 64-hex
pubkey and report {valid, pubkey, npub}; never errors on bad input
(reports valid:false) so scripts branch on the field.
- `fetch <nevent|naddr|nprofile|npub|note|nip05>` — code mode that resolves
relays the way the Android app opens a shared link: the relay hints
embedded in the nip19 code UNION the author's NIP-65 write (outbox)
relays, draining the author's kind:10002 on a cache miss. This is nak's
`fetch` (nip19-hint resolution); filter mode is unchanged.
Verified: key validate accepts fiatjaf's npub/hex and rejects garbage +
bad-checksum npubs; `fetch <npub>` drained the author's kind:10002,
queried their actual advertised write relays, and returned kind:0.
nak parity: 23 full / 3 partial / 6 missing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
The two largest Android napplet files mixed many concerns. Decompose each
into focused collaborators, behavior-preserving (both napplet test suites
stay green):
NappletBrokerService (641 -> 195 lines) is now a thin IPC shell — Messenger
transport, per-account broker cache, lifecycle — delegating to:
- gateways/AccountNappletGateways: the account -> NappletBroker adapter that
wires all six gateway impls (relay publish/query, consent, wallet/NWC,
resource, identity, upload).
- gateways/NappletResourceFetcher: data:/https:/blossom: resource fetching
with the Tor-aware OkHttp client (sha256-verified blossom blobs).
- gateways/AccountIdentityReader: identity.* reads -> JSON (public data only).
- NappletConsentSummary: NappletRequest -> localized consent dialog text.
- NappletLiveSubscriptions: the live relay subscription registry (open/close/
closeAll, single-EOSE latch, per-sub client for correct teardown).
NappletHostActivity (483 -> 336 lines) keeps the Activity lifecycle, WebView
hardening, and the shell<->broker bridge; the resource edge moves to:
- NappletContentServer: serves the shell + verified blobs (CSP headers, SPA
fallback, shim injection) and owns the disk-cached blob HTTP client.
No wire or policy change; all decode/consent/exec still flows through the
shared NappletRequestRouter + NappletBroker.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
Two new nak-parity commands:
- `amy admin RELAY METHOD [args]` — NIP-86 Relay Management API over NIP-98
HTTP auth. Full method set: ban/unban + allow/unallow pubkey, ban/allow
event, allow/disallow kind, block/unblock IP, change name/description/icon,
and all list-* queries. Reuses quartz's Nip86Client (request build + NIP-98
auth + parse) and the Nip86Retriever HTTP path — extracted from amethyst to
commons/jvmAndroid so amy and the Android relay-management screen share it.
- `amy serve [--host --port --path --db --admin]` — runs a Nostr relay by
embedding geode (the standalone Ktor relay on quartz's relay-server code).
In-memory by default (ephemeral, like nak serve); --db FILE for SQLite. The
account's own pubkey is always an admin, so `amy admin` works against it out
of the box. cli gains a :geode dependency (geode depends only on :quartz) and
kotlinx-serialization-json (to render NIP-86 JSON results).
Verified end-to-end: `amy serve` + `amy admin ws://127.0.0.1:PORT
supported-methods|change-name|ban-pubkey|list-banned-pubkeys` round-trip
cleanly over real HTTP + NIP-98 against the live geode relay.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
Two shared extractions so the future desktop host reuses the exact same
sandbox and feed UI as Android, with no chance of drift:
Shared web contract:
- Move shell.html + shim.js into commons composeResources
(files/napplet/), read via Res.readBytes on any platform.
- New NappletWebContract (commons/commonMain) single-sources the whole
web contract: the shell/shim loaders plus the internal origin/host/URLs
and both Content-Security-Policies (SHELL_CSP, APP_CSP). The Android
host preloads the bytes in onCreate and reads every origin/CSP constant
from NappletWebContract instead of its own duplicated constants and
assets.open() calls.
Shared feed card:
- New StaticWebsiteCard (commons/.../ui/note) renders the inert NIP-5A /
NIP-5D preview card: self-contained with commons compose-resource
strings, LocalUriHandler for links, and inlined card chrome. It takes
an isNapplet flag and an onOpen launch slot, so it never executes applet
code itself.
- amethyst's note/types/StaticWebsite.kt becomes thin event->card
adapters that supply the sandboxed onOpen launch.
Card strings move to commons strings.xml. Both napplet test suites
(commons jvmTest + amethyst) stay green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
Move the decode → broker → encode orchestration out of Android's
NappletBrokerService.handleMessage and into a pure, transport-free
NappletRequestRouter in commons/jvmAndroid. It returns a small Outcome
(Ignore / Reply / OpenSubscription / CloseSubscription / Push) that each
host acts on, so the Android service and the future desktop host share
the routing brain and can't drift on wire behavior.
The service now resolves the broker and dispatches on the Outcome,
supplying only the Messenger transport and the live relay subscription.
openLiveSubscription takes the decoded filters from the router instead of
re-decoding the payload, and the now-redundant process() is removed.
Unit-tested in commons/jvmTest (NappletRequestRouterTest).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
Two behavioral divergences from the Android wallet, found while auditing
for parity:
- Publish targets: Amethyst sends every cashu event via
sendLiterallyEverywhere (all the user's relays). amy published only to
outboxRelays(); switch to anyRelays() (outbox + inbox + keypackage), the
CLI's closest analog, so the wallet lands on the same broad relay set.
- Nutzap relays on create: Amethyst always advertises the account's outbox
relays in kind:10019 (so senders publish nutzaps where the user reads).
amy defaulted to none; default to outboxRelays() unless --relay overrides.
Also align cashuSnapshot()'s store query with commons'
CashuWalletFilterAssembler exactly (six authored kinds by authors=[pk],
inbound nutzaps by #p) so amy projects the same event set the app
subscribes to. Verified the emitted kind:10019 now carries relay/mint/
pubkey tags identical to NutzapInfoEvent.build.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
The notifications feed is dominated by MultiSetCards, each rendering a gallery
of up to 30 author avatars. Profiling a loaded account showed the per-author
work on the main thread, not the GPU, as the scroll-jitter driver.
Three feature-preserving cuts:
- Hoist account-global reads out of the per-author avatar. The auto-play-gif
setting and the logged-in follow set were collected once *per author* (~60
redundant Flow collectors / coroutine launches per card). They are now
collected once per gallery and passed down via LocalAuthorGalleryRenderContext.
- Dedupe the per-author metadata subscription. Each avatar fired
UserFinderFilterAssemblerSubscription twice (via observeUserPicture +
observeUserContactCardsScore); both observers gained a `subscribe` flag so the
gallery subscribes once per author.
- Replace the per-event DateTimeFormatter day-bucketing in convertToCard with a
LocalDate.toEpochDay() Long key (identical grouping, no Instant/ZonedDateTime/
String allocation per reaction/zap/repost), and hoist the ZoneId lookup.
Measured before/after on a Samsung SM-T220 (loaded account, identical scripted
scroll, dumpsys gfxinfo): Slow-UI-thread events ~13 -> ~5 (~60% fewer),
95th-pct frame ~52ms -> ~38ms, janky frames ~13% -> ~10%. GPU timings unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add the offline Cashu command tier to amy, all driven by the shared
commons wallet code so amy exercises the same path as the Android app:
amy cashu wallet create [--mint URL] [--mints a,b] [--privkey HEX] [--relay r1,r2]
amy cashu wallet show
amy cashu wallet export-key
amy cashu wallet destroy
amy cashu mint ping URL (stateless)
amy cashu mint info URL (stateless)
amy cashu balance [--mint URL]
- create/destroy reuse commons CashuWalletOps.publishWalletEvents /
deleteWallet; show/balance reuse the CashuWalletReader projection over
the local event store; mint ping/info hit quartz's MintHttpClient.
- Context gains cashuOps() (wired to the file NUT-13 counter store + a
per-run seed cache) and cashuSnapshot(); DataDir gains cashu.json.
- Extraction D: CashuKeysetCounterStore contract in commons +
FileCashuKeysetCounterStore (atomic ~/.amy/<account>/cashu.json).
PRs 4 of cli/plans/2026-05-28-cashu-cli.md (extractions A–D + offline
tier). receive/send/maintenance/mint-rec + interop harness still pending.
Verified end-to-end against mint.minibits.cash and live relays.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
Add a pure, stateless CashuWalletReader in commons that projects a stream
of NIP-60/61/87 events into a WalletSnapshot (wallet/nutzap-info events,
decrypted mints, unspent token entries, history, pending quotes, nutzaps,
recommendations, plus balance + per-mint balances).
Android's CashuWalletState keeps its incremental dirty-tracking and
StateFlow plumbing but now delegates the two tricky computations —
del-rollover over decrypted tokens (computeUnspent) and the
destroyed/expired pending-quote filter (computePending) — to the shared
reader instead of carrying its own copies. amy will call project() once
per command over its event store.
Extraction C of cli/plans/2026-05-28-cashu-cli.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
Move the NIP-60/61 wallet orchestration layer (CashuWalletOps + its
result types: TokenEntry, MintQuoteStarted, MintCompleted, MeltCompleted,
SendTokenCompleted, RedeemCompleted, NutzapSent, RestoreOutcome,
MigrationResult, CreatedWallet, describeMintError) out of amethyst into
commons/jvmAndroid/cashu/ops.
The class already had zero Android dependencies — it takes signer,
publish, okHttpClient, secretFactory, and the NUT-13 counter callbacks as
constructor params. It lands in the jvmAndroid source set (not commonMain)
because it composes quartz's jvmAndroid CashuMintOperations/MintHttpClient
and uses ConcurrentHashMap. Both Android (amethyst) and the JVM CLI (amy)
can now drive the exact same wallet code path.
This is Extraction B of cli/plans/2026-05-28-cashu-cli.md. Android
callers (CashuWalletState, the wallet ViewModels, AccountViewModel)
updated to the new package; no behavioral change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
Set the desktopApp up to host napplets/nsites by maximizing the shared
core and documenting the edge it must build.
- Moved NappletProtocolJson (the wire codec) from amethyst to
commons/jvmAndroid (package ...commons.napplet.protocol), next to the
NappletRequest/Response types it marshals. It depends only on quartz +
kotlinx.serialization + java.util.Base64 (Android 26+/JVM), so a future
desktop host marshals through the identical object — request/result/push
shapes can't drift between platforms. amethyst host/service/tests updated
to import it; tests stay in amethyst and still exercise it.
- Added desktopApp/plans/2026-06-21-napplet-desktop-host.md: what's already
shared (broker, protocol, codec, resolver, the shell.html/shim.js web
contract), what desktop must build (KCEF/JCEF engine, custom-scheme
serving, isolation, transport, gateways, UI), the decisions to make, a
security-parity checklist, and recommended further extractions
(NappletRequestRouter, shared web assets, the inert feed card).
commons:jvmTest and the amethyst napplet suite pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
Code-audit pass over the napplet subsystem (see
plans/2026-06-21-napplet-code-audit.md):
Correctness:
- Live subscriptions now store the exact INostrClient that opened them, so
teardown unsubscribes from the right account even after an account
switch (previously leaked on the original account).
- Multi-relay subscriptions emit a single relay.eose via an eoseSent
latch, instead of one per relay (the SDK expects one).
Performance:
- The broker is cached per account (reference identity) instead of rebuilt
on every request.
- The blob OkHttpClient is cached and reused (keyed by Tor port) for
connection pooling, instead of a new client per fetch.
Refactor / docs:
- Moved the 105-line injected shim from a Kotlin string constant to
assets/napplet/shim.js (loaded once like shell.html); corrected stale
onChanged / subscribe comments.
Deferred (documented with rationale): cross-relay event dedup,
background pause/resume of live subs, request ordering, and the
recommended NappletProtocolJson -> commons/jvmAndroid move.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
Implements the remaining tractable conformance follow-ups:
- Live subscription tail: relay.subscribe now opens a real
client.subscribe whose SubscriptionListener streams relay.event
(stored + live), relay.eose, and relay.closed pushes keyed by subId,
instead of a one-shot snapshot. relay.close unsubscribes (tracked in
liveSubs, torn down in onDestroy). The broker only authorizes the
subscription (RELAY consent) and returns Subscribed; the host owns the
live stream. Shim dispatches relay.closed too.
- Multi-filters: relay.query/subscribe honor every filter in filters[],
not just the first (decodeFilterList; gateway query(List<Filter>);
queryEvents unions across filters; max limit applied).
- resource.cancel: accepted at the host edge as a no-op Done.
Conformance tests extended (multi-filter decode, relay.closed push);
commons:jvmTest and the amethyst napplet suite pass.
Still open: identity getList/getZaps/getBadges + onChanged (shapes
underspecified), the keys.action push (needs a host trigger UI), the
resource nostr: scheme, inc + the niche domains, and on-device
verification.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
Fill the KindNames registry from 145 to 280 entries so every event kind
quartz defines a class for has a canonical English label + NIP. Adds the
whole NIP-90 DVM request/response set, NIP-29 relay groups, NIP-60/61
Cashu wallet + nutzaps, NIP-43 relay members, WebRTC calls (NIP-AC),
marketplace (NIP-15), git PRs/state (NIP-34), CLINK, NIP-51 curation
sets, NIP-85 assertions, Marmot MLS events, and more.
For the handful of kind numbers shared by multiple classes, the registry
keeps one canonical entry (e.g. CashuToken over the deprecated nip61
TokenEvent, ExternalIdentities over GalleryList, ReleaseArtifactSet over
SoftwareRelease). Kind 1 stays "Notes" rather than the bounty value-add
helper that reuses it.
Also point `amy nip`'s Nostr fallback at quartz's canonical NipText kind
30817 (NipTextEvent) alongside the wiki and long-form kinds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
The relay kind chips, NIP-86 management screen, and app-recommendation
labels all map kinds to translated R.string labels via kindDisplayName(),
returning -1 for kinds without a localized name. Route that -1 case
through the shared quartz KindNames registry so any kind known to the
protocol layer renders its canonical English label instead of a bare
"k<kind>"/empty placeholder. Translations still win where they exist;
quartz is the canonical source for the rest.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
Closes the 🔴 items from the conformance audit so stock @napplet/shim
napplets interop:
1. Shell handshake: the host answers shell.ready with shell.init
{capabilities:{domains,protocols},services} built from the declared
domains (NappletProtocolJson.encodeShellInit), so supports() works.
2. Id-less messages: onShellMessage no longer drops messages without an
id — shell.ready is answered locally and fire-and-forget messages get
a synthetic id so they reach the broker.
3. keys: keys.registerAction/unregisterAction decode and the broker acks
them (declared-gated, no consent) so registerAction() resolves; the
shim dispatches the keys.action push. (Global-key binding is a
follow-up — keys.action isn't emitted yet.)
4. upload: realigned to upload.upload{request:{data,mimeType,filename}} →
rich UploadResult{ok,uploadId,status,url,sha256,size,mimeType};
shell.html inlines the request Blob as base64 so it survives the
bridge; the gateway uploads via the app's BlossomUploader to the
user's kind:10063 server with a signed auth event.
NappletSdkConformanceTest's gap guards flip to conformance assertions for
shell.init/keys/upload; inc stays the one documented gap. commons:jvmTest
and the amethyst napplet suite pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
Introduce a canonical, i18n-free event-kind registry in quartz:
`com.vitorpamplona.quartz.kinds.KindNames` maps each of the 145 known
kinds to an English label + the defining NIP. The data is ported from
Amethyst's relay-view `kindDisplayName` mapping (the NIP derived from
each event class's package), so the "what is kind N" knowledge now lives
once in quartz instead of only in the Android UI.
`amy kind <N|NAME>` looks a kind up by number (label + NIP) or searches
labels by name — a thin wrapper over KindNames, dispatched statelessly
(no account/network).
i18n split: quartz holds the canonical English (quartz is intentionally
translation-free); localized front ends overlay their own strings and can
fall back to KindNames.nameFor() for kinds they don't translate. amy
prints the English directly.
Verified: kind 1/0/30023/1059/24133 labels+NIPs, name search ("podcast"
→ 4), unknown → known:false, text + JSON output.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
- `amy key encrypt|decrypt` (NIP-49): encrypt a secret key to ncryptsec1…
and back. Bidirectionally interop-verified vs the real nak binary
(amy-encrypt → nak-decrypt and nak-encrypt → amy-decrypt both match).
- `amy nip N` / `amy nip list`: look up a NIP — the nostr-protocol/nips
git repo FIRST, then a Nostr fallback (NIP-50 search over wiki kind:30818
+ long-form kind:30023 on search-capable relays). Slug normalization
handles 1→01 and hex-suffixed NIPs (7d→7D, 5a→5A); titles parsed from the
setext headings NIP docs use.
- `amy blossom check|mirror`: HEAD-check blobs (exit 1 if any missing, like
nak) and request BUD-04 mirroring of a blob from a source URL.
Also persists the full introspected nak comparison into ROADMAP. `kind`
stays deferred — it needs a kind→schema registry that doesn't exist in
quartz (would be data/logic that doesn't belong in cli).
Verified: key round-trip + nak cross-impl both ways; nip repo titles for
01/46/5A/7D + Nostr fallback on miss; blossom check 200/404 + exit codes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
Three refinements to the disappearing top/bottom bar animations:
- Gap-safe settle: settleToNearestEdge could snap a partially-collapsed bar to
fully hidden whenever it was past the halfway point, even when the content had
only scrolled part of a bar height (e.g. a gentle flick from the top). Because
the content padding is fixed and the bar is translated, hiding it further than
the content scrolled reopens the same blank band the reveal-damping fix removed.
Each bar now latches whether it has actually reached its hidden edge through
scrolling; the settle only commits to fully hidden when that latch is set,
otherwise it settles back into view. This keeps the deliberate-reveal behavior
(a sub-halfway reveal after fully hiding still snaps back hidden) without the gap.
- Snappier settle spring: StiffnessMediumLow -> StiffnessMedium so the bars
resolve to their edge with a quick native snap instead of a slow float. Still
DampingRatioNoBouncy, and overshoot stays clamped by animateOne's bounds.
- Micro-scroll dead-zone: ignore sub-pixel scroll attempts so jitter doesn't
nudge the bars or flip the binary status-bar toggle. Kept tiny and symmetric so
the reveal never lags the content enough to open a gap.
Proportional top/bottom collapse was intentionally left out: both bars already
move at the same pixel rate (visual lock-step for their shared travel), and
forcing the shorter bar to finish at the same time as the taller one would push
it off the 1:1 content track and reintroduce a gap.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011jbLnoWks19ottXNrMZ6DH
Feature-by-feature audit of our edge layer against the canonical
@napplet/nap@0.15.0 / @napplet/core@0.15.0 message types, plus a test
suite that pins the codec to the SDK's exact wire so drift fails CI.
Audit (plans/2026-06-21-napplet-sdk-conformance-audit.md) catalogs every
domain with the verified wire shapes and ranks the inconsistencies found:
- 🔴 shell handshake missing: SDK uses shell.ready -> shell.init{capabilities,
services} and answers supports() locally; we model a shell.supports request
the SDK never sends, so a real napplet's capability env stays empty.
- 🔴 host drops id-less messages (shell.ready / inc.emit / keys.unregisterAction).
- 🔴 keys.* rejects at the boundary (only client-side stubs).
- 🔴 upload non-conformant (upload vs upload.upload; flat base64 vs
request:{data:Blob}; and a Blob can't cross our JSON string bridge).
- ◐ relay query/subscribe use only the first of filters[]; identity
getList/getZaps/getBadges + onChanged; resource nostr: + cancel;
relay.closed + live tail.
Conformant and now pinned by NappletSdkConformanceTest: relay
publish/publishEncrypted/query/subscribe + event/eose pushes, identity
reads (method-specific result fields), storage get/set/remove/keys,
resource.bytes, and the error convention. Gap guards assert the current
(non-conformant) behavior so each flips intentionally when fixed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
Adds NIP-46 `auth_url` (web-authorization) support to quartz's remote
signer, so amy (and Amethyst) can use auth-requiring bunkers like
nsec.app / nsecbunker:
- Both BunkerResponse deserializers (kotlinx + the JVM/Android Jackson
one actually used by OptimizedJsonMapper) now special-case
`{result:"auth_url", error:<url>}` BEFORE the generic error branch,
which previously flattened it to a plain error and dropped the marker.
- RemoteSignerManager gained an `onAuthUrl` callback and a wait loop: on
an auth_url response it surfaces the URL once and keeps waiting for the
real response under the same request id (UNLIMITED channel so both are
buffered). newResponse peeks the pending entry (get, not remove) so the
follow-up isn't dropped; the waiter still removes it in finally.
- NostrSignerRemote forwards `onAuthUrl`; amy's Context prints the URL to
stderr and the pending command keeps waiting.
Tests: a deserializer test (auth_url keeps result+error) and a manager
test (auth_url surfaced via callback, then the real response resumes the
request) — both green; existing duplicate/late-response manager tests
still pass after the get-vs-remove change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
Closes the remaining nsite/napplet runtime gaps, all keeping content
integrity (every blob is sha256-verified) and the sandbox intact:
- resource.bytes blossom: scheme — blossom:<sha256> fetches from the
user's kind:10063 Blossom servers and verifies the hash before
returning. nostr: stays deferred (bytes semantics unspecified).
- Content-type byte-sniffing in the resolver: when a manifest path has
no/unknown extension, sniff magic bytes (png/jpeg/webp/gif/pdf/wasm/...).
Text/markup is never sniffed so HTML detection stays extension-driven.
Unit-tested in quartz.
- kind:10063 server fallback: the launcher augments the manifest's servers
with the author's published Blossom list (best-effort, when cached).
- Blob caching: the host OkHttp client caches blobs on disk with a forced
immutable policy (content-addressed). The resolver re-verifies every
served blob's sha256, so a stale/poisoned cache entry can't be served.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
Add the client-initiated NostrConnect flow to complete NIP-46 parity:
- `amy login --nostrconnect [--relay …] [--name N]` (client) mints a
transport keypair, prints a nostrconnect:// offer, subscribes, and
waits for the signer's connect ACK (a kind:24133 whose decrypted
result echoes our secret). The ACK's author is the signer; it persists
a bunker account acting as that key.
- `amy bunker connect nostrconnect://…` (signer) parses a client offer,
sends the secret-echo ACK, then services that client's requests on the
offer's relays. Shares the serve loop with `amy bunker`.
NostrConnect.kt holds the offer parse/build (+percent-decode) and the
client handshake. BunkerCommand grew a `connect` sub-mode and factored
the request loop into serve().
Verified end-to-end:
- amy client ⇄ real `nak bunker connect`: amy learns nak's key and signs;
event authored by nak, signature valid.
- amy client ⇄ amy `bunker connect`: same, authored by the host key.
Only `auth_url` challenges remain unimplemented in the NIP-46 surface.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
Close the NIP-46 interop gap against the real nak binary:
- `Identity.fromBunkerUri` now URL-decodes the relay/secret params. nak
emits `bunker://<pk>?relay=wss%3A%2F%2F…&secret=…`; without decoding,
`amy login` of a nak bunker URI produced a broken relay and never
connected. (This was the one real break — found by testing vs nak.)
- `amy bunker` now percent-encodes the relay/secret in the URI it prints,
matching nak's output format.
- `amy bunker` implements `get_relays` (returns its relay set), so nak
clients that probe it get a proper reply instead of an error.
Verified end-to-end with `go install`-built nak over relay.damus.io,
both directions:
- `amy login bunker://` ⇄ `nak bunker`: amy signs, event authored by
nak's key, signature valid.
- `nak event --sec bunker://<amy>` ⇄ `amy bunker`: nak signs through
amy, event authored by amy's key; amy logs connect→ok, sign_event→ok.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
Add both halves of NIP-46 remote signing so two amy processes interop:
- `amy bunker [--relay …] [--secret S] [--timeout SECS]` runs a remote
signer for the active local-key account: prints a bunker:// URI, then
subscribes to kind:24133, decrypts each BunkerRequest, dispatches to
ctx.signer (connect/get_public_key/sign_event/nip04/nip44/ping), and
publishes the encrypted BunkerResponse. Long-running like `subscribe`.
- `amy login bunker://PUBKEY?relay=…&secret=…` creates a remote-signer
account: mints a local transport keypair, records the connection, and
acts as PUBKEY. Context builds a NostrSignerRemote (vs the local
NostrSignerInternal) and runs openSubscription()+connect() in prepare();
every signing/encryption call is delegated to the bunker.
Storage: IdentityFile gains a `bunker` block; the transport key is kept
in the existing SecretStore `secret` field. Identity/DataDir load+save
handle the remote-signer account type; `canSign` reflects "writeable via
bunker".
Thin assembly over quartz nip46RemoteSigner (NostrSignerRemote,
BunkerRequest*/BunkerResponse*, NostrConnectEvent). Verified end-to-end
over relay.damus.io: alice hosts a bunker, bob logs in and `amy event`
signs remotely — the note is authored by alice's key and verifies
(id_ok + signature_ok); bunker logs connect→ok, sign_event→ok.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
The 0.5 reveal-sensitivity damping in DisappearingBarNestedScroll made the
bars reveal at half the rate they hide. Because the content scrolls 1:1, the
bar offset would lag behind the content scroll: after hiding the chrome and
scrolling back up to the top, the list reaches its top while the bar is still
only half revealed, leaving a blank band between the bar and the first item.
The bar's translationY must mirror the content scroll offset exactly so its
bottom edge stays glued to the first item's top edge. Any persistent reveal
damping breaks that invariant and opens the gap, so revealing now tracks the
finger 1:1 like hiding does.
The original goal of the damping — keeping the chrome from popping back on the
tiny reverse drag a finger makes when it catches a fast scroll — is still met
without breaking the 1:1 invariant: a partial reveal that doesn't cross the
halfway point is snapped back to the hidden edge by settleToNearestEdge on
fling/lift, and the binary OS status bar is already debounced by the
show/hide hysteresis in the scaffold.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011jbLnoWks19ottXNrMZ6DH
Two nsite/napplet runtime improvements in the sandboxed WebView host,
both keeping the trust boundary intact:
- SPA fallback: a document navigation (Accept: text/html) to a route not
in the manifest now serves the verified index.html instead of 404, so
client-side-routed static sites survive deep links and refreshes.
Missing sub-resources (js/css/images) still 404 — they don't accept
html — so a broken asset never silently returns the page. The fallback
only ever serves the manifest's own hash-verified index.html.
- External links: a link the user taps to an off-origin host is handed to
the system browser via ACTION_VIEW instead of silently failing. Gated on
a real user gesture (so a hostile site can't auto-redirect to spam-open
the browser) and restricted to http/https (no arbitrary intent schemes).
The sandbox WebView itself never navigates away from the internal origin.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
git (repository metadata + collaboration events; clone/push packfile
transport is out of scope):
- git announce publish a kind:30617 repository announcement
- git list list a user's repo announcements
- git show print one announcement (naddr or kind:pubkey:id), cache-first
- git issue publish a kind:1621 issue against a repo (fetches the repo
announcement to build the EventHintBundle)
podcast (NIP-F4):
- podcast metadata publish kind:10154 show metadata (replaceable)
- podcast publish publish a kind:54 episode (--audio URL[,URL])
- podcast list list a user's metadata + episodes
Thin assembly over quartz GitRepositoryEvent / GitIssueEvent /
PodcastMetadataEvent / PodcastEpisodeEvent. Verified offline: announce->
show cache round-trip (name/clone/hashtags), issue kind:1621 against the
repo, podcast metadata kind:10154 + episode kind:54.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
`amy sync --relay URL [filter] [--down] [--up]` reconciles the local
event store with a relay using NIP-77 Negentropy:
- negotiate the symmetric difference under the filter (drives quartz
NegentropySession over a raw WebSocket, mirroring geode's interop
driver),
- --down (default) downloads the ids the relay has and we lack via
Context.drain,
- --up uploads the events we have and the relay lacks via
Context.publish.
Filter flags match fetch/subscribe; an empty filter reconciles the
whole store. Verified against relay.damus.io: cold store -> need=60,
downloaded=60; immediate re-sync -> need=0 (idempotent).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY