fix(desktop): make remotely hosted agents mentionable from other machines - #4023
fix(desktop): make remotely hosted agents mentionable from other machines#4023TanskiSzymon wants to merge 6 commits into
Conversation
`isAgentIdentityInManagedList` dropped every candidate whose `isAgent` flag was set unless the agent was in `managedAgentPubkeys`, i.e. running locally on this machine. That gate ran before `shouldHideAgentFromMentions`, so the finer directory rules it implements (kind:10100 entry, `respond_to`, shared channels) could never be reached for an agent hosted elsewhere. The effect was self-contradictory: `useMentions` adds relay-directory agents as candidates with `isAgent: true`, and the very next filter removed them again. An agent running on another machine was impossible to @-mention no matter how it was configured. Pass the already-computed `mentionableAgentPubkeys` into the gate so agents the relay advertises as invocable survive it. The parameter is optional, so `MembersSidebar` — where restricting to locally managed agents is intended — keeps its current behavior. Signed-off-by: Szymon Tanski <szymontanski8@gmail.com>
Clients discover invocable agents through `list_relay_agents`, which queries kind:10100, and `getMentionableAgentPubkeys` derives mentionability from that entry's `respond_to` and `channel_ids`. Nothing ever wrote the kind, so the directory was empty in every deployment and an agent hosted on one machine could not be mentioned from another. Publish the entry from `probe_agent_relay_access`, which already runs per (agent, community) on start and already queries the agent's kind:39002 memberships — so the channel list needs no extra round trip, and the event is signed with the agent's own keys, as consumers expect (the pubkey is taken from the event author). kind:10100 carries two contracts at once: the client-facing directory fields and the relay's `channel_add_policy`, whose side effect fails outright when the field is absent. The entry therefore includes `owner_only`, matching the relay's existing default so behavior is unchanged. Publishing is best-effort — a failure is logged and never blocks startup, since the agent still works locally regardless. Also adds `submit_signed_event_with_keys_at`, so the entry goes to the agent's own relay rather than whichever community happens to be active. Signed-off-by: Szymon Tanski <szymontanski8@gmail.com>
The entry published at start time is necessarily channel-less: kind:39002 memberships do not exist until the harness first connects, so a freshly created agent always advertises an empty `channel_ids`. That is worse than publishing nothing. `relayAgentIsSharedWithUser` requires a shared channel for `respond_to: anyone`, so a channel-less entry is never invocable — and `shouldHideAgentFromMentions` reads directory presence without invocability as an explicit not-invocable signal, hiding an agent that would otherwise have been offered. Refresh the entry from `add_channel_members`, once memberships exist. The refresh is a no-op for pubkeys that are not locally managed agents, since only this machine holds their keys, and stays best-effort so a failure never breaks adding a member. Signed-off-by: Szymon Tanski <szymontanski8@gmail.com>
kind:39002 is addressable: the channel id is the event's `d` tag and the members are `p` tags — the same shape `get_channels` already relies on. The directory entry read `h` instead, so `channel_ids` came out empty for every agent and the entry advertised the agent as reachable in no channel at all. `h` is how *message* events scope a channel; membership events are a different kind with a different tag, and the repo guidance about `h` tags does not extend to them. Signed-off-by: Szymon Tanski <szymontanski8@gmail.com>
…tory Only the machine holding an agent's key can sign its kind:10100 entry, so a membership change made from another device never reaches it — the agent stays unmentionable in that channel until its next restart. `add_channel_members` covers the case where the host itself adds the agent; this covers the rest. Add `refresh_agent_directory_entries`, invoked from the relay-directory query so entries are republished exactly when a stale one would be visible. Throttled per agent (60s) because the caller is UI-driven, and best-effort throughout: a failure just means the directory is read as-is. Also documents the behavior where users choose it. "Who can send instructions" now states that Anyone requires a shared channel, that the agent runs on the machine that started it, and that it answers only while that machine is awake with the app open — the three things that otherwise look like the feature is broken. Signed-off-by: Szymon Tanski <szymontanski8@gmail.com>
…achable `respond_to: anyone` means "any member of a channel I am in", but the check asked only the agent's own directory entry whether it shares a channel with us. That entry can only be written by the machine holding the agent's key, so it lags behind reality whenever someone else changes the agent's channels, and is empty for an agent that has not republished since starting. Visibility therefore depended on whether the agent's host happened to refresh in time — something the reader can neither observe nor influence, and which showed up as a newly created agent being unmentionable right after being added to a channel. Accept the open channel's membership as an equally valid signal. The client already holds it, and it is correct the moment the agent joins. The directory entry stays as the second source, still covering shared channels whose membership is not loaded. `owner-only` and `allowlist` are unaffected: presence in a channel never widens who may instruct an agent. Signed-off-by: Szymon Tanski <szymontanski8@gmail.com>
|
I can offer windows testing of this fix. See: #3277 (comment) |
|
Independently traced this on The only production writer of
So Searched all of For triage, the open queue splits along exactly that line. Most of the PRs address the gate half; I read #4058 closely and it is inert on its own for the reason above, and the same argument applies to any gate-only change. #3708 is deliberately scoped to the publisher half and states the same finding ("nothing has ever written to it... a no-op in practice"). This PR is the only open one I found that closes both halves in a single change. So the shortest path to a user-visible fix is either this PR, or #3708 plus one gate PR. Either way the publisher half cannot be skipped. Six open issues describe the resulting symptom: #2508, #3125, #3277, #3671, #3809, #3971. Reported by a user hitting this in a multi-machine setup: agents show in the member list with presence, but never appear in autocomplete from any machine other than the host. |
|
Thanks — Windows testing would be genuinely useful. I only verified this on macOS (Apple Silicon and Intel), across two machines against a self-hosted relay, so a third platform closes a real gap. Coming back to this with the context I should have gathered before opening: there are already several PRs against the same symptom — #2605, #2893, #3292, #3472, #3676 — plus #2603, #2950, #2987 and #3277 describing it. I didn't check first, which was my mistake. Having read through them, one difference is worth flagging, and it's the reason this one is larger: Every other PR fixes only the client side. They correct the gate ordering in This PR does both halves:
Verified end to end on a self-hosted relay: an agent running on one machine is mentionable and answers from a second, under I'm not attached to this being the PR that lands. If maintainers prefer #3676's client-side shape, the Rust half here is separable and I'm happy to rebase it as a follow-up on top of whichever client fix is chosen — the two are complementary, not competing. @Shipitrealgood, happy to coordinate if that's useful. One thing I'd flag for whoever picks this up: |
|
Two additions, both verified against The #3708 is the other open PR on the publisher half; it is not in the duplicate list above, and it solves this by reading the agent's prior record back rather than sending a fixed default: pub(super) fn existing_channel_add_policy(events: &[nostr::Event]) -> Stringwith the stated reason that a fixed value would "rewrite an operator's deliberate The No carve-out for addressable kinds, so reading it as universal is the natural reading. Worth a one-line PR so the next person does not lose the same cycle. |
|
You are right on both counts, and #3708 changes what I think should happen with this PR. I missed #3708 when I looked for prior work. It solves the publisher half properly, and the read back of the prior So I am not going to argue for my Rust half. #3708 does it better, it came first, and it comes from the person who reported the issue. If maintainers want the publisher, that is the one to take. What I would like to check before deciding what is left here: does any open PR treat channel membership as an independent proof of reachability for The reason I ask is that publishing the directory record is necessary but not sufficient. A record published at agent start is always channel less, because kind:39002 memberships do not exist until the harness first connects. That empty record then reads as an explicit not invocable signal in My last commit sidesteps it by accepting the open channel's membership as sufficient for If that idea is already covered somewhere in the queue, I will close this and there is nothing lost. If it is not, I would rather strip this PR down to just that piece on top of #3708 than keep a competing publisher around. On the doc bug, I will open a separate one line PR for AGENTS.md. It is uncontested and it does not need to wait for any of this to be resolved. |
The channel scoping note in `AGENTS.md` reads as universal: > **Channel scoping**: Channels use `h` tags (NIP-29 group tag), not `e` tags. > Filters and queries must scope to `h` tags when operating within a channel. It holds for events inside a channel, but not for the addressable events that describe one. kind:39000, kind:39001 and kind:39002 carry the channel id in their `d` tag, which is what `get_channels` already reads. Taking the existing wording at face value while working on kind:39002 produces an empty result rather than an error, since those events do carry `h` tags in other flows, so the mistake is quiet and costs a debugging cycle. Came up while working on #4023. Four lines, no behaviour change. Signed-off-by: Szymon Tanski <szymontanski8@gmail.com>
* fix(desktop): keep thread-open affordance in archived channels (block#4012) ## Problem Threaded replies "disappeared" from archived Buzz channels: the **"N replies →"** summary row and the huddle-started **"View thread"** button vanished, so existing threads were unreachable from the channel timeline. The thread data was intact — this was a UI gate, not data loss. ## Root cause A single `onReply` prop drove two distinct affordances: - the **compose** affordances (hover "Reply" button, inline reply target), and - the **view** affordances ("N replies →" summary row, huddle "View thread"). `ChannelPane` nulls `onReply` on archived channels to keep them read-only. That correctly hid composing — but also hid the view affordances, since they keyed off the same prop. ## Fix Two independent props, one per concern: - **`onReply`** drives the compose affordances and is gated on `archivedAt` — nulled on archived channels, so no new replies can be started. - **`onOpenThread`** drives the view affordances and is passed regardless of archived state, threaded `ChannelPane → MessageTimeline → TimelineMessageList → MessageRow`. Opening a thread on an archived channel is read-only: the thread panel's composer is independently gated via `isComposerDisabled` (includes `archivedAt !== null`, `ChannelPane.tsx:318`). ### Before <img width="811" height="794" alt="Screenshot 2026-07-31 at 20 26 00" src="https://github.com/user-attachments/assets/670d9db4-30da-4c6d-97dc-275b5dbebca8" /> ### After <img width="873" height="791" alt="Screenshot 2026-07-31 at 20 28 04" src="https://github.com/user-attachments/assets/88525231-2539-4eb3-8117-8e58a0cb3855" /> ## Validation - `pnpm typecheck` clean - biome lint clean on touched files - full `pnpm test` suite green (3885 tests) - pre-push `branch-skew` / `desktop-check` / `desktop-test` hooks passed Signed-off-by: Trey Wood <treyw@squareup.com> Co-authored-by: npub14h0tw3uj7jm77qfxcwn6um2s5h55l0klrt2w9srzp3m3yvjc0mpsjsuk6e <addeb74792f4b7ef0126c3a7ae6d50a5e94fbedf1ad4e2c0620c771232587ec3@buzz.block.builderlab.xyz> * docs: fix stale kind count, quick-start numbering, and empty Further Reading (block#2613) ## Problem Three small documentation defects, each verified against the code at 06e3d82: 1. **ARCHITECTURE.md (Event Kinds section)** says `buzz-core` defines "all 81 kinds". The registry has grown: `ALL_KINDS` in `crates/buzz-core/src/kind.rs` now has **127** entries (all unique values). The sentence also says every kind is `pub const KIND_*`, but registry entries such as `RELAY_ADMIN_ADD_MEMBER` do not use that prefix. 2. **NOSTR.md Quick Start** numbers its steps 1, 2, 3, 5 — there is no step 4. PR block#797 (2a03851) collapsed the old steps 1-4 (dropping the separate "Start infrastructure" step) into 1-3, but the final "Connect any NIP-29 + NIP-42 client" comment kept its old number 5. 3. **NOSTR.md "Further Reading"** is an empty heading — the section's only content (a link to `crates/buzz-proxy/README.md`) was removed in PR block#1321 (14fba21) along with the proxy crate itself, leaving a dangling header as the last line of the file. ## Fix 1. Reworded the ARCHITECTURE.md sentence to defer to `crates/buzz-core/src/kind.rs` as the source of truth, with the current count (127) as an explicit "at the time of writing" snapshot, so the sentence stays truthful as kinds are added. Also removed the incorrect `KIND_*`-naming claim. 2. Renumbered the final quick-start step 5 → 4. 3. Populated Further Reading with three durable links: the upstream nostr-protocol/nips repo, this repo's `docs/nips/` extension documents, and `ARCHITECTURE.md`. Docs-only; no code changes, no build impact. ## Verification (each claim ~30 seconds) - Kind count: `python3 -c "import re; s=open('crates/buzz-core/src/kind.rs').read(); m=re.search(r'ALL_KINDS: &\[u32\] = &\[(.*?)\];', s, re.S); print(len([e for e in m.group(1).split(',') if e.strip()]))"` → 127. All 127 values are distinct. Non-`KIND_*` entry example: `RELAY_ADMIN_ADD_MEMBER` (kind.rs, in `ALL_KINDS`). - Missing step: `grep -n '^# [0-9]' NOSTR.md` on main shows `# 1.`, `# 2.`, `# 3.`, `# 5.` in the Quick Start block; `git show 2a03851 -- NOSTR.md` shows the renumbering that orphaned step 5. - Empty section: `tail -1 NOSTR.md` on main is `## Further Reading` with nothing after it; `git log -S'buzz-proxy/README' --oneline -- NOSTR.md` shows the content removal in 14fba21 (block#1321). ## Links - `crates/buzz-core/src/kind.rs` — `ALL_KINDS` registry (source of truth for the count) - PR block#797 / 2a03851 — introduced the step-numbering gap - PR block#1321 / 14fba21 — emptied the Further Reading section Signed-off-by: Sean Gearin <sgearin@gmail.com> Co-authored-by: Sean Gearin <sgearin@gmail.com> * docs: note that addressable channel events scope by d, not h (block#4103) The channel scoping note in `AGENTS.md` reads as universal: > **Channel scoping**: Channels use `h` tags (NIP-29 group tag), not `e` tags. > Filters and queries must scope to `h` tags when operating within a channel. It holds for events inside a channel, but not for the addressable events that describe one. kind:39000, kind:39001 and kind:39002 carry the channel id in their `d` tag, which is what `get_channels` already reads. Taking the existing wording at face value while working on kind:39002 produces an empty result rather than an error, since those events do carry `h` tags in other flows, so the mistake is quiet and costs a debugging cycle. Came up while working on block#4023. Four lines, no behaviour change. Signed-off-by: Szymon Tanski <szymontanski8@gmail.com> * Nest-MCP war still unbenutzt: Vault + n8n verdrahtet, Approval-Loch gefixt (#3) --------- Signed-off-by: Trey Wood <treyw@squareup.com> Signed-off-by: Sean Gearin <sgearin@gmail.com> Signed-off-by: Szymon Tanski <szymontanski8@gmail.com> Co-authored-by: Trey Wood <edwinjwood3rd@gmail.com> Co-authored-by: npub14h0tw3uj7jm77qfxcwn6um2s5h55l0klrt2w9srzp3m3yvjc0mpsjsuk6e <addeb74792f4b7ef0126c3a7ae6d50a5e94fbedf1ad4e2c0620c771232587ec3@buzz.block.builderlab.xyz> Co-authored-by: Sean Gearin <sean@indistinct.ai> Co-authored-by: Sean Gearin <sgearin@gmail.com> Co-authored-by: Tansky <108231030+TanskiSzymon@users.noreply.github.com>
* fix(desktop): keep thread-open affordance in archived channels (block#4012) ## Problem Threaded replies "disappeared" from archived Buzz channels: the **"N replies →"** summary row and the huddle-started **"View thread"** button vanished, so existing threads were unreachable from the channel timeline. The thread data was intact — this was a UI gate, not data loss. ## Root cause A single `onReply` prop drove two distinct affordances: - the **compose** affordances (hover "Reply" button, inline reply target), and - the **view** affordances ("N replies →" summary row, huddle "View thread"). `ChannelPane` nulls `onReply` on archived channels to keep them read-only. That correctly hid composing — but also hid the view affordances, since they keyed off the same prop. ## Fix Two independent props, one per concern: - **`onReply`** drives the compose affordances and is gated on `archivedAt` — nulled on archived channels, so no new replies can be started. - **`onOpenThread`** drives the view affordances and is passed regardless of archived state, threaded `ChannelPane → MessageTimeline → TimelineMessageList → MessageRow`. Opening a thread on an archived channel is read-only: the thread panel's composer is independently gated via `isComposerDisabled` (includes `archivedAt !== null`, `ChannelPane.tsx:318`). ### Before <img width="811" height="794" alt="Screenshot 2026-07-31 at 20 26 00" src="https://github.com/user-attachments/assets/670d9db4-30da-4c6d-97dc-275b5dbebca8" /> ### After <img width="873" height="791" alt="Screenshot 2026-07-31 at 20 28 04" src="https://github.com/user-attachments/assets/88525231-2539-4eb3-8117-8e58a0cb3855" /> ## Validation - `pnpm typecheck` clean - biome lint clean on touched files - full `pnpm test` suite green (3885 tests) - pre-push `branch-skew` / `desktop-check` / `desktop-test` hooks passed Signed-off-by: Trey Wood <treyw@squareup.com> Co-authored-by: npub14h0tw3uj7jm77qfxcwn6um2s5h55l0klrt2w9srzp3m3yvjc0mpsjsuk6e <addeb74792f4b7ef0126c3a7ae6d50a5e94fbedf1ad4e2c0620c771232587ec3@buzz.block.builderlab.xyz> * docs: fix stale kind count, quick-start numbering, and empty Further Reading (block#2613) ## Problem Three small documentation defects, each verified against the code at 06e3d82: 1. **ARCHITECTURE.md (Event Kinds section)** says `buzz-core` defines "all 81 kinds". The registry has grown: `ALL_KINDS` in `crates/buzz-core/src/kind.rs` now has **127** entries (all unique values). The sentence also says every kind is `pub const KIND_*`, but registry entries such as `RELAY_ADMIN_ADD_MEMBER` do not use that prefix. 2. **NOSTR.md Quick Start** numbers its steps 1, 2, 3, 5 — there is no step 4. PR block#797 (2a03851) collapsed the old steps 1-4 (dropping the separate "Start infrastructure" step) into 1-3, but the final "Connect any NIP-29 + NIP-42 client" comment kept its old number 5. 3. **NOSTR.md "Further Reading"** is an empty heading — the section's only content (a link to `crates/buzz-proxy/README.md`) was removed in PR block#1321 (14fba21) along with the proxy crate itself, leaving a dangling header as the last line of the file. ## Fix 1. Reworded the ARCHITECTURE.md sentence to defer to `crates/buzz-core/src/kind.rs` as the source of truth, with the current count (127) as an explicit "at the time of writing" snapshot, so the sentence stays truthful as kinds are added. Also removed the incorrect `KIND_*`-naming claim. 2. Renumbered the final quick-start step 5 → 4. 3. Populated Further Reading with three durable links: the upstream nostr-protocol/nips repo, this repo's `docs/nips/` extension documents, and `ARCHITECTURE.md`. Docs-only; no code changes, no build impact. ## Verification (each claim ~30 seconds) - Kind count: `python3 -c "import re; s=open('crates/buzz-core/src/kind.rs').read(); m=re.search(r'ALL_KINDS: &\[u32\] = &\[(.*?)\];', s, re.S); print(len([e for e in m.group(1).split(',') if e.strip()]))"` → 127. All 127 values are distinct. Non-`KIND_*` entry example: `RELAY_ADMIN_ADD_MEMBER` (kind.rs, in `ALL_KINDS`). - Missing step: `grep -n '^# [0-9]' NOSTR.md` on main shows `# 1.`, `# 2.`, `# 3.`, `# 5.` in the Quick Start block; `git show 2a03851 -- NOSTR.md` shows the renumbering that orphaned step 5. - Empty section: `tail -1 NOSTR.md` on main is `## Further Reading` with nothing after it; `git log -S'buzz-proxy/README' --oneline -- NOSTR.md` shows the content removal in 14fba21 (block#1321). ## Links - `crates/buzz-core/src/kind.rs` — `ALL_KINDS` registry (source of truth for the count) - PR block#797 / 2a03851 — introduced the step-numbering gap - PR block#1321 / 14fba21 — emptied the Further Reading section Signed-off-by: Sean Gearin <sgearin@gmail.com> Co-authored-by: Sean Gearin <sgearin@gmail.com> * docs: note that addressable channel events scope by d, not h (block#4103) The channel scoping note in `AGENTS.md` reads as universal: > **Channel scoping**: Channels use `h` tags (NIP-29 group tag), not `e` tags. > Filters and queries must scope to `h` tags when operating within a channel. It holds for events inside a channel, but not for the addressable events that describe one. kind:39000, kind:39001 and kind:39002 carry the channel id in their `d` tag, which is what `get_channels` already reads. Taking the existing wording at face value while working on kind:39002 produces an empty result rather than an error, since those events do carry `h` tags in other flows, so the mistake is quiet and costs a debugging cycle. Came up while working on block#4023. Four lines, no behaviour change. Signed-off-by: Szymon Tanski <szymontanski8@gmail.com> * Werkzeugbestand steht jeden Morgen im Brief statt nur auf Zuruf (#59) --------- Signed-off-by: Trey Wood <treyw@squareup.com> Signed-off-by: Sean Gearin <sgearin@gmail.com> Signed-off-by: Szymon Tanski <szymontanski8@gmail.com> Co-authored-by: Trey Wood <edwinjwood3rd@gmail.com> Co-authored-by: npub14h0tw3uj7jm77qfxcwn6um2s5h55l0klrt2w9srzp3m3yvjc0mpsjsuk6e <addeb74792f4b7ef0126c3a7ae6d50a5e94fbedf1ad4e2c0620c771232587ec3@buzz.block.builderlab.xyz> Co-authored-by: Sean Gearin <sean@indistinct.ai> Co-authored-by: Sean Gearin <sgearin@gmail.com> Co-authored-by: Tansky <108231030+TanskiSzymon@users.noreply.github.com>
|
@TanskiSzymon "What I would like to check before deciding what is left here: does any open PR treat channel membership as an independent proof of reachability for Yes — open PR #3676 treats channel membership as independent reachability for How it is handled there #3676 rewrites Desktop
Local managed agents stay always offerable (native path), membership not required. So the race you hit — a start-time record with empty This also satisfies the four-row fixture table in #3277 ( Before closing, worth a pass with @4dlt against #3708 on three things here:
|
Related work
Reported in #3277, #2950, #2987 and #2603. Client-side fixes for the same
symptom are proposed in #2605, #2893, #3292, #3472 and #3676 — this PR differs
in also publishing kind:10100, without which the directory those fixes read
from stays empty (see #3277 for relay-layer confirmation). The Rust half here
is separable and can be rebased on top of whichever client-side fix maintainers
prefer.
Problem
An agent hosted on one machine cannot be @-mentioned from another. This makes the
"run an agent on a always-on box, talk to it from your laptop" setup impossible,
and the failure is silent: the agent shows up in the member list with a presence
dot, but never appears in mention autocomplete and never answers.
Three independent causes, each sufficient on its own.
1. Remote agents were filtered out before the directory rules ran.
useMentionsadds relay-directory agents as candidates withisAgent: true, andthe very next filter —
isAgentIdentityInManagedList— dropped every agent not inmanagedAgentPubkeys, i.e. not running locally.shouldHideAgentFromMentions,which implements the actual directory rules, could never be reached for a remote
agent. The two are contradictory: one path adds them, the next removes them.
2. Nothing published kind:10100.
list_relay_agentsqueries kind:10100 andgetMentionableAgentPubkeysderivesmentionability from it, but no code path in the desktop or the harness ever wrote
that kind. The directory is empty in every deployment, so the discovery path it
backs is dead code in practice.
3.
respond_to: anyonewas decided from a source only the agent's host can write.anyonemeans "any member of a channel I am in". The check asked the agent's owndirectory entry whether it shares a channel with the reader — but only the machine
holding the agent's key can update that entry. It is empty for an agent that has
not republished since starting, and stale whenever someone else changes the agent's
channels. Visibility therefore depended on whether a third party's laptop happened
to refresh in time.
Changes
mentionableAgentPubkeysintoisAgentIdentityInManagedListso directory-advertised agents survive the gate.The parameter is optional;
MembersSidebar, where restricting to locally managedagents is intended, is unchanged.
probe_agent_relay_access, whichalready runs per (agent, community) on start and already queries the agent's
kind:39002 memberships — no extra round trip, and it is signed with the agent's
own keys as consumers expect.
(throttled per agent), so it does not stay channel-less after creation.
anyone.The client already holds it and it is correct the moment the agent joins;
the directory entry remains as the second source.
owner-onlyandallowlistare unaffected — presence in a channel never widens who may instruct an agent.
kind:10100 carries two contracts
Worth flagging for review. The client reads
name,respond_toandchannel_idsfrom this kind, while the relay's
handle_agent_profilerequireschannel_add_policyand fails the whole side effect without it:So an entry written for the client is rejected by the relay, and vice versa. This
is likely why the directory was never populated. The entry here satisfies both,
publishing
owner_only— the relay's own default, so behavior is unchanged. Acleaner fix is to split the concerns, but that is a protocol change and out of
scope here.
Testing
desktop: 3889 unit tests pass, including new cases for the eligibility gate,membership-beats-stale-directory, and that presence never overrides
owner-only.desktop/src-tauri:agent_events11/11, covering the entry shape,channel_add_policy, snake_case wire format, and channel-id extraction.cargo clippy --all-targets -- -D warningsclean;biome checkclean.on a Mac mini is mentionable and answers from a second laptop, under both
anyoneandallowlist, whileowner-onlystays private.Note on kind:39002
channel_idsare read from thedtag.AGENTS.mdsays channels usehtags,which holds for message events but not for membership events — kind:39002 is
addressable and carries the channel in
d, the same shapeget_channelsrelieson. Calling it out because the guidance reads as universal and cost me a debugging
cycle.