Skip to content

feat(skills): add buzz-multi-session skill for cross-session coordination - #4481

Open
ashbrener wants to merge 11 commits into
block:mainfrom
ashbrener:feat/skills-buzz-multi-session
Open

feat(skills): add buzz-multi-session skill for cross-session coordination#4481
ashbrener wants to merge 11 commits into
block:mainfrom
ashbrener:feat/skills-buzz-multi-session

Conversation

@ashbrener

@ashbrener ashbrener commented Aug 3, 2026

Copy link
Copy Markdown

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 member
is a terminal a human can go and find.

wt-a $ /buzz-connect
       session  : Claude Code (auth-refactor-a)
       relay    : enrolled from the configured invite code
       channel  : agent-coordination
       watcher  : armed

wt-b $ /buzz-join auth-refactor
       auto-admit: 'auth-refactor-a' owns this channel and its key is on this machine.
                   authorised by the local identity 'auth-refactor-a'
       channel  : auth-refactor (joined)

wt-b $ CLAIM crates/buzz-auth/**
wt-a   [buzz] 7d98c4ef: CLAIM crates/buzz-auth/**    <- wakes; leaves those paths alone
wt-a $ ASK does the NIP-42 challenge expire before or after the membership check?
wt-b   [buzz] 3f0a12a5: ASK does the NIP-42 challenge …
wt-b $ ANSWER before — auth.rs:141, the gate runs after
wt-b $ /buzz-disconnect
       DONE posted; TaskStop printed; identity and membership kept, so a
       reconnect is free

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.md files exist for discoverability and delegate to the same
scripts.

/buzz-connect join the shared channel, arm the watcher
/buzz-join <name> a room for one piece of work; creates it if absent
/buzz-status [--all] is anything receiving? is the watcher armed? --all audits every identity on the machine
/buzz-leave done with the room, not the session
/buzz-disconnect [--retire] done entirely; --retire archives the identity too
/buzz-agent-provision <name> identity + env block for a buzz-acp harness

Messages 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 CLI

buzz-ws-client already had connect, NIP-42 auth, send_raw and next_event,
but no REQ helper and no caller that streams; buzz-cli had no watch, subscribe,
stream or tail verb. So BuzzClient gains subscribe_events() beside
publish_ephemeral_event() — same crate, same keys, still private — and one new
subcommand prints one JSON object per line as the relay pushes it.

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: it believes it is listening.

  • --idle-timeout (90s) clears the relay's 30s heartbeat, so silence past it is
    a dead socket and not a quiet room.
  • --reconnect-after (300s) ends a healthy stream on a schedule so a
    supervisor 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

relay --wss--> buzz-stream.sh --appends--> ~/.buzz/stream/<id>.<chan>.log
               (the receiver,                       ^
                outside Monitor)                    | tail -F from a stored offset
                                              buzz-watch.sh
                                              (the wake, under Monitor)

Three Monitor-hosted watchers died in the field with exit 144 while the same
command under plain nohup stayed healthy on the same channel, and Monitor reaps
a 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.sh keeps its exact interface — same three arguments, same
one-line-per-message output — so a Monitor(...) recorded before this still
works after it.

Tested by killing the Monitor side specifically:

watcher(Monitor side)=32933   receiver(outside Monitor)=32590
live delivery: [buzz] 0d723eac: before-outage

>>> kill ONLY the Monitor side (pid 32933)
receiver still alive? YES
log contents while nothing was armed:
     1  [buzz] 0d723eac: before-outage
     2  [buzz] 0d723eac: DURING-OUTAGE-1
     3  [buzz] 0d723eac: DURING-OUTAGE-2
     4  [buzz] 0d723eac: DURING-OUTAGE-3
offset (must not have advanced past 1): 1

>>> RE-ARM
delivered on re-arm:
[buzz] 0d723eac: DURING-OUTAGE-1
[buzz] 0d723eac: DURING-OUTAGE-2
[buzz] 0d723eac: DURING-OUTAGE-3

Guardrails, because silent deafness is the real failure

  • The receiver heartbeats every 15s. A receiver wedged on a socket is still a
    running process, so a pid check alone would call it healthy; status reports
    live / stale / dead / none.
  • status restarts a dead receiver, counts queued messages, and exits 1
    when the watcher is unarmed, 6 when nothing is receiving.
  • buzz-msg.sh send and read both check first and warn. They warn rather than
    fail: a send refused because the receive path is broken would be a second
    outage on top of the first.
  • Receiver stderr is kept in <id>.<chan>.err across restarts — the post-mortem
    Monitor's own output never was.
  • leave and disconnect stop the receiver, which is an ordinary process and
    really is stopped rather than described.

Measured, not asserted

Local relay, same messages, send to a notification line out of the Monitor
command:

push poll (5s)
median 44 ms 2.0 s
worst observed 90 ms 4.5 s

A CLI with no subscribe verb falls back to polling with no error — latency is a
nicety, 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

  • An orphaned tail kept advancing the offset after its watcher was
    SIGKILLed, 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.
  • An orphaned heartbeat kept stamping liveness for a dead pid, so
    disconnect reported "not running" and left a live receiver behind. The
    pidfile is now authoritative for identity, the heartbeat only for health.
  • A SIGKILLed receiver orphaned its stream job, leaving a second
    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 '' URG anyway — an ignored disposition is inherited across exec, so it
costs nothing and covers the CLI and python3 too. SKILL.md documents 143 as
"someone stopped it" and 144 as "report it, with the .err file".

The two gates

sequenceDiagram
  participant S as session
  participant R as relay
  S->>R: authenticated read
  R-->>S: 403 relay_membership_required
  S->>R: invites claim (code from ~/.buzz/config)
  R-->>S: joined
  S->>R: read the channel
  R-->>S: [] — a relay member, not a channel member
  Note over S,R: indistinguishable from an empty room
  S->>R: channels add-member, signed with a locally-held owner key
  R-->>S: admitted
Loading

Relay membership and channel membership are separate, and the second failure is
silent: [] looks exactly like nobody having said anything. Both gates are
named 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 Monitor primitive 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.sh is 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, announces HELLO, and prints the exact Monitor(...) call to arm. It is idempotent, so re-running it is how you check state rather than a risk. buzz-msg.sh send|read and buzz-watch.sh load 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 /rename

A 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_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 — last occurrence wins, because /rename can be re-run. That is tier 1. It falls back to the worktree directory, then session-<first 8 of the session id>, then session-<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 /rename is not a new member. The published name is prefixed Claude 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-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 in the process.

The safety model is three rules:

  1. Only keys already in ~/.buzz/sessions. Nothing is minted, fetched or derived.
  2. Only channels add-member --role member, only on the channel being joined. The role and the verb are literals in join_channel; the channel is the one just resolved. There is no generic run-as-another-key helper exposed above those two call sites.
  3. Every use is printed — the identity name, the owner pubkey, the file the key came from, and the exact command run under it. A privilege action that leaves no trace in the output is unacceptable.

BUZZ_AUTO_ADMIT=0 skips 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 --invite already reduces that to one paste, and the human should stay the one who authorises it.

Dedicated channels hold now

BUZZ_COORD_CHANNEL was 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 keeping BUZZ_COORD_CHANNEL so 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 bare buzz-msg.sh send afterwards posts there rather than to the machine's default. The pin is per session, not per machine: one worktree can sit in pp-refactor while another stays in agent-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 .env holds 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 grepped. This is not theoretical: before the split, a display name containing spaces produced a stray command not found on every connect, and a title of x$(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 before BUZZ_CHANNEL_ is prepended. config_get builds a regex from the key; a name like Weird Name!/../x would otherwise be a regex, and a path.

~/.buzz/config 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.

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:

  • not a relay member → the exact sentence to say to the user, asking for the invite link, and nothing else. buzz-admin add-member is 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 instruction
  • a relay member but not a channel member → admitted with the local owner key, printing what was used; or, if no local key owns the channel, the exact buzz channels add-member line for the owner
  • watcher never armed → the exact Monitor(...) to run; buzz-connect.sh --status exits non-zero, so "connected but deaf" is checkable

buzz-msg.sh read on 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 into desktop/src-tauri/.../managed_agents/ and mirrored across .agents/.goose/.codex. This one is scoped to Claude Code because the protocol depends on the Monitor tool 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/ as desktop-screenshot does. 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 the set -e the identity manager wants.

python3 for JSON, following the Justfile, rather than adding a jq dependency. The resolver has a pure-shell fallback so it still produces a name without it.

Named buzz-multi-session. Avoids the sprout-clibuzz-cli rename in #2525, and avoids "mesh", which VISION_MESH.md already uses for GPU pooling.

Testing

Every documented command was verified against a release build of buzz. All six scripts pass bash -n and shellcheck -x with zero findings.

The resolver was tested against a real 7 MB transcript containing a customTitle (resolves to buzz-init), against every fallback tier, and against hostile titles: ../../etc/passwd 🚀 spacesetc-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=true on an isolated database: two worktree sessions with different /rename titles connected, hit and recovered from both membership gates with the diagnostics above, published profiles that buzz users get returned as Auth Refactor A / Auth Refactor B, exchanged messages with one session's watcher reporting the peer's CLAIM and correctly not its own STATUS, and a mid-flight /rename moved the identity and republished the profile as Auth Refactor A v2 while 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 wrote BUZZ_CHANNEL_ZZ_ADMIT_ROOM while leaving BUZZ_COORD_CHANNEL pointing at the default. Session B then ran the same command and was admitted with no human action:

channel  : zz-admit-room (27dcbea8-81f8-42c8-a28a-1757c905e8e8)
auto-admit: not a member of 'zz-admit-room'; checking whether a key in
            /Users/…/.buzz/sessions owns it
auto-admit: 'hermes' owns this channel and its key is on this machine.
            owner key : 592b948b9ff4906a8bb16050d337185ca7b7998f42b29ee098338eba4b4f53ba
            from      : /Users/…/.buzz/sessions/hermes.env
            running   : buzz channels add-member --channel 27dcbea8-… \
                          --pubkey eeb08ba6… --role member
auto-admit: granted. This session now has role member in 'zz-admit-room', authorised by
            the local identity 'hermes'. Nothing else was touched: no relay
            membership, no other channel, no role above member, no new key.

buzz-msg.sh send from session B then reported sent to zz-admit-room as pp with no --channel, and read returned both HELLOs and the STATUS — 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:

auto-admit: off (BUZZ_AUTO_ADMIT=0) — not looking for an owner key

  BLOCKED: relay membership is not channel membership — they are separate gates.
  …
  Ask the channel owner to run:
    buzz channels add-member --channel f959fba8-… --pubkey eeb08ba6… --role member

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-roomBUZZ_CHANNEL_ZZ_ADMIT_ROOM, a UUID passes straight through, a pinned session resolves to its own room, and BUZZ_COORD_CHANNEL in 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.

connect                     (default) identity, enrolment, profile, channel, HELLO, watcher
join <name>                 a room for one piece of work
status [--all]              am I connected, is the watcher alive, what else is on this machine
leave [--leave-channel]     stop participating in the current channel
disconnect [--leave-channel] [--retire]
                            stop participating entirely

Every existing flag still works and nothing was renamed: --status is status, --channel <name> is join <name>, and a bare run is still connect. --invite and --quiet-hello are untouched.

The verbs live on buzz-connect.sh rather 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:

  1. Stop the watcher. It is a Claude Code Monitor, so a shell script cannot kill it. Both verbs print the exact TaskStop call, the mirror of the Monitor(...) connect prints. The printed pid is a fallback when the task id is lost — the watcher clears its own marker on TERM — but TaskStop is what stops Claude Code tracking the task.
  2. Say goodbye. DONE is posted first, while the session is still a channel member, because after channels leave the relay refuses the send. A session that stops answering without a DONE is indistinguishable from one that is merely slow.

Then the room pin is cleared, so a bare buzz-msg.sh send stops 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:

Flag Right when Wrong when
--leave-channel the piece of work is finished the session reconnects tomorrow and would have to be re-admitted
--retire a throwaway worktree anything resumable

leave and disconnect do the same two things by default. The difference is what they say and what they offer — disconnect reports 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.

--retire is never implicit. leave --retire is 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 is actor == target, so a session can retire itself with no owner or admin involved.

What --retire says about archiving, because it is not what it sounds like

kind:9035 adds one row to archived_identities and 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 --retire says so before it acts.

buzz agents unarchive is 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 happens

On a private channel this is not self-reversible: channels join is refused with restricted: 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 leavecannot remove the last owner, because an ownerless private room can never admit anyone again. That is the normal outcome of join <name> followed by disconnect --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

status will 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, a status call after a leave would have silently rejoined the channel, making leave look like it had failed.

Roster hygiene

status --all lists 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:

  IDENTITY                       PUBKEY             RELAY            WATCHER        ROOM
  buzz-init                      0550845571d4322b   member           live pid 4137  agent-coordination
  buzz                           f747e9f509cf69b3   member           unbound        -
  hermes                         592b948b9ff4906a   member           unbound        -
  localowner                     9f33902767b7cbf6   not-a-member     unbound        -
  pp                             eeb08ba69917ea29   member           unbound        -
  spec-kit-arch-governance-init  ce24afa247e2674c   member           live pid 49820 none pinned; still polling 6c61c7b4

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_members has no TTL, no expiry and no last-seen column. Nothing in this skill removes a row, and disconnect --retire does not either. What I found looking for a path:

  • The relay does implement a self-service leave — NIP-43 kind:28936, which removes the sender's own row — but no client builds that event. It exists in the relay's ingest handler and in buzz-core's kind table and nowhere else: not in buzz, not in buzz-sdk, not in Desktop, not in the web app. The capability is real and unreachable.
  • The admin remove (kind:9031) explicitly refuses self-removal.
  • buzz-admin remove-member writes 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 -n and shellcheck -x with zero findings.

End-to-end against the live production relay with two throwaway identities in a throwaway channel:

  • leave is seen by the peer. Session B ran leave --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.
  • Rejoin works. After channels leave, B's own channels join was refused with restricted: channel is private; buzz-connect.sh join zz-teardown-room re-admitted it with A's owner key and no human action.
  • status does not undo a leave. status after the leave reported channel : none — this session is not in a room and exited 2; status --channel zz-teardown-room reported NOT a member and exited 4. Neither rejoined.
  • --retire does what it says and nothing more. agents archived went from {"archived":[]} to containing the pubkey; the archived identity then sent a message that the relay accepted; agents unarchive returned the snapshot to [].
  • Owner cannot leave. A, the channel's creator, got cannot remove the last owner and the two real options.
  • TaskStop closes the loop. With the watcher stopped, status reported NOT ARMED and 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 sees buzz-multi-session has no way to learn that leave exists. 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 frontmatter description is the whole discoverability surface, written for a human scanning a list. They carry no logic and no duplicated prose — one SKILL.md naming 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.md rather than symlinks to the umbrella, because a skill's identity is its frontmatter name and description: 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-cli and desktop-screenshot are 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 agent

buzz-acp runs goose, codex, claude-agent-acp and 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_CHANNELS only 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 to no channel subscriptions resolved — agent will sit idle. That gap is the same keypair/enrolment/profile/membership sequence buzz-connect.sh already performs, so it reuses ensure_relay_membership, publish_profile, resolve_channel and join_channel — the first two lifted out of buzz-connect.sh for 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.

--command is not validated against a list because buzz-acp has none: it normalises the command to an identity and looks up default arguments. goose gets acp; codex, codex-acp, claude-agent-acp, claude-code-acp, claude-code, claudecode and buzz-agent get none. Everything else — hermes included — gets no defaults, and the built-in default for BUZZ_ACP_AGENT_ARGS is the literal string acp, so an unrecognised harness is launched as <cmd> acp. The env block sets it explicitly and says why.

Fixed at the source: buzz-session.sh recorded CLAUDE_CODE_SESSION_ID even for an explicit name, so a /rename in 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-to defaults to owner-only, so an unowned agent connects and ignores everyone; agents draft-create/draft-update fail with exit 3 and have no --owner flag; agent turn metrics are rejected. buzz mem is 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's compute_auth_tag example. So --auth-tag uses a real attestation, --owner records 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_pubkey only on the ViaOwner path — a key that is not a direct member, admitted because its owner is one. A direct member's check returns Member and 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-tag therefore 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_TAG is 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 at desktop/src-tauri/src/managed_agents/<name>_skill.md, which nest.rs:44 include_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 split lib.sh, and there is no precedent for a skill that depends on another's scripts.

Changing relays no longer breaks silently

BUZZ_COORD_CHANNEL and BUZZ_CHANNEL_<NAME> were bare UUIDs with no relay in the key, and a UUID is structurally valid on any relay — so pointing BUZZ_RELAY_URL somewhere 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.sh resolves 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 get proves 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_URL recorded 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. status now 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:

  • The relay does implement a self-service leave — NIP-43 kind:28936, which removes the sender's own relay_members row — but no client builds that event. It appears in the relay's ingest handler and in buzz-core's kind table and nowhere else: not in buzz, not in buzz-sdk, not in Desktop, not in the web app. Real, and unreachable.
  • The admin remove (kind:9031) explicitly refuses self-removal.
  • buzz-admin remove-member writes 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_members has no TTL, expiry or last-seen column.

So disconnect --retire cannot drop relay membership, and every teardown says so instead of implying the session has been erased.

…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>
@ashbrener
ashbrener requested a review from a team as a code owner August 3, 2026 05:04
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>
@ashbrener

Copy link
Copy Markdown
Author

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.

just ci status, reported rather than left to be discovered.

It fails on this machine, on one test, and the failure is not from this branch:

relay_admission::tests::concurrent_429_extends_the_window_for_parked_waiters
  left: 300.001s   right: 5s
desktop-tauri-test  exit 101

Evidence it is pre-existing and load-dependent:

  • passes in isolation on unmodified main
  • passes in isolation on this branch
  • fails only inside the full parallel just ci run

The cause looks structural rather than environmental: the test is
#[tokio::test(start_paused = true)] (desktop/src-tauri/src/relay_admission.rs:152)
and asserts an exact Instant::now() - start == 5s. With a paused clock, tokio
auto-advances to the next pending timer, so under a parallel run it can adopt a
300s timer from elsewhere instead of its own — which is exactly the 300.001s
reading. Happy to file that separately if it is not already known.

Everything this branch touches is green: cargo test -p buzz-cli 355 passed,
cargo clippy -p buzz-cli --all-targets -- -D warnings clean, cargo fmt --check
clean, and the full pre-push gate (rust-tests, desktop-check, desktop-test,
mobile-test, desktop-tauri-checks) passed on the pushes that landed here.

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant