feat(skills): add buzz-multi-session skill for cross-session coordination - #4481
feat(skills): add buzz-multi-session skill for cross-session coordination#4481ashbrener wants to merge 11 commits into
Conversation
…tion Parallel Claude Code sessions — typically three git worktrees of one repo — have no channel between them today, so the human becomes the message bus: copying answers between terminals and discovering conflicting edits late. This adds a Claude Code skill that gives each session its own Buzz identity, joins them all to one channel, and arms Claude Code's Monitor tool on that channel so a peer's message wakes the session directly. Two bundled scripts carry the details that are easy to get wrong: - scripts/buzz-session.sh mints or reuses a per-session identity named after the git worktree, stored mode 600 under ~/.buzz/sessions. buzz-admin generate-key is piped straight into the file so the secret never reaches a terminal or an agent transcript; only the public key is ever printed. - scripts/buzz-watch.sh is the Monitor command. `messages get --since` is inclusive, so a timestamp watermark alone replays the newest message on every poll — it dedupes on event id instead, primes the seen-set from existing history so arming the watcher does not dump the backlog, filters out the session's own pubkey so it cannot react to itself, and keeps only chat kinds 9 and 1. Poll interval is 5s, the relay rate-limit floor. SKILL.md documents the two-gate rule that causes the most confusion (relay membership and channel membership are separate; a relay member still sees nothing until the channel owner runs `channels add-member`), and defines a verb-prefixed message protocol — HELLO / CLAIM / RELEASE / STATUS / ASK / ANSWER / BLOCKED / DONE — so peers can triage without reading every message and can avoid editing paths another session has claimed. Enrolment is written against `buzz invites claim --code <token>` from block#3014, which is not yet implemented; the skill says so explicitly and documents `buzz-admin add-member` as the interim path. The directory name avoids the `buzz-cli` rename landing in block#2525. Placed in .claude/skills only, not the other harness roots, because the workflow depends on the Monitor tool. Signed-off-by: Ash Brener <ashley@starlogik.com>
…i-session Signed-off-by: Ash Brener <ashley@starlogik.com>
The identity name was derived from <repo>-<worktree-dir>, so a message in the coordination channel pointed at a directory. Two sessions in one worktree collided, and the name told the user nothing about which of their sessions had spoken. A session can name itself. Claude Code exports CLAUDE_CODE_SESSION_ID and writes the transcript to ~/.claude/projects/<cwd with / and . replaced by ->/<session-id>.jsonl, where the title set by /rename appears as customTitle. buzz-session-name.sh reads it — last occurrence wins, because /rename can be run more than once — and falls back to the worktree directory, then the session id, then a hash of the cwd. It never fails and never returns an empty name: a session with no /rename, no session id and no git repo still gets a stable, per-directory identity. Two forms come out of the same tier so they cannot disagree. The slug is lowercased, reduced to [a-z0-9._-], collapsed and capped at 64 characters, because it becomes ~/.buzz/sessions/<slug>.env — a "/" can only ever become "-" and a leading ".." is stripped, so a title cannot escape the directory. The display name keeps the original characters, emoji included, with control and format characters (bidi overrides among them) removed. The identity now follows a /rename rather than forking. The session id is recorded alongside the keypair, so a later run finds the existing identity under its old name and renames the file. The keypair is preserved: a /rename is not a new member. Free text and a sourced file do not mix. The .env holds only two hex keys and a validated relay URL, and is checked against that shape before being sourced; the session id, display name and published-profile name live in a .meta sidecar that is only ever grepped. Without the split, a /rename title of "x$(rm -rf ~)" would execute on the next connect — which it did, in testing, as a stray "command not found" before the shell-safe values were separated out. lib.sh carries the shared helpers so the four scripts agree on where an identity lives, how ~/.buzz/config is read (parsed, never sourced — it holds an invite code), and how a relay failure is explained. Signed-off-by: Ash Brener <ashley@starlogik.com>
Invoking the skill was a reading exercise: mint an identity, source an env file, find a channel UUID, paste it into a Monitor command, and do it again in every terminal. Each of those steps is derivable, and every one of them was a chance to get it wrong quietly. buzz-connect.sh is now the only thing anyone runs. It resolves the session name, mints or adopts the identity, loads it, enrols from an invite code if one is configured, publishes the display name, finds or creates the channel, announces HELLO, and prints the exact Monitor call to arm. It is idempotent, so re-running it is the way to check state rather than a risk. buzz-msg.sh send/read and buzz-watch.sh load the identity themselves — nobody is told to source anything by hand, and "buzz-watch.sh - <channel>" resolves the session so the Monitor command stays correct across a /rename. Profile publication is part of connecting and refreshes on rename: the published name is recorded in the identity's .meta sidecar and compared each run, so a session renamed mid-flight republishes and keeps its keypair. Live against a relay with membership enforcement, a session renamed from "Auth Refactor A" to "Auth Refactor A v2" republished and kept pubkey 492d3f89. Authorising a new pubkey on a closed relay is the one step that cannot be derived, so it is handled rather than papered over. An invite code in ~/.buzz/config — read by every session on the machine, parsed rather than sourced, warned about if it is group-readable — lets each session self-enrol via block#4479's `buzz invites claim`. Only with no code does the skill surface the pubkey and make a single, exact ask. The channel UUID is written back to ~/.buzz/config on creation. Without that, the second session cannot see the first one's private channel — a non-member gets no rows from `channels list` — and silently creates a second "agent-coordination" that nobody shares. That failure appeared in end-to-end testing and is precisely the kind of quiet divergence the skill exists to prevent. The three failures that cost real time now name themselves: not a relay member, a relay member but not a channel member, and a watcher that was never armed. Each prints the command that fixes it. `--status` exits non-zero when the watcher is down, so "connected but deaf" is checkable. The watcher writes a liveness marker keyed on the session id, because otherwise "not armed" and "channel is quiet" are indistinguishable. Two smaller fixes found by running it: `git rev-parse --abbrev-ref HEAD` prints "HEAD" *and* fails on an unborn branch, which put "branch=HEAD no-git" into HELLO; and the relay's "no community is configured for this host" 404 was being reported as "cannot reach the relay", so the relay's own words are now always included. Signed-off-by: Ash Brener <ashley@starlogik.com>
Three fixes from running the skill against a real relay. `~/.buzz/config` now outranks `PATH` when resolving `buzz` and `buzz-admin`. A configured path is a decision; `PATH` is ambient, and on a machine with Buzz Desktop installed it resolves to the app's bundled CLI, which lags the relay. The shadowing is invisible and the failure is wrong-but-plausible: the script reports a feature missing, which is true of the binary it picked and false of the one the user configured. Enrolment is now one ask with one answer. `buzz-connect.sh --invite` takes whatever "Invite to community -> Copy link" put on the clipboard — the whole URL or a bare code — saves it, and enrols; every later session on the machine self-enrols from it. `diagnose_relay` prints the exact sentence to say and nothing else. It no longer offers `buzz-admin add-member`: that writes to the relay's Postgres directly, so it is inert anywhere but the relay host, and a menu of routes is a worse answer than one instruction. The published profile is prefixed `Claude Code (<session>)`. In a channel listing a bare session slug is indistinguishable from a human; the prefix says what kind of member it is and which terminal to go find. Signed-off-by: Ash Brener <ashley@starlogik.com>
… topic Two manual steps removed from connecting a session. **Channel membership is granted automatically when the machine holds the owner's key.** Relay membership and channel membership are separate gates, and only a channel's owner can open the second one, so a human was running `buzz channels add-member` by hand for every new session — four times now. Every session on a machine mints its identity into `~/.buzz/sessions`, so the key that created the channel is almost always right there; asking a human to run the command is asking them to relay a decision they already made. The owner is found the only way the relay permits. A non-member cannot see a private channel at all — `channels members` returns `[]` with exit 0 and `channels get` returns `null` — so the blocked session cannot read the member list and look the owner up. Each key in `~/.buzz/sessions` is asked in turn whether the relay reports it as this channel's owner, and that key then runs the grant in a subshell so the caller's identity is never replaced. The safety model is three rules: only keys already in `~/.buzz/sessions`; only `channels add-member --role member` on the channel being joined, both literals in `join_channel`; and every use prints the identity, the owner pubkey, the file the key came from, and the command run under it. A privilege action that leaves no trace in the output is unacceptable. `BUZZ_AUTO_ADMIT=0` skips the search and falls back to the single ask, unchanged. Relay membership deliberately does not work this way, though a local owner/admin key could mint an invite. Channel membership is one room and one scoped grant; relay membership is the whole community, and the artefact is a bearer token that outlives the action and sits in a config file where anything that can read it can join. `--invite` already reduces that to one paste, and the human should stay the one who authorises it. **Dedicated channels now hold.** `BUZZ_COORD_CHANNEL` was a single slot, so joining a second named channel overwrote the first and the sessions still pointing at the old UUID went quiet with no error at all. The UUID cache is now one key per name — `BUZZ_CHANNEL_<NAME>`, with the default keeping the existing key — and a found channel is cached, not only a created one. `buzz-connect.sh --channel <name>` is the documented way to open a room for a piece of work: it joins or creates, admits the session as above, and pins the room to that session's `.meta`, so a bare `buzz-msg.sh send` afterwards posts there rather than to the machine's default. The pin is per session, so one worktree can sit in `pp-refactor` while another stays in `agent-coordination`. Signed-off-by: Ash Brener <ashley@starlogik.com>
|
Filed #4570 describing the problem and the approach — this PR should be read against that. Opening the PR before the issue was the wrong order per CONTRIBUTING, and the issue says so.
It fails on this machine, on one test, and the failure is not from this branch: Evidence it is pre-existing and load-dependent:
The cause looks structural rather than environmental: the test is Everything this branch touches is green: |
Everything was a flag on buzz-connect.sh, and nothing tore anything down. A session could only accumulate: arm a watcher, join rooms, become a permanent relay member, then vanish when its terminal closed, leaving all three behind. Five verbs — connect, join, status, leave, disconnect. Every existing flag still works and nothing was renamed: --status is status, --channel <name> is join <name>. They live on buzz-connect.sh rather than in a dispatcher because all five need the same first three steps, and those steps are this script. leave and disconnect do only the two unambiguous things: post DONE while still a channel member, because after `channels leave` the relay refuses the send, and print the exact TaskStop call — the watcher is a Claude Code Monitor, so a shell script cannot kill it. Then the room pin is cleared, so a bare `buzz-msg.sh send` stops posting into a room the session has left. The two destructive actions are opt-in, because each is right in one case and wrong in the other. --leave-channel is right for finished work and wrong for a session that reconnects tomorrow: on a private channel it is not self-reversible (`channels join` is refused, some remaining member has to re-add the pubkey), and a session that opened its own room owns it and cannot leave at all, which the script now names instead of passing on "cannot remove the last owner". --retire is right for a throwaway worktree and wrong for anything resumable, and it is never implicit — leave refuses the flag, and it only ever targets the running session's own pubkey. --retire says plainly what archiving is not. NIP-IA kind:9035 adds one row and republishes the kind:13535 snapshot; it does not stop the key reading, writing or connecting, does not hide anything already published, and does not touch relay or channel membership. `agents unarchive` is a clean inverse of the state — the archive is that row — but not of the record: both requests are stored, publicly readable events. status now reports rather than acts. It will not create a channel, and it will not re-admit a session that has just left one, which would have made leave look like it had silently failed. status --all lists every identity on the machine, whether the relay still counts it as a member, and whether anything is listening for it, because relay_members has no TTL and identities accumulate silently. It prunes nothing: an identity with no watcher is usually a session between runs. It does surface the three states worth acting on — an identity no session ever adopted, a watcher still polling a room its session left, and an archived identity that can still write. Every teardown ends by printing what remains, including that relay membership does not go anywhere. There is no path out of it from here: the relay implements a self-service leave (NIP-43 kind:28936) but no client builds that event, the admin remove refuses self-removal, and buzz-admin remove-member writes to the relay's Postgres directly. Signed-off-by: Ash Brener <ashley@starlogik.com>
…cache that knows its relay Three things, all downstream of the verbs landing. **A skill per verb.** Verbs on one script are correct and undiscoverable: the slash menu lists skill names and there is no completion into a skill's arguments, so a user who sees buzz-multi-session cannot learn that `leave` exists. A session that cannot be told to disconnect never disconnects, which is how watchers and identities accumulate. buzz-connect, buzz-join, buzz-status, buzz-leave, buzz-disconnect and buzz-agent-provision are thin siblings whose frontmatter description is the whole discoverability surface. They carry no logic and no duplicated prose — one document, one scripts directory, still. They are directories with their own SKILL.md rather than symlinks because a skill's identity is its name and description, and six symlinks to one file would be six skills with the same name. **buzz-agent-provision.sh.** buzz-acp runs goose, codex, claude-agent-acp and hermes, and assumes its identity is already a relay member and already a channel member — it never claims an invite, publishes a profile or joins anything, and given none of them boots to "agent will sit idle". That is the same keypair, enrolment, profile and membership buzz-connect.sh already does, so it reuses ensure_relay_membership, publish_profile, resolve_channel and join_channel rather than reimplementing them; ensure_relay_membership and publish_profile were lifted out of buzz-connect.sh for the purpose. It prints the env block for a Dockerfile or a fly secret, and never the private key — only its path and two ways to load it that keep it out of a terminal, a log, a history and ps. The identity is deliberately not bound to the session that created it. buzz-session.sh recorded CLAUDE_CODE_SESSION_ID even for an explicit name, which meant a /rename in the terminal that provisioned an agent renamed the daemon's key out from under it. Fixed at the source; the caller also drops the variable. Ownership is the part that cannot be automated, so it is stated rather than papered over. An unowned agent is not a formality: --respond-to defaults to owner-only, so it connects and ignores everyone. --auth-tag takes a real NIP-OA attestation, which only the owner's secret key can mint; --owner records a pubkey and says plainly that it is not the same thing. And the finding that matters: a key that enrols itself can never have an owner recorded. The relay materialises users.agent_owner_pubkey only on the ViaOwner path, so a direct member's membership check short-circuits before the attestation is read, on both the HTTP submit and the WS AUTH. --auth-tag therefore does not claim an invite. BUZZ_AUTH_TAG is also unset inside _as_identity, or auto-admit under an owner key would present another identity's attestation and hard-fail. **Relay-scoped caches.** A channel UUID is structurally valid on any relay, so pointing BUZZ_RELAY_URL somewhere new made every session resolve the old relay's UUID and post into a channel that does not exist, silently. Cache keys now carry the relay — scoped rather than invalidated, because verifying on every resolve costs a round trip on the path buzz-msg.sh takes for every send, and invalidating would make switching back create a duplicate instead of finding the original room. Unscoped keys are still read and adopted on first use: silently when channels get proves the channel is here, announced when it cannot, because a private channel this identity is not in looks the same as one somewhere else. The room pin records its relay and is dropped with a message when that changes, and a failed invite claim says when the code may belong to a relay you left. Worse than the cache: the relay recorded in an identity file outranked ~/.buzz/config, so editing the relay there did nothing at all for any existing identity and every session kept talking to the relay it was born on. The mint record no longer outranks configuration — environment, then config, then the record — and a mismatch says that the keypair carries over but membership does not. The record itself is left alone, so switching back needs no repair. Signed-off-by: Ash Brener <ashley@starlogik.com>
…en the watcher dies
The watcher slept 5 seconds between REST reads, so a peer's message took up to
5s to wake a session. It also *was* the fetch: when a Monitor-hosted watcher
died — three did, with exit 144, where the same command under nohup stayed
healthy — the messages were never fetched at all and were simply gone.
Two changes, and the second matters more.
buzz messages subscribe
A new CLI verb: hold a NIP-42-authenticated WebSocket open and print one
JSON object per line as the relay pushes it. buzz-ws-client already had
connect/auth/send_raw/next_event but no REQ helper and no caller that
streams, so BuzzClient gains subscribe_events() beside publish_ephemeral_event
and keeps the keys private.
It never returns Ok: every exit is a reason the stream stopped, because a
reader that cannot tell a quiet channel from a dead socket is worse than a
poller. --idle-timeout clears the relay's 30s heartbeat, so silence past it
is a dead socket rather than a quiet room. --reconnect-after ends a healthy
stream on a schedule so a supervisor can backfill over HTTP — a subscription
the relay has quietly stopped matching is silent and still heartbeating, and
nothing else would ever notice.
Receiving split from waking
buzz-stream.sh is a per-identity, per-channel daemon outside Monitor. It owns
the relay connection, the dedupe and the filter, and appends notifications to
~/.buzz/stream/<id>.<chan>.log. buzz-watch.sh — all Monitor runs — is now just
tail -F on that log from a stored line offset. Its interface is unchanged, so
a Monitor command recorded before this still works.
A Monitor death now costs the wake, not the messages: they keep landing in the
log and re-arming replays them, in order, once. Verified by SIGKILLing a
watcher, posting three messages, and re-arming: all three delivered.
Guardrails, because silent deafness beats slow delivery only in the sense that
neither is acceptable: the receiver heartbeats every 15s so a wedged one is
distinguishable from a working one; status reports live/stale/dead/none,
restarts a dead receiver, counts queued messages and exits 1 unarmed, 6 with
no receiver; every send and read warns first and continues. Receiver stderr
is kept in .err across restarts — Monitor reaps its own task output, which is
why three deaths produced no diagnosis.
Measured against a local relay, send to notification: push 35-90ms, poll
326-4541ms. A CLI with no subscribe verb falls back to polling with no error —
latency is a nicety, hearing your peers is not.
Three defects found by testing the failure rather than the happy path:
- an orphaned tail kept advancing the offset after its watcher was SIGKILLed,
so re-arming skipped messages that were never delivered. The delivery loop
now runs in the watcher's own shell and the offset advances only after the
line is out.
- an orphaned heartbeat kept stamping liveness for a dead pid, so disconnect
reported "not running" and left a receiver behind. The pidfile is now
authoritative for identity, the heartbeat only for health.
- a SIGKILLed receiver orphaned its stream job, leaving a second WebSocket
appending duplicates. Its process group is recorded and reaped by whoever
starts next.
On exit 144: bash ignores SIGURG by default, verified on Darwin 25, so a bare
SIGURG cannot produce it. No mechanism is claimed. Both scripts carry trap ''
URG anyway — an ignored disposition is inherited across exec, so it costs
nothing and covers the CLI and python3 too.
Signed-off-by: Ash Brener <ashley@starlogik.com>
143 is 128+15: someone stopped it, which is normal. 144 is 128+16 and means it died on its own, which is a bug worth reporting with the receiver's .err file. The distinction was earned rather than assumed — a live Monitor watcher was deliberately SIGTERMed and reported 143, which is what rules out "the harness reaped it" and "someone stopped it" as explanations for the 144s seen in the field. Two lines here save the next person that hour. Either way the response is identical, which is the point of splitting reception from waking: run status, re-arm, nothing was lost. Signed-off-by: Ash Brener <ashley@starlogik.com>
This PR is a skill; it should not also be shipping a new buzz-cli verb. The subscribe work now lives on feat/cli-messages-subscribe, where it can be reviewed as the CLI change it is, and where it stops colliding with the invites work in block#4479 — both were adding methods to the same regions of client.rs and lib.rs, so whichever merged first would have broken the other. Nothing here regresses. buzz-stream.sh already probes for the verb (`messages subscribe --help`) and falls back to the HTTP sweep when it is absent, which is the polling loop this skill has always used. With the CLI change merged the same skill gets push delivery for free; without it, it behaves exactly as it did before. Signed-off-by: Ash Brener <ashley@starlogik.com>
Tracked by #4570. Depends on #4479 (
invites claim) and #4674 (messages subscribe) — the skill falls back to HTTP polling without the latter.Adds a Claude Code skill that lets several independent agent sessions — typically three git worktrees of one repo — coordinate through a Buzz channel instead of a human copy-pasting between terminals.
Depends on #4479 (
buzz invites mint|claim) for self-enrolment. Everything else works against the CLI as it stands today; without #4479 the skill asks the user once for an invite link and saves it.What using it looks like
Three worktrees of one repo, three terminals, each
/renamed so the Buzz memberis a terminal a human can go and find.
Six skills rather than one with verbs, because the slash menu lists skill
names and there is no completion into a skill's arguments — a verb nobody has
been told about is a verb nobody finds. They share one implementation; the
sibling
SKILL.mdfiles exist for discoverability and delegate to the samescripts.
/buzz-connect/buzz-join <name>/buzz-status [--all]--allaudits every identity on the machine/buzz-leave/buzz-disconnect [--retire]--retirearchives the identity too/buzz-agent-provision <name>buzz-acpharnessMessages are pushed, and they arrive even when the watcher dies
The watcher slept 5 seconds between REST reads, so a peer's message took up to 5s
to wake a session — and the same loop was the fetch, so a watcher that died
meant the messages were never fetched at all.
buzz messages subscribe— a streaming read for the CLIbuzz-ws-clientalready had connect, NIP-42 auth,send_rawandnext_event,but no REQ helper and no caller that streams;
buzz-clihad no watch, subscribe,stream or tail verb. So
BuzzClientgainssubscribe_events()besidepublish_ephemeral_event()— same crate, same keys, still private — and one newsubcommand prints one JSON object per line as the relay pushes it.
It never returns
Ok. Every exit is a reason the stream stopped, because areader that cannot tell a quiet channel from a dead socket is worse than a
poller: it believes it is listening.
--idle-timeout(90s) clears the relay's 30s heartbeat, so silence past it isa dead socket and not a quiet room.
--reconnect-after(300s) ends a healthy stream on a schedule so asupervisor can backfill over HTTP. A subscription the relay has quietly
stopped matching against is silent and still heartbeating; nothing else would
ever notice.
Receiving is split from waking
Three Monitor-hosted watchers died in the field with exit 144 while the same
command under plain
nohupstayed healthy on the same channel, and Monitor reapsa task's output before anyone can read it, so all three produced no diagnosis.
Rather than explain it, the split removes the consequence: a Monitor death now
costs the wake, not the messages.
buzz-watch.shkeeps its exact interface — same three arguments, sameone-line-per-message output — so a
Monitor(...)recorded before this stillworks after it.
Tested by killing the Monitor side specifically:
Guardrails, because silent deafness is the real failure
running process, so a pid check alone would call it healthy;
statusreportslive/stale/dead/none.statusrestarts a dead receiver, counts queued messages, and exits 1when the watcher is unarmed, 6 when nothing is receiving.
buzz-msg.sh sendandreadboth check first and warn. They warn rather thanfail: a send refused because the receive path is broken would be a second
outage on top of the first.
<id>.<chan>.erracross restarts — the post-mortemMonitor's own output never was.
leaveanddisconnectstop the receiver, which is an ordinary process andreally is stopped rather than described.
Measured, not asserted
Local relay, same messages,
sendto a notification line out of the Monitorcommand:
A CLI with no
subscribeverb falls back to polling with no error — latency is anicety, hearing your peers is not. HTTP reads remain as the sweep before every
stream and after every one that ends.
Three defects found by testing the failure, not the happy path
tailkept advancing the offset after its watcher wasSIGKILLed, so re-arming skipped messages that had never been delivered —
silent loss caused by the code meant to prevent it. The delivery loop now runs
in the watcher's own shell (which dies with it) and the offset advances only
after the line is written out.
disconnectreported "not running" and left a live receiver behind. Thepidfile is now authoritative for identity, the heartbeat only for health.
authenticated WebSocket appending duplicates to the same log. Its process group
is recorded and reaped by whoever starts next.
On exit 144
Not chased, and deliberately not theorised about. Two cheap explanations are
ruled out: a deliberate SIGTERM of a live watcher reports 143, so the harness
is not simply reaping them; and bash ignores SIGURG by default (verified on
Darwin 25), so a bare SIGURG cannot produce 144 either. Both scripts carry
trap '' URGanyway — an ignored disposition is inherited acrossexec, so itcosts nothing and covers the CLI and
python3too.SKILL.mddocuments 143 as"someone stopped it" and 144 as "report it, with the
.errfile".The two gates
Relay membership and channel membership are separate, and the second failure is
silent:
[]looks exactly like nobody having said anything. Both gates arenamed rather than surfaced as a 403, and the second is crossed automatically
when the machine already holds the owner's key.
Why
Parallel agent sessions are siloed. There is no shared state, so a human relays context by hand. Buzz already has the substrate — signed events, channels, per-identity membership — and Claude Code has a
Monitorprimitive that turns each new channel message into a wake-up for a running session. This wires the two together.Invoking the skill is the whole setup
buzz-connect.shis the only thing anyone runs. It resolves the session's name, mints or adopts its identity, loads it, enrols from an invite code if one is configured, publishes the display name, finds or creates the channel, gets the session into it, announcesHELLO, and prints the exactMonitor(...)call to arm. It is idempotent, so re-running it is how you check state rather than a risk.buzz-msg.sh send|readandbuzz-watch.shload the identity themselves; nobody is told to source an env file or paste a channel UUID.A human is asked for exactly two things, and only when nothing on the machine can supply them: relay enrolment with no invite code available, and channel membership for a channel no local key owns.
The identity is the session, and the name follows
/renameA message in the channel has to be attributable to a session the user can actually go and find, so the Buzz member is the session rather than the directory it happens to sit in.
Claude Code exports
CLAUDE_CODE_SESSION_IDand writes the transcript to~/.claude/projects/<cwd with / and . replaced by ->/<session-id>.jsonl, where the title set by/renameappears ascustomTitle— last occurrence wins, because/renamecan be re-run. That is tier 1. It falls back to the worktree directory, thensession-<first 8 of the session id>, thensession-<first 8 of sha256(cwd)>. It never fails and never returns an empty name: a session with no/rename, no session id and no git repo still gets a stable, per-directory identity.The identity follows a rename rather than forking. The session id is stored alongside the keypair, so a later run finds the existing identity under its old name and renames the file, then republishes the profile. A
/renameis not a new member. The published name is prefixedClaude Code (<session>), because in a channel listing a bare session slug is indistinguishable from a human.Sanitisation matters because a title is free text. The slug is lowercased, reduced to
[a-z0-9._-], collapsed, stripped of leading and trailing-._and capped at 64 characters, because it becomes~/.buzz/sessions/<slug>.env:/can only ever become-, and a leading..is stripped, so a title cannot escape the directory. The display name keeps the original characters, emoji included, with control and format characters (bidi overrides among them) removed.Channel membership is granted with a local owner key
Relay membership and channel membership are separate gates, and only a channel's owner can open the second one. A human was running
buzz channels add-memberby hand for every new session — four times now. Every session on a machine mints its identity into~/.buzz/sessions, so the key that created the channel is almost always right there; asking a human to run the command is asking them to relay a decision they already made.The owner is found the only way the relay permits. A non-member cannot see a private channel at all —
channels membersreturns[]with exit 0 andchannels getreturnsnull— so the blocked session cannot read the member list and look the owner up. Each key in~/.buzz/sessionsis asked in turn whether the relay reports it as this channel's owner, and that key then runs the grant in a subshell, so the caller's identity is never replaced in the process.The safety model is three rules:
~/.buzz/sessions. Nothing is minted, fetched or derived.channels add-member --role member, only on the channel being joined. The role and the verb are literals injoin_channel; the channel is the one just resolved. There is no generic run-as-another-key helper exposed above those two call sites.BUZZ_AUTO_ADMIT=0skips the owner search entirely and falls back to the single ask, which is unchanged.Relay membership deliberately does not work this way, though a local owner/admin key could mint an invite. Channel membership is one room and one scoped grant that the owner can revoke; relay membership is the whole community, and the artefact is a bearer token that outlives the action and sits in a config file where anything that can read it can join.
buzz-connect.sh --invitealready reduces that to one paste, and the human should stay the one who authorises it.Dedicated channels hold now
BUZZ_COORD_CHANNELwas a single slot. Joining a second named channel overwrote it, and the sessions still pointing at the old UUID went quiet with no error at all — the exact failure mode the write-back was introduced to prevent, reintroduced one channel later. The cache is now one key per name:BUZZ_CHANNEL_<NAME>, with the default channel keepingBUZZ_COORD_CHANNELso existing configs keep working. A channel found by name is cached too, not only one that was created.buzz-connect.sh --channel <name>is the documented way to open a room for a piece of work. It joins or creates, admits the session as above, and pins the room to that session's.meta, so a barebuzz-msg.sh sendafterwards posts there rather than to the machine's default. The pin is per session, not per machine: one worktree can sit inpp-refactorwhile another stays inagent-coordination.The skill says when to bother — a distinct piece of work with its own peers — and when not to. A channel per session is not a dedicated channel, it is silence.
Two behaviours a reviewer should check
Free text never enters a file that gets sourced. The
.envholds two hex keys and a validated relay URL and is checked against that shape before being sourced; the session id, display name and published-profile name live in a.metasidecar that is only grepped. This is not theoretical: before the split, a display name containing spaces produced a straycommand not foundon every connect, and a title ofx$(rm -rf ~)would have executed.Channel names become config keys, so they are uppercased and reduced to
[A-Z0-9_]and capped at 48 characters beforeBUZZ_CHANNEL_is prepended.config_getbuilds a regex from the key; a name likeWeird Name!/../xwould otherwise be a regex, and a path.~/.buzz/configoutranksPATHwhen resolvingbuzzandbuzz-admin. A configured path is a decision;PATHis ambient, and on a machine with Buzz Desktop installed it resolves to the app's bundled CLI, which lags the relay. The shadowing is invisible and the failure is wrong-but-plausible: the script reports a feature missing, which is true of the binary it picked and false of the one the user configured.Failures that name themselves
Three states cost real time today because they all look like an agent ignoring you. Each now prints the command that fixes it, not a bare 403:
buzz-admin add-memberis no longer offered: it writes to the relay's Postgres directly, so it is inert anywhere but the relay host, and a menu of routes is a worse answer than one instructionbuzz channels add-memberline for the ownerMonitor(...)to run;buzz-connect.sh --statusexits non-zero, so "connected but deaf" is checkablebuzz-msg.sh readon an empty channel says the same thing rather than printing nothing.Deliberate choices
.claude/skills/only, not mirrored to managed agents. The existing skills are symlinked intodesktop/src-tauri/.../managed_agents/and mirrored across.agents/.goose/.codex. This one is scoped to Claude Code because the protocol depends on theMonitortool to wake a session on a new message, and on the session transcript for the name. A managed agent has neither, so a mirrored copy would document a workflow that runtime cannot perform.Scripts live in the skill directory, not repo-root
scripts/asdesktop-screenshotdoes. They must run inside any repo's worktree, not only this one, so the skill has to be self-contained.Name resolution is its own script, not a function in
buzz-session.sh. It touches no binaries and no relay, three other scripts consume it, and it needs the opposite error discipline — every tier must be allowed to fail so the next one runs, which is incompatible with theset -ethe identity manager wants.python3for JSON, following the Justfile, rather than adding ajqdependency. The resolver has a pure-shell fallback so it still produces a name without it.Named
buzz-multi-session. Avoids thesprout-cli→buzz-clirename in #2525, and avoids "mesh", whichVISION_MESH.mdalready uses for GPU pooling.Testing
Every documented command was verified against a release build of
buzz. All six scripts passbash -nandshellcheck -xwith zero findings.The resolver was tested against a real 7 MB transcript containing a
customTitle(resolves tobuzz-init), against every fallback tier, and against hostile titles:../../etc/passwd 🚀 spaces→etc-passwd-spaces,../../..→ falls through to tier 2, pure emoji → falls through, 300 characters → capped at 64, and RTL-override/BOM/zero-width characters stripped from the display name. No output ever contained a/or was.or...End-to-end against a relay with
BUZZ_REQUIRE_RELAY_MEMBERSHIP=trueon an isolated database: two worktree sessions with different/renametitles connected, hit and recovered from both membership gates with the diagnostics above, published profiles thatbuzz users getreturned asAuth Refactor A/Auth Refactor B, exchanged messages with one session's watcher reporting the peer'sCLAIMand correctly not its ownSTATUS, and a mid-flight/renamemoved the identity and republished the profile asAuth Refactor A v2while keeping the same keypair.Auto-admit and dedicated channels were tested against a live production relay with two identities on one machine. Session A ran
--channel zz-admit-room, which created the channel and wroteBUZZ_CHANNEL_ZZ_ADMIT_ROOMwhile leavingBUZZ_COORD_CHANNELpointing at the default. Session B then ran the same command and was admitted with no human action:buzz-msg.sh sendfrom session B then reportedsent to zz-admit-room as ppwith no--channel, andreadreturned bothHELLOs and theSTATUS— the pin routes messages to the room the session is actually in.On a second channel with
BUZZ_AUTO_ADMIT=0, the same session fell back to the single ask and exited 4:With auto-admit on but the owner's key moved out of the sessions directory it printed
auto-admit: no local key owns 'zz-optout-room'and then the same ask. Cache precedence was unit-checked: bare →BUZZ_COORD_CHANNEL,--channel zz-admit-room→BUZZ_CHANNEL_ZZ_ADMIT_ROOM, a UUID passes straight through, a pinned session resolves to its own room, andBUZZ_COORD_CHANNELin the environment overrides the pin. Both test channels were deleted afterwards.Verbs, and a way to leave
Everything was a flag on
buzz-connect.sh, and nothing tore anything down. A session could only accumulate: arm a watcher, join rooms, become a permanent relay member, then vanish when its terminal closed, leaving all three behind. Six identities existed on one relay after a single evening, two of them belonging to sessions that never ran.Every existing flag still works and nothing was renamed:
--statusisstatus,--channel <name>isjoin <name>, and a bare run is stillconnect.--inviteand--quiet-helloare untouched.The verbs live on
buzz-connect.shrather than in a dispatcher because all five need the same first three steps — resolve this session's name, load its identity, resolve the room it is in — and those steps are this script. A dispatcher would either duplicate them or hand straight back here, and it would cost the skill the one sentence that makes it usable: there is a single entry point.leave / disconnect do only what is unambiguous
Four things a departing session could do. Two are unambiguous and happen by default:
Monitor, so a shell script cannot kill it. Both verbs print the exactTaskStopcall, the mirror of theMonitor(...)connect prints. The printed pid is a fallback when the task id is lost — the watcher clears its own marker onTERM— butTaskStopis what stops Claude Code tracking the task.DONEis posted first, while the session is still a channel member, because afterchannels leavethe relay refuses the send. A session that stops answering without aDONEis indistinguishable from one that is merely slow.Then the room pin is cleared, so a bare
buzz-msg.sh sendstops posting into a room the session has left.The other two are opt-in, because each is right in one case and wrong in the other:
--leave-channel--retireleaveanddisconnectdo the same two things by default. The difference is what they say and what they offer —disconnectreports what is left behind and is the only verb that accepts--retire. Inventing a deeper difference would have meant inventing a third teardown action nothing needs.--retireis never implicit.leave --retireis refused with an error rather than ignored, and it only ever targets the running session's own pubkey — there is no argument that could point it elsewhere. The relay's NIP-IA self path isactor == target, so a session can retire itself with no owner or admin involved.What
--retiresays about archiving, because it is not what it sounds likekind:9035 adds one row to
archived_identitiesand republishes the kind:13535 snapshot. It does not block the key from reading, writing or connecting, does not hide anything already published, does not touch relay membership, and does not remove the identity from any channel. Verified against the live relay: an archived identity posted a message immediately after being archived, and the relay accepted it. Archival is a signal to readers, not a lock, and--retiresays so before it acts.buzz agents unarchiveis a clean inverse of the state — the archive is that one row, nothing else was mutated, so nothing else needs restoring. It is not a clean inverse of the record: both the 9035 and the 9036 are stored, publicly readable events, the row's reason and timestamp are destroyed rather than rolled back, and re-archiving later silently keeps the first reason and publishes no new delta.--leave-channel, and the case that always happensOn a private channel this is not self-reversible:
channels joinis refused withrestricted: channel is private, and some remaining member has to re-add the pubkey — any member, not only the owner.buzz-connect.sh join <name>does it with no human involved when the owner's key is in~/.buzz/sessions.A session that opened its own room owns it, and an owner cannot leave —
cannot remove the last owner, because an ownerless private room can never admit anyone again. That is the normal outcome ofjoin <name>followed bydisconnect --leave-channel, not an edge case, so it is named with the two real options rather than passed through as a relay error.status reports; it never acts
statuswill not create a channel and will not re-admit a session that has just left one. It exits 1 when the watcher is not armed, 4 when the session is not a channel member, and 2 when it is in no room at all. Before this change, astatuscall after aleavewould have silently rejoined the channel, makingleavelook like it had failed.Roster hygiene
status --alllists every identity on the machine, asks the relay whether it is still a member (one call each, which is why it is on request), and says whether anything is listening. On the machine that prompted this:Four identities no Claude Code session ever adopted, all still relay members, all holding keys that can authorise a channel admit. One watcher still polling a room its session left, costing a relay call every five seconds and waking nobody. It prunes nothing — an identity with no watcher is usually a session between runs, and the script cannot tell the difference. Surfacing it is the point.
Relay membership survives all of it
relay_membershas no TTL, no expiry and no last-seen column. Nothing in this skill removes a row, anddisconnect --retiredoes not either. What I found looking for a path:buzz-core's kind table and nowhere else: not inbuzz, not inbuzz-sdk, not in Desktop, not in the web app. The capability is real and unreachable.buzz-admin remove-memberwrites to the relay's Postgres directly, so it does nothing unless the operator runs it on the relay host.So every teardown ends by printing what remains, including that membership does not go anywhere. Claiming otherwise is how six identities accumulated.
Testing
All scripts pass
bash -nandshellcheck -xwith zero findings.End-to-end against the live production relay with two throwaway identities in a throwaway channel:
leaveis seen by the peer. Session B ranleave --leave-channel; session A's armed watcher emitted[buzz] 82fa439a: DONE Claude Code (zz-tear-b): leaving zz-teardown-room. B's own watcher emitted nothing, because a session never reacts to itself.channels leave, B's ownchannels joinwas refused withrestricted: channel is private;buzz-connect.sh join zz-teardown-roomre-admitted it with A's owner key and no human action.statusdoes not undo a leave.statusafter the leave reportedchannel : none — this session is not in a roomand exited 2;status --channel zz-teardown-roomreportedNOT a memberand exited 4. Neither rejoined.--retiredoes what it says and nothing more.agents archivedwent from{"archived":[]}to containing the pubkey; the archived identity then sent a message that the relay accepted;agents unarchivereturned the snapshot to[].cannot remove the last ownerand the two real options.statusreportedNOT ARMEDand exited 1.Both throwaway identities were archived and their channel deleted. Their relay membership persists, because — as above — nothing can remove it from here.
A skill per verb, because the menu lists names
Verbs on one script are correct and undiscoverable. Claude Code's
/menu lists skill names and there is no completion into a skill's arguments, so a user who seesbuzz-multi-sessionhas no way to learn thatleaveexists. A session that cannot be told to disconnect never disconnects — which is how the watchers and identities above accumulated.So each verb has a thin sibling skill:
buzz-connect,buzz-join,buzz-status,buzz-leave,buzz-disconnect,buzz-agent-provision. Each sibling's frontmatterdescriptionis the whole discoverability surface, written for a human scanning a list. They carry no logic and no duplicated prose — oneSKILL.mdnaming the one command and pointing at the umbrella, which remains the only copy of the documentation, next to the only copy of the scripts.They are directories with their own
SKILL.mdrather than symlinks to the umbrella, because a skill's identity is its frontmatternameanddescription: six symlinks to one file would be six skills with the same name and the same description, which is precisely the problem being fixed.sprout-clianddesktop-screenshotare single symlinked files with no scripts and no siblings, so there was no existing pattern to follow.This is not a menu of ways to solve a blocker. A human picks a verb from the slash menu; no agent deliberates over which to try.
buzz-agent-provision.sh— an identity for a hosted agentbuzz-acpruns goose, codex,claude-agent-acpand hermes against a relay, and assumes its identity is already a relay member and already a channel member. It never claims an invite, never publishes a profile and never joins a channel;BUZZ_ACP_CHANNELSonly narrows channels it has already discovered from its own membership events, so a UUID it is not a member of is dropped silently and the agent boots tono channel subscriptions resolved — agent will sit idle. That gap is the same keypair/enrolment/profile/membership sequencebuzz-connect.shalready performs, so it reusesensure_relay_membership,publish_profile,resolve_channelandjoin_channel— the first two lifted out ofbuzz-connect.shfor the purpose — and ends in the env block for a Dockerfile, a fly secret or a systemd unit.The private key is never printed, only its path and two ways to load it that keep it out of a terminal, a log, a shell history and
ps.--commandis not validated against a list because buzz-acp has none: it normalises the command to an identity and looks up default arguments.goosegetsacp;codex,codex-acp,claude-agent-acp,claude-code-acp,claude-code,claudecodeandbuzz-agentget none. Everything else —hermesincluded — gets no defaults, and the built-in default forBUZZ_ACP_AGENT_ARGSis the literal stringacp, so an unrecognised harness is launched as<cmd> acp. The env block sets it explicitly and says why.Fixed at the source:
buzz-session.shrecordedCLAUDE_CODE_SESSION_IDeven for an explicit name, so a/renamein the terminal that provisioned an agent would have renamed the daemon's identity out from under it.Ownership, and a gap with no bridge
A self-enrolled agent has no owner, and that costs more than it sounds like: buzz-acp's
--respond-todefaults toowner-only, so an unowned agent connects and ignores everyone;agents draft-create/draft-updatefail with exit 3 and have no--ownerflag; agent turn metrics are rejected.buzz memis not what breaks — every subcommand takes--owner <hex>and the relay gates engrams on author-or-p.Only the owner's secret key can mint a NIP-OA attestation, and there is no CLI command, relay endpoint or event kind that registers ownership — the one shipped tool is
buzz-sdk'scompute_auth_tagexample. So--auth-taguses a real attestation,--ownerrecords a pubkey and says plainly it is not the same thing, and neither prints the full cost rather than leaving it to be discovered.The finding worth reviewing: enrolling makes ownership unrecordable. The relay writes
users.agent_owner_pubkeyonly on theViaOwnerpath — a key that is not a direct member, admitted because its owner is one. A direct member's check returnsMemberand short-circuits before the attestation is looked at, on both the HTTP event submit and the NIP-42 WS AUTH. Since relay membership has no self-service exit, a key that claimed an invite can never acquire an owner.--auth-tagtherefore does not claim an invite, and says so.Demonstrated on the live relay with one tag mechanism and two keys: the self-enrolled attested agent got
restricted: observer frame is not authorized for this agent owner; the owner-admitted attested agent got{"accepted":true,...,"saved":false}.BUZZ_AUTH_TAGis also unset inside_as_identity— otherwise auto-admit under a local owner key would present another identity's attestation, which the CLI verifies against its own pubkey and rejects.Why it is not mirrored to the other runtimes
The
.agents/,.goose/and.codex/symlinks all point atdesktop/src-tauri/src/managed_agents/<name>_skill.md, whichnest.rs:44include_str!s into Buzz Desktop and installs for every managed agent. The mirror is a shipping channel, not a doc mirror, and mirroring this would ship "mint a keypair and enrol it" to agents that already have an identity Desktop minted and owns. It would also have to splitlib.sh, and there is no precedent for a skill that depends on another's scripts.Changing relays no longer breaks silently
BUZZ_COORD_CHANNELandBUZZ_CHANNEL_<NAME>were bare UUIDs with no relay in the key, and a UUID is structurally valid on any relay — so pointingBUZZ_RELAY_URLsomewhere new made every session resolve the previous relay's UUID and post into a channel that does not exist there, with no error.Cache keys now carry the relay:
BUZZ_COORD_CHANNEL__<RELAY>,BUZZ_CHANNEL_<NAME>__<RELAY>,BUZZ_INVITE_CODE__<RELAY>, where<RELAY>is the host plus eight characters of its hash.Scoped rather than invalidated or verified, for three reasons: verifying a cached UUID on every resolve costs a relay round trip on the hot path, and
buzz-msg.shresolves on every send; invalidating discards the old value, so switching back to the first relay creates a duplicate channel instead of finding the original room; and keys that cannot collide beat detecting a collision afterwards.Unscoped keys are still read and adopted on first use — silently when
channels getproves the channel is on this relay, announced when it cannot, because a private channel this identity is not in is indistinguishable from one that is somewhere else. The session's room pin records its relay and is dropped with a message when that changes. A failed invite claim says when the code came from the unscoped key and may belong to a relay you have switched away from.Worse than the cache, and also fixed: the
BUZZ_RELAY_URLrecorded in an identity file outranked~/.buzz/config, so editing the relay in the config did nothing at all for any existing identity and every session silently kept talking to the relay it was born on. The mint record no longer outranks configuration — environment, then config, then the record — and when they differ the run says that the keypair carries over but relay membership does not. The record is left alone, so switching back needs no repair.Tested across two relays (a local relay and the production one): creating a room on A wrote a scoped key; switching to B dropped the pin with a message, warned that the identity was minted elsewhere, and resolved a separate key rather than reusing A's UUID; switching back to A resolved the original UUID and created nothing.
statusnow prints the relay a channel belongs to and the cache key it came from.Self-removal from relay membership: what I found
Read from the relay source rather than by attempting the event:
kind:28936, which removes the sender's ownrelay_membersrow — but no client builds that event. It appears in the relay's ingest handler and inbuzz-core's kind table and nowhere else: not inbuzz, not inbuzz-sdk, not in Desktop, not in the web app. Real, and unreachable.kind:9031) explicitly refuses self-removal.buzz-admin remove-memberwrites to Postgres directly. Confirmed empirically: it worked against the local relay's database and does nothing from a machine that is not the relay host.relay_membershas no TTL, expiry or last-seen column.So
disconnect --retirecannot drop relay membership, and every teardown says so instead of implying the session has been erased.