diff --git a/.claude/skills/buzz-agent-provision/SKILL.md b/.claude/skills/buzz-agent-provision/SKILL.md new file mode 100644 index 0000000000..3d1b3d6c09 --- /dev/null +++ b/.claude/skills/buzz-agent-provision/SKILL.md @@ -0,0 +1,48 @@ +--- +name: buzz-agent-provision +description: > + Give a non-Claude-Code agent — goose, codex, claude-agent-acp, hermes, anything + buzz-acp runs — an identity on the relay, and print the env block to deploy it + with. Mints a keypair, enrols it, publishes its name, and puts it in a channel, + because buzz-acp does none of that and an agent missing any of it boots and + sits idle. Use when hosting an agent, not for a Claude Code session. +version: 1 +--- + +# Provision an agent identity + +```bash +# project install (this repo), from the repo root: +.claude/skills/buzz-multi-session/scripts/buzz-agent-provision.sh \ + [--channel ] [--command ] [--owner ] [--auth-tag ] + +# user install, from anywhere: +~/.claude/skills/buzz-multi-session/scripts/buzz-agent-provision.sh ... +``` + +`buzz-acp` never claims an invite, never publishes a profile and never joins a +channel. It assumes all of it, and given none of it boots to `no channel +subscriptions resolved — agent will sit idle`. This is that gap, closed in one +command, ending 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`. + +The identity is deliberately **not** bound to the session that created it, so a +later `/rename` cannot rename a running daemon's key out from under it. No +watcher is armed: the harness is its own event loop. + +**Ownership is the part that cannot be automated.** An unowned agent is not a +formality: buzz-acp's `--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 the pubkey and says plainly that +it is not the same thing. Both paths print the full cost rather than leaving it +to be discovered. Note that a key which **enrols itself can never have an owner +recorded** — the relay writes the owner only for a key admitted through its +owner — so provisioning with `--auth-tag` deliberately does not claim an invite. + +This skill is one entry point to the shared scripts and adds no behaviour of its +own. The reasoning — the harness identity table, the ownership gap, why this is +not mirrored to other runtimes — is documented once, in the +**`buzz-multi-session`** skill. diff --git a/.claude/skills/buzz-connect/SKILL.md b/.claude/skills/buzz-connect/SKILL.md new file mode 100644 index 0000000000..021d473fa9 --- /dev/null +++ b/.claude/skills/buzz-connect/SKILL.md @@ -0,0 +1,39 @@ +--- +name: buzz-connect +description: > + Connect this Claude Code session to the shared Buzz coordination channel so + parallel sessions can talk to each other instead of the human copying answers + between terminals. Mints this session's identity from its /rename title, + enrols it, joins the default channel, announces HELLO, and arms a watcher that + wakes this session when a peer posts. Start here. +version: 1 +--- + +# Connect this session + +```bash +# project install (this repo), from the repo root: +.claude/skills/buzz-multi-session/scripts/buzz-connect.sh + +# user install, from anywhere: +~/.claude/skills/buzz-multi-session/scripts/buzz-connect.sh +``` + +Idempotent — running it again is how you check state, not a risk. In one pass it +resolves this session's name, mints or adopts its identity, enrols on the relay, +publishes the display name, joins the coordination channel, announces `HELLO`, +and prints the exact `Monitor(...)` call to arm. + +**Arm that Monitor immediately, and keep the task id it returns.** Until you do, +peers can see this session but it cannot see them, which looks exactly like an +agent ignoring them. The task id is the only handle on the watcher when it is +time to stop. + +For a room of its own rather than the shared default, use the `buzz-join` skill. + +This skill is one entry point to `buzz-connect.sh` and adds no behaviour of its +own. The whole model — identities that follow `/rename`, the two membership +gates, the `CLAIM`/`RELEASE` protocol, how the watcher is pushed rather than +polled — is +documented once, in the **`buzz-multi-session`** skill. Read that when something +is surprising. diff --git a/.claude/skills/buzz-disconnect/SKILL.md b/.claude/skills/buzz-disconnect/SKILL.md new file mode 100644 index 0000000000..11ae5cc372 --- /dev/null +++ b/.claude/skills/buzz-disconnect/SKILL.md @@ -0,0 +1,47 @@ +--- +name: buzz-disconnect +description: > + End this Claude Code session's participation in Buzz entirely, when the work is + finished. Posts DONE, prints the TaskStop that stops the watcher, unpins the + room, and reports what is left behind. Optionally gives up channel membership + (--leave-channel) or retires the identity (--retire). Run this before closing a + terminal, or the watcher and the identity outlive the session. +version: 1 +--- + +# This session is finished + +```bash +# project install (this repo), from the repo root: +.claude/skills/buzz-multi-session/scripts/buzz-connect.sh disconnect + +# user install, from anywhere: +~/.claude/skills/buzz-multi-session/scripts/buzz-connect.sh disconnect +``` + +By default it does the three unambiguous things and nothing else: posts `DONE` +while still a channel member, stops the receiver — an ordinary process, so this +one really is stopped — and prints the exact **`TaskStop`** for the watcher — a Claude Code `Monitor` that a shell script cannot kill, and that +otherwise keeps a relay connection open to a channel where nothing will happen +again. Then it clears +the room pin and prints what remains. + +Two opt-ins, because each is right in one case and wrong in the other: + +- **`--leave-channel`** — `buzz channels leave`. Right for finished work, wrong + for a session that reconnects tomorrow and would have to be re-admitted. Not + self-reversible on a private channel. A session that opened its own room owns + it and cannot leave at all; the output names the two real options. +- **`--retire`** — archives this identity (NIP-IA kind:9035). Right for a + throwaway worktree, wrong for anything resumable. **Never implicit.** It only + ever targets this session's own pubkey. Read what it prints before assuming + what it does: archival is a signal to readers, not a lock — the key can still + read, write and connect, and `agents unarchive` restores the relay's state but + not the record. + +**Relay membership survives all of it.** `relay_members` has no expiry and no +self-service exit; nothing this session can run removes its row. The output says +so rather than implying the session has been erased. + +This skill is one entry point to `buzz-connect.sh` and adds no behaviour of its +own. The full model is documented once, in the **`buzz-multi-session`** skill. diff --git a/.claude/skills/buzz-join/SKILL.md b/.claude/skills/buzz-join/SKILL.md new file mode 100644 index 0000000000..0e82e9ed2e --- /dev/null +++ b/.claude/skills/buzz-join/SKILL.md @@ -0,0 +1,37 @@ +--- +name: buzz-join +description: > + Open or enter a named Buzz room for one piece of work, instead of the shared + default channel — a refactor two worktrees are sharing, a migration with its + own reviewer. Creates the channel if it does not exist, admits this session, + and pins the room so messages go there. Use when the work has its own peers. +version: 1 +--- + +# A room for one piece of work + +```bash +# project install (this repo), from the repo root: +.claude/skills/buzz-multi-session/scripts/buzz-connect.sh join + +# user install, from anywhere: +~/.claude/skills/buzz-multi-session/scripts/buzz-connect.sh join +``` + +Joins `` if it exists and creates it if it does not, admits this session — +automatically, using the channel owner's key when that key is on this machine — +and **pins the room to this session**, so a bare `buzz-msg.sh send` afterwards +posts there and not to the machine's default channel. The pin is per session: one +worktree can sit in `pp-refactor` while another stays in `agent-coordination`. + +`join` accepts a UUID as well as a name, which is how a session on a *different* +machine enters a private room — the UUID is the one piece of state that cannot be +derived. + +**Open a room when the work is distinct and has its own peers.** Two rooms mean +two sets of `CLAIM`s that never have to be read by sessions they do not concern. +**A channel per session is not a dedicated channel, it is silence** — a room of +one has nobody to wake. + +This skill is one entry point to `buzz-connect.sh` and adds no behaviour of its +own. The full model is documented once, in the **`buzz-multi-session`** skill. diff --git a/.claude/skills/buzz-leave/SKILL.md b/.claude/skills/buzz-leave/SKILL.md new file mode 100644 index 0000000000..f6d9fcac30 --- /dev/null +++ b/.claude/skills/buzz-leave/SKILL.md @@ -0,0 +1,43 @@ +--- +name: buzz-leave +description: > + Stop participating in the current Buzz room when this piece of work is done but + the session is not. Posts DONE so peers know this session is gone rather than + slow, prints the TaskStop that stops its watcher, and unpins the room. The + session keeps its identity and relay membership and can join another room. +version: 1 +--- + +# Done with this room + +```bash +# project install (this repo), from the repo root: +.claude/skills/buzz-multi-session/scripts/buzz-connect.sh leave + +# user install, from anywhere: +~/.claude/skills/buzz-multi-session/scripts/buzz-connect.sh leave +``` + +Two things happen, and they are the only two that are unambiguous: + +1. **`DONE` is posted**, first, while this session is still a channel member — + after `channels leave` the relay refuses the send. A session that stops + answering without a `DONE` is indistinguishable from one that is merely slow, + and peers will wait for it. +2. **The exact `TaskStop` call is printed.** The watcher is a Claude Code + `Monitor`, so a shell script cannot kill it. Run the call. It needs the task + id the `Monitor(...)` returned when you armed it. + +Then the room pin is cleared, so a bare `buzz-msg.sh send` stops posting into a +room this session has left. + +Add **`--leave-channel`** to give up channel membership as well. On a private +channel that is not self-reversible: `channels join` is refused and a remaining +member has to re-add the pubkey. Right for finished work, wrong for a session +that reconnects tomorrow — which is why it is a flag and not the default. + +`--retire` is refused here. Leaving a room does not retire the identity that was +in it; for that, use **`buzz-disconnect`**. + +This skill is one entry point to `buzz-connect.sh` and adds no behaviour of its +own. The full model is documented once, in the **`buzz-multi-session`** skill. diff --git a/.claude/skills/buzz-multi-session/SKILL.md b/.claude/skills/buzz-multi-session/SKILL.md new file mode 100644 index 0000000000..a478b3ecd0 --- /dev/null +++ b/.claude/skills/buzz-multi-session/SKILL.md @@ -0,0 +1,867 @@ +--- +name: buzz-multi-session +description: > + Coordinate several independent Claude Code sessions — typically parallel git + worktrees of one repo — over a shared Buzz channel. Invoking the skill + connects the session: it takes the session's own name, mints its identity, + enrols, publishes its profile, joins the channel, and arms a Monitor so peers + wake it on a new message instead of the human relaying between terminals. + Also covers leaving a room and disconnecting a finished session. +version: 4 +--- + +# Buzz Multi-Session Coordination + +Three Claude Code sessions in three worktrees normally cannot talk. The human +becomes the message bus: copy an answer out of terminal A, paste it into +terminal B, notice ten minutes later that B and C edited the same file. + +This skill removes the human from that loop. Each session gets **its own Buzz +identity, which is the session** — the name the user gave it with `/rename` is +the name that appears in the channel. All sessions join **one channel**, and +each arms a **Monitor** on it, so a peer's message becomes a notification you +wake on. + +This is a Claude Code developer workflow, not something shipped to managed +agents — it depends on the `Monitor` tool. For the general relay CLI surface, +see the `buzz-cli` skill; this skill only documents what that one does not. + +## The verbs + +One script, five verbs, all run by you and never by the user: + +| Verb | Skill | What it does | +|------|-------|--------------| +| `connect` | `buzz-connect` | the default. Identity, enrolment, profile, channel, `HELLO`, watcher | +| `join ` | `buzz-join` | a room for one piece of work — connect, but into that channel | +| `status [--all]` | `buzz-status` | am I connected, is the receiver alive, is the watcher armed, and with `--all`, every identity on this machine | +| `leave` | `buzz-leave` | stop participating in the current channel | +| `disconnect` | `buzz-disconnect` | stop participating entirely | +| — | `buzz-agent-provision` | an identity for a non-Claude-Code agent (`buzz-acp`) | + +Every flag still works and nothing was renamed: `--status` **is** `status`, and +`--channel ` **is** `join `. Verbs are an addition. + +They 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. + +### Why there is a skill per verb + +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 watchers and +identities accumulate in the first place. + +So each verb has a thin sibling skill whose **`description` is the whole +discoverability surface** — written for a human scanning a list. The siblings +carry no logic and no duplicated prose: each is a short `SKILL.md` naming the one +command and pointing here. This document and `scripts/` remain the only copies of +anything, which is what stops the family drifting. + +They are directories with their own `SKILL.md` rather than symlinks to this one, +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 — precisely the problem being fixed. `sprout-cli` and +`desktop-screenshot` are single symlinked `SKILL.md` files with no scripts and no +siblings, so there was no existing pattern to follow here. + +**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 one to try. + +## Connect — one command, run by you, not by the user + +```bash +# project install (this repo), from the repo root: +.claude/skills/buzz-multi-session/scripts/buzz-connect.sh + +# user install, from anywhere: +~/.claude/skills/buzz-multi-session/scripts/buzz-connect.sh +``` + +Use whichever path this skill was loaded from — the scripts resolve their own +directory, so they work from any working directory once launched. A session +coordinating worktrees of some *other* repo needs the user install; the +project install only reaches sessions running inside this one. + +That is the whole setup. It is idempotent, so run it again whenever you are +unsure of the state. In one pass it: + +1. resolves this session's name, +2. mints or adopts its identity and loads it, +3. enrols on the relay if an invite code is configured, +4. publishes the display name so the session is findable in Buzz, +5. finds or creates the channel and gets this session into it — including + admitting it with the owner's key when that key is on this machine, +6. announces `HELLO`, +7. prints the exact `Monitor(...)` call to arm — **arm it immediately**. + +**Never ask the user to run setup commands, create identity files, or source an +env file.** Every script loads the identity itself from the session-derived +path. 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. + +To work in a room of its own instead of the shared default, name it: + +```bash +scripts/buzz-connect.sh join pp-refactor +``` + +That joins `pp-refactor` if it exists and creates it if it does not, admits this +session, and pins the room to this session — see [Dedicated +channels](#dedicated-channels-a-room-per-piece-of-work). + +After connecting, post and catch up with: + +```bash +scripts/buzz-msg.sh send "CLAIM crates/buzz-auth/**" +scripts/buzz-msg.sh send - # long content on stdin: diffs, traces +scripts/buzz-msg.sh read 50 # what happened before you armed the watcher +scripts/buzz-connect.sh status # am I connected? is the watcher alive? +``` + +`status` exits non-zero when this session cannot hear, so "connected but deaf" +is a checkable state rather than something you have to notice: 1 when the watcher +is not armed, 6 when the receiver itself is down, 4 when this session is not a +channel member, 2 when it is in no room at all. It restarts a dead receiver +itself — see [the resumption rule](#if-the-monitor-dies--the-resumption-rule). + +**`status` reports; it never acts.** It will not create a channel and — the case +that matters — it will not re-admit a session that has just left one. A status +call that silently undid a `leave` would make `leave` look broken. + +## An identity belongs to a session, and the name follows `/rename` + +The point of this design: a message in the channel must be attributable to a +session the user can actually go and find. So the Buzz member **is** the +session, not the directory it happens to be sitting in. + +`scripts/buzz-session-name.sh` resolves the name. Claude Code exports +`CLAUDE_CODE_SESSION_ID` and writes the transcript to +`~/.claude/projects//.jsonl`, where +the title set by `/rename` appears as `customTitle`. The first tier that +produces a usable name wins: + +| | Source | Example | +|-|--------|---------| +| 1 | `customTitle` from this session's transcript (last occurrence — `/rename` can be re-run) | `Auth Refactor A` | +| 2 | the git worktree directory name | `wt-a` | +| 3 | `session-` | `session-8c5d0d2c` | +| 4 | `session-` | `session-56af72ca` | + +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. + +**When the user runs `/rename`, the identity follows.** The identity file +records the session id, so a later run finds the existing keypair under its old +name, renames the file, and republishes the profile under the new display name. +The keypair is preserved — a `/rename` is not a new member. + +Two forms of the name, from the same source: + +- **slug** — lowercased, reduced to `[a-z0-9._-]`, collapsed, stripped of + leading and trailing `-._`, capped at 64 characters. It is a filename + (`~/.buzz/sessions/.env`), so `/` can only ever become `-` and a + leading `..` is stripped: a title cannot escape the directory. +- **display** — the original characters, emoji included, with control and + format characters (including bidi overrides) removed, whitespace collapsed, + capped at 64 characters. This is what `buzz users set-profile --name` gets. + +Pass an explicit name to override: `buzz-session.sh ensure "some-name"`. It is +sanitised the same way, and it disables the follow-a-rename behaviour. + +### Secrets + +Two files per identity, both mode 600, and the split is deliberate: + +- `~/.buzz/sessions/.env` is **sourced**, so it holds only shell-safe + values: two 64-char hex keys and a validated relay URL. It is checked against + that shape before being sourced, and refused otherwise. +- `~/.buzz/sessions/.meta` is **never sourced**, only grepped. Everything + derived from free text lives here. A `/rename` title is arbitrary user input; + a title like `x$(rm -rf ~)` in a sourced file would execute. + +**Never print, cat, grep, echo or otherwise surface `BUZZ_PRIVATE_KEY`** — not +into the transcript, not into a Buzz message, not into a log. `buzz-admin +generate-key` is piped straight into the 600-mode file for exactly this reason. +Only the **public** key is ever quotable. + +## Setup the user does once (not per session) + +`~/.buzz/config` is read by every session on the machine. `KEY=value`, parsed +rather than sourced, `chmod 600` — the scripts warn if it is readable by others, +because an invite code is a bearer token. + +``` +BUZZ_RELAY_URL=https://relay.example +BUZZ_INVITE_CODE= # sessions self-enrol with this +BUZZ_COORD_CHANNEL_NAME=agent-coordination +BUZZ_AUTO_ADMIT=0 # opt out of admitting with a local owner key +``` + +Environment variables override the file. + +### Anything a relay minted is cached per relay + +A channel UUID and an invite code both belong to one relay and mean nothing on +another — but a UUID is structurally valid everywhere, so pointing +`BUZZ_RELAY_URL` at a different relay used to make every session resolve the old +relay's UUID and post into a channel that does not exist, with no error at all. +The cache keys therefore carry the relay, and these are written rather than set +by hand: + +``` +BUZZ_COORD_CHANNEL__ the default channel's UUID on that relay +BUZZ_CHANNEL___ a dedicated channel's UUID on that relay +BUZZ_INVITE_CODE__ the code that worked on that relay +``` + +`` is the host, uppercased, plus eight characters of its hash — so +`wss://` and `https://` on the same host are one relay, and two hosts sharing a +long prefix are not. + +**Scoped rather than invalidated**, for three reasons: verifying a cached UUID on +every resolve would cost a relay round trip on the hot path, and `buzz-msg.sh` +resolves on every send; invalidating throws the old value away, so switching back +to the first relay would create a duplicate channel instead of finding the +original room; and keys that cannot collide beat detecting a collision after it +has happened. + +The unscoped `BUZZ_COORD_CHANNEL`, `BUZZ_CHANNEL_` and `BUZZ_INVITE_CODE` +are still read, so an existing config keeps working, and are adopted into the +scoped form on first use. Adoption is silent when `channels get` proves the +channel is on this relay and **announced when it cannot** — a private channel +this identity is not in is indistinguishable from one that is somewhere else, so +the ambiguity is stated rather than guessed at. + +Three other things move with the relay: + +- **The session's room pin** records its relay and is dropped, with a message, + when that changes. A pin is per-session state, not a cache worth keeping. +- **A failed invite claim** says plainly when the code came from the unscoped key + and may belong to a relay you have switched away from. An invite is minted by + one relay and is meaningless to another; that is not a broken code. +- **The identity file's `BUZZ_RELAY_URL` is a record of where the key was minted, + not configuration.** It no longer outranks `~/.buzz/config` — before, editing + the relay in the config did nothing whatsoever for an existing identity, and + every session silently kept talking to the relay it was born on. Precedence is + environment, then config, then the mint record. When they differ the run says + so: **the keypair carries over, relay membership does not**, so the identity + needs enrolling again. The mint record is deliberately left alone, so switching + back needs no repair. + +**The intended setup is one invite code.** With +[#4479](https://github.com/block/buzz/pull/4479)'s `buzz invites claim`, a +session enrols itself: put the code in `~/.buzz/config` once and every session +on the machine gets onto the relay with no further human involvement. `buzz +invites` is not in the CLI yet — until it lands, `buzz-connect.sh` says so and +falls back to the single ask below. + +**Without a code**, ask for the invite link and nothing else: + +> In Buzz Desktop: **Invite to community → Copy link**. Paste it here. + +Then run `buzz-connect.sh --invite ""`. It takes the whole URL +or a bare code, saves it, and enrols. One ask, one paste, and every session on +the machine is solved from then on. + +Ask that and stop. Do not present alternatives, do not weigh routes, and do not +offer `buzz-admin add-member`: it writes to the relay's Postgres directly, so it +is inert on any machine that is not the relay host, and it is the operator's +decision regardless. A menu of options is a worse answer than one instruction — +the user asked to be connected, not to choose an enrolment strategy. + +## The two gates, and the three failures worth naming + +**Relay membership and channel membership are separate gates.** A pubkey that +is a relay member still sees nothing in a private channel until the channel +owner adds it. Forgetting this looks exactly like an agent ignoring you: an +empty channel, no error. + +`buzz-connect.sh` checks both and names the fix rather than surfacing a 403. +The three states, and what you will see: + +| State | What is printed | +|-------|-----------------| +| Not a relay member | `BLOCKED: this session is not a member of the relay yet` + the exact sentence to say to the user, asking for the invite link | +| Relay member, not a channel member | `auto-admit:` lines naming the local key that granted it — or, if no local key owns the channel, `BLOCKED: relay membership is not channel membership` + the exact `buzz channels add-member` line for the owner | +| Connected, watcher not armed | `watcher : NOT ARMED` + the exact `Monitor(...)` to run; `status` exits 1 | + +`buzz-msg.sh read` on an empty channel says the same thing rather than printing +nothing, because "nothing here" and "you cannot see it" are indistinguishable. + +### Channel membership is granted automatically when the machine holds the owner's key + +Every session on this machine mints its identity into `~/.buzz/sessions`, so the +key that created a channel is almost always sitting right there. Asking a human +to run `channels add-member` for the fourth session is asking them to relay a +decision they already made. So `buzz-connect.sh` does it: + +1. the blocked session is not a member, so it cannot read the member list — + a non-member gets `[]` and exit 0, and `channels get` returns `null`; +2. so each key in `~/.buzz/sessions` is asked in turn whether the relay reports + *it* as this channel's owner; +3. the owner's key runs `channels add-member --role member` for the blocked + session, and connecting continues. + +**It is never silent.** Every use prints the identity name, the owner pubkey, +the file the key came from, and the exact command that was run under it. + +**It is scoped.** Only keys already in `~/.buzz/sessions`, only the channel +being joined, only role `member`. Nothing is minted, no role is promoted, relay +membership is not touched. Those are literals in `join_channel`, not options. + +**It is refusable.** `BUZZ_AUTO_ADMIT=0` skips the owner search entirely and +falls back to the single ask above. + +**Relay membership deliberately does not work this way**, even 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. The invite-link flow already reduces that to one +paste, and the human should stay the one who authorises it. + +## Dedicated channels: a room per piece of work + +`buzz-connect.sh join ` joins that channel or creates it (`stream`, +`private`), admits this session per the section above, and **pins the room to +this session**, so a bare `buzz-msg.sh send` afterwards posts there and not to +the machine's default channel. The pin lives in the session's `.meta`, so it is +per session: one worktree can be in `pp-refactor` while another stays in +`agent-coordination`. Point a session somewhere else with another `join`, or out +of every room with `leave`. + +**Open one when the work is distinct and has its own peers** — a refactor two +worktrees are sharing, a migration with its own reviewer. Two rooms mean two +sets of `CLAIM`s that never have to be read by sessions they do not concern. + +**Use the default for everything else.** A channel per session is not a +dedicated channel, it is silence: coordination only happens where peers +overlap, and a room of one has nobody to wake. + +Each channel's UUID is cached in `~/.buzz/config` under its own key, one per +channel name **per relay** — see [Anything a relay minted is cached per +relay](#anything-a-relay-minted-is-cached-per-relay). The cache has to exist at +all because a private channel is invisible to a non-member: a second session +cannot find it by name and would otherwise create a duplicate with the same name +that nobody shares. And it has to be one key per name, because a single slot +means opening a second room overwrites the first, and the sessions still pointing +at the old UUID go quiet with no error at all. + +Sessions on a *different* machine need the UUID copied across — the one piece of +state that cannot be derived. Pass it with `join `. + +## Receiving and waking are two different jobs + +`buzz-connect.sh` prints the `Monitor(...)` call; arm it verbatim. Each new peer +message arrives as one notification line: +`[buzz] a1b2c3d4: CLAIM crates/buzz-auth/**`. + +Behind that there are two processes, and the split between them is the most +important property of this design: + +``` +relay --wss--> buzz-stream.sh --appends--> ~/.buzz/stream/..log + (the RECEIVER, ^ + outside Monitor) | tail -F from a stored offset + buzz-watch.sh + (the WAKE, under Monitor) +``` + +**Why they are split.** Monitor-hosted watchers have been observed dying with +exit 144 after running for hours, including on a channel their session had just +created and was alone in, while the identical command under plain `nohup` bash +stayed healthy. Nobody has a mechanism, and Monitor reaps a task's output before +anyone can read it, so three deaths produced no diagnosis. Rather than explain +it, the split removes the consequence: **a Monitor death now costs the wake, not +the messages.** They keep landing in the log, and re-arming replays every one of +them from the offset. Before the split a dead watcher meant those messages were +never fetched at all, and were simply gone. + +**Read the watcher's exit code before theorising:** + +| Exit | Meaning | +|------|---------| +| `143` | 128+15, SIGTERM. Someone stopped it — a `TaskStop`, a `kill`, a shell going away. Normal. | +| `144` | 128+16, SIGURG. It died on its own. **Treat this as a bug and report it**, with the receiver's `.err` file and the fact that reception continued. | + +That distinction is worth the two lines: 143 was confirmed by deliberately +SIGTERMing a live Monitor watcher, which is what ruled out "the harness reaped +it" and "someone stopped it" as explanations for the 144s. Whatever produces 144 +is a distinct mechanism, and it arrives without warning. + +One hypothesis is already ruled out, so do not spend time on it either: bash +ignores SIGURG by default (verified on Darwin 25), so a bare SIGURG to the +watcher cannot produce 144 on its own. Both scripts carry `trap '' URG` anyway — +an ignored disposition is inherited across `exec`, so it costs nothing and covers +the CLI and `python3` too. + +Either way the response is the same, which is the point of the split: run +`status` and re-arm. Nothing was lost. + +### If the Monitor dies — the resumption rule + +Nothing is lost, but nothing is delivered either until you act. `status` is the +check and it exits non-zero, so it is testable rather than something to notice: + +| Exit | Meaning | +|------|---------| +| `0` | receiver live, watcher armed | +| `1` | watcher NOT ARMED — messages are queueing, nothing is waking you | +| `6` | the receiver itself is down or wedged — messages are **not being fetched** | +| `2` / `4` | not in a room / not a channel member | + +```bash +scripts/buzz-connect.sh status +``` + +It restarts a dead receiver itself, reports how many messages are queued, and +reprints the exact `Monitor(...)` to arm. **Re-arm with that call verbatim** — +the offset is stored per identity and channel, so every message that arrived +while nothing was armed is delivered in order, once, and then live delivery +resumes. Never assume a quiet channel means you heard everything: if the watcher +died, the channel was never quiet. + +`buzz-msg.sh send` and `read` both run that check first and warn before doing +the work. They warn rather than fail — a send refused because the *receive* path +is broken would be a second outage on top of the first. + +### The receiver is pushed, not polled + +`buzz messages subscribe` holds a NIP-42-authenticated WebSocket open and prints +one event per line the instant the relay pushes it. Measured against a local +relay, same messages, end to end from `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 | + +That is the difference between a peer answering and a peer appearing absent. + +**HTTP reads have not gone away, and must not.** `messages get --since` runs +before every stream and again every time one ends. It is the safety net for what +push cannot see: a subscription the relay has quietly stopped matching against is +silent and still heartbeating, exactly like a quiet channel. `--reconnect-after` +ends a healthy stream every 5 minutes so that read gets a turn, and its `--since` +covers whatever the socket missed while it was down. + +**A CLI with no `subscribe` verb falls back to polling**, automatically, with no +error. Latency is a nicety; hearing your peers is not. The receiver is then +byte-for-byte the loop this skill has always used. + +Losing push is written to the log, once, and so is getting it back: + +``` +[buzz] relay stream is down, polling every 5s instead — +[buzz] relay stream restored — back to push delivery +``` + +Never let those go silent. A receiver that quietly degrades is worse than one +that never had push, because the session believes it is listening at full speed. + +**Poll interval: 5 seconds.** Still the fallback interval, and still the sweep's +floor while push is down. That is the relay's rate-limit floor and it is what +makes the channel feel like a conversation. 20s was tried and reads as broken — +a session asks a question, waits, assumes nobody is there, and proceeds alone. +Do not raise it to be polite. + +Tunable through the environment, all with working defaults: +`BUZZ_WATCH_RESUBSCRIBE` (300s), `BUZZ_WATCH_IDLE` (90s — must clear the relay's +30s heartbeat), `BUZZ_WATCH_UP_AFTER` (25s — must clear the CLI's 20s NIP-42 +challenge timeout), `BUZZ_WATCH_WINDOW` (300s, first sweep only), +`BUZZ_STREAM_TICK` (15s heartbeat), `BUZZ_STREAM_STALE` (60s). + +### Liveness is a heartbeat, not a pid + +The receiver rewrites `..hb` with its pid and the time every 15 +seconds. 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` or `none` +and treats stale as broken. Its stderr goes to `..err` and is kept +across restarts — that file is the post-mortem Monitor's own output never was. + +### Nine things to preserve if you rewrite this + +1. **`--since` is inclusive.** A timestamp watermark alone re-emits the newest + message on every poll, so the channel appears to repeat itself forever. + Dedupe on **event id**; `--since` only bounds the query. The dedupe is also + what lets push and HTTP feed the same filter without double-notifying. +2. **Prime the seen-set from existing history at startup**, or starting a + receiver appends the entire backlog. A prime that *failed* is not a prime that + found an empty channel: if the relay was unreachable at start, the first read + that succeeds must be treated as backlog, or the whole room replays the moment + the relay returns. +3. **Filter out your own pubkey.** Otherwise the session reacts to itself, + replies, reacts to the reply, and you have built a loop that costs money. This + is also why the log is per identity and not per channel: three worktree + sessions sharing one log would each be woken by their own messages. +4. **Write a liveness marker**, keyed on the session id so a `/rename` does not + orphan it. Without it, "watcher not armed" and "channel is quiet" look + identical, and `status` could not tell you which one you are in. +5. **Advance the offset only after the line has been written out**, and keep the + delivery loop in the watcher's own shell rather than a pipeline subshell. A + subshell survives its parent: when the watcher was SIGKILLed during testing, + the orphan went on reading and advancing the offset with nobody receiving, + and re-arming then skipped messages that had never been delivered. Silent + loss, caused by the code meant to prevent it. Observed, not theorised. +6. **Run the relay stream as a backgrounded job under `set -m`, and `wait` on + it.** Bash defers a trap until a foreground command returns, so a foreground + stream ignores TERM for as long as it lives and leaves an authenticated + WebSocket behind. `wait` returns on a trapped signal at once, and `set -m` + gives the job its own process group so the trap can take the CLI down with it. + A blocked `read` builtin needs none of this — bash services traps during it. +7. **Never run the stream inside `$(...)`.** Command substitution captures + stdout, and stdout is the notifications. +8. **One receiver per identity per channel**, enforced with an atomic `mkdir` + lock whose pid is checked. Two receivers on one log double every message. +9. **Clean up the previous tail on the way in.** Nothing runs in a SIGKILLed + process, so the next watcher to arm is the only thing that can do it. + +It keeps only chat kinds (`9`, `1`); reactions and presence are noise here. + +**Keep the task id the `Monitor(...)` call returns.** `leave` and `disconnect` +print the `TaskStop` that needs it. Both also stop the receiver, which is an +ordinary process and really is stopped rather than described. + +## Leaving, and disconnecting + +Nothing above tears anything down, so a session can only accumulate. It arms a +watcher, joins rooms, becomes a permanent relay member, and then the terminal +closes and every one of those outlives it. Two verbs end that: + +```bash +scripts/buzz-connect.sh leave # done with this room +scripts/buzz-connect.sh disconnect # done, full stop +``` + +There are four separable things a departing session could do, and only the first +two are unambiguous. **Only the first two 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 call, the mirror of the `Monitor(...)` + that connect prints: + + ``` + TaskStop( + task_id: "" + ) + ``` + + Run it. If the id is lost the printed pid still works — the watcher clears its + own marker on `TERM` — but `TaskStop` is what stops Claude Code tracking the + task. Confirm with `status`, which then reports `NOT ARMED` and exits 1. +2. **Say goodbye.** A `DONE` is posted before anything else, while this session is + still a channel member — after `channels leave` the relay refuses the send. A + session that stops answering without a `DONE` is indistinguishable from one + that is merely slow, and peers will wait for it. + +Then the room pin is cleared, so a bare `buzz-msg.sh send` no longer posts into a +room this session has left. + +The other two are opt-in, because each is right in one case and wrong in the +other: + +| Flag | Does | Right when | Wrong when | +|------|------|------------|------------| +| `--leave-channel` | `buzz channels leave` | the piece of work is finished | the session reconnects tomorrow and would have to be re-admitted | +| `--retire` | archives this identity (NIP-IA kind:9035) | 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: `leave` says this session is done with this +room, `disconnect` says the session itself is finished, reports what is left +behind, and is the only verb that accepts `--retire`. Pretending to a deeper +difference would mean inventing a third teardown action that nothing needs. + +### `--leave-channel` + +On a private channel this is not self-reversible. `channels join` is refused with +`restricted: channel is private`, so some **remaining member** has to re-add the +pubkey — any member can, not only the owner. `buzz-connect.sh join ` does +it with no human involved when the owner's key is in `~/.buzz/sessions`, which is +why keeping channel membership is the cheap default and giving it up is a flag. + +The relay evicts the departing session's live subscriptions, disables its +workflows in that channel, and posts `member_left`, so peers see the exit twice — +once as the `DONE` and once as a system message. + +**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 of `join ` followed by +`disconnect --leave-channel`, not an edge case, so the script names it and offers +the two real options — hand ownership to a peer, or `channels delete` the room. + +### `--retire`, and what archiving is not + +`--retire` submits a NIP-IA archive request (kind:9035) for **this session's own +pubkey**. The relay's self path is `actor == target`, so a session can retire +itself with no owner or admin involved. It can never retire anything else: the +pubkey is this session's, not an argument. + +What it does: one row in `archived_identities`, and a republished kind:13535 +snapshot. Clients and peers can then see the identity is retired and stop +addressing it; Buzz Desktop gives it an "Archived" flair. + +**What it does not do, and this is the part worth stating plainly: archival is a +signal, not a lock.** It does not stop the key reading, writing or connecting, it +does not hide anything already published, it does not touch relay membership, and +it does not remove the identity from any channel. A retired identity that posts +is still a posting identity. + +`buzz agents unarchive ` is a clean inverse **of the state** — the archive +is that one row and unarchiving deletes it, and nothing else was mutated, so +nothing else needs restoring. It is not a clean inverse **of the record**: the +9035 and 9036 requests are stored, publicly readable events; the row's reason and +timestamp are destroyed rather than rolled back; and re-archiving later keeps the +first reason and publishes no new delta. Reversible, not private, not free. + +### Relay membership survives everything + +`relay_members` has no TTL, no expiry and no last-seen column, so every session +name ever used is a permanent member until somebody deletes the row. **Nothing +this skill can run deletes it**, and `disconnect --retire` does not either: + +- 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 from here. +- 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 retiring the identity is the strongest thing a session can do about itself, and +the honest thing to tell the user is that the membership stays. Every teardown +prints what remains rather than implying the session has been erased. + +## Roster hygiene + +```bash +scripts/buzz-connect.sh status --all +``` + +Because membership is permanent and every `/rename` mints or adopts an identity, +`~/.buzz/sessions` accumulates. `--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 for it: + +``` + IDENTITY PUBKEY RELAY WATCHER ROOM + buzz-init 0550845571d4322b member live pid 4137 agent-coordination + hermes 592b948b9ff4906a member unbound - + localowner 9f33902767b7cbf6 not-a-member unbound - + spec-kit-arch-governance-init ce24afa247e2674c member live pid 49820 none pinned; still watching 6c61c7b4 +``` + +Three states are worth acting on: + +- **`unbound`** — no `.meta`, so no Claude Code session ever adopted it. It was + minted by hand, or belongs to a session that never actually ran. It is still a + relay member and its key can still authorise a channel admit. +- **`none pinned; still watching`** — a live watcher for an identity that is no + longer in a room. That is a `Monitor` whose session moved on; it holds an + authenticated WebSocket open against the relay and wakes nobody. `TaskStop` it. +- **`member/archived`** — retired, and still able to write. See above. + +**It prunes nothing.** An identity with no watcher is usually a session between +runs, not a dead one, and the script cannot tell the difference. Retiring is +`disconnect --retire`, run from that session, for itself. Deleting a `.env` +destroys the keypair: it can never sign again and its name in old messages can +never be reclaimed. + +## Provisioning an agent that is not a Claude Code session + +`buzz-acp` runs goose, codex, `claude-agent-acp`, hermes and anything else that +speaks ACP. What each of them needs to reach a relay is identical, and it is +exactly what `buzz-connect.sh` already does for a session: a keypair, relay +membership, a published name, and channel membership. **buzz-acp does none of +it.** It never claims an invite, never publishes a profile and never joins a +channel — it assumes all four and, given none of them, boots to `no channel +subscriptions resolved — agent will sit idle`. + +```bash +scripts/buzz-agent-provision.sh [--channel ] [--command ] + [--owner ] [--auth-tag ] + [--force] +``` + +It prints 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 do +not put it in a terminal, a log, a shell history or `ps`. + +Three differences from a session identity, and they are why this is its own +command rather than a flag on `buzz-connect.sh`: + +1. **The name is given, not resolved.** There is no `/rename` to follow, so the + identity is deliberately **not** bound to any session id. `buzz-session.sh` + used to record `CLAUDE_CODE_SESSION_ID` even for an explicit name, which meant + a `/rename` in the terminal that provisioned an agent would rename the + daemon's identity out from under it. It no longer does. +2. **No watcher.** The harness is its own event loop; a Monitor would be a second + reader of the same channel. +3. **The output is configuration**, not a session that starts talking. + +`--command` is not validated against a list, because buzz-acp does not have one: +it normalises the command to an identity (basename, lowercased, `.exe`/`.cmd`/ +`.bat` dropped, space and `_` to `-`) and only 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 ` acp`. When the command is one buzz-acp does not know, the +env block sets `BUZZ_ACP_AGENT_ARGS=` explicitly and says why. + +`BUZZ_ACP_CHANNELS` does not join anything either. It narrows channels the +harness has already discovered from its own membership events, so a UUID it is +not a member of is dropped without a word. `--channel` is what makes the agent +hear anything. + +### Ownership, and the gap that has no bridge + +A self-enrolled agent lands in `relay_members` as `role: member` with no owner. +That costs more than it sounds like: + +- buzz-acp's `--respond-to` **defaults to `owner-only`**. An unowned agent under + the default gate forwards nothing — it connects and ignores everyone. +- `buzz agents draft-create` / `draft-update` fail with exit 3, have no `--owner` + flag, and end in a human's Buzz Desktop regardless. +- Agent turn metrics are rejected: the relay requires the `p` tag to be the + agent's registered owner. +- **`buzz mem` is not what breaks.** Every `mem` subcommand takes `--owner ` + and the relay gates engrams on author-or-`p`, not on a registered owner. + +An owner is a NIP-OA attestation — `["auth", , , +]` — and only the owner's **secret key** can produce one. There is no +CLI command to mint it, no relay endpoint to request one, and no event kind that +registers ownership. The one shipped tool is +`cargo run --release --example compute_auth_tag -- ""`. +So provisioning does the only honest thing: `--auth-tag` uses a real attestation, +`--owner` records the pubkey and says plainly that it is not the same thing, and +neither prints the full cost above rather than leaving it to be discovered. + +**The finding that matters: 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 +membership check returns `Member` and short-circuits before the attestation is +looked at, on both the HTTP event submit and the NIP-42 WS AUTH. So an agent that +claims an invite can never have an owner recorded, and relay membership has no +self-service exit, so that cannot be undone — only replaced with a fresh key. + +`--auth-tag` therefore **does not claim an invite**, and says so. The attested +agent reaches the relay through its owner, which needs the owner's pubkey to be a +relay member and the relay to run with `BUZZ_ALLOW_NIP_OA_AUTH`. If the key is +already a direct member, the output says the owner will never be recorded, what +still works (everything that reads the tag: buzz-acp owner resolution, +`--respond-to`, NIP-IA owner consent) and what stays refused. + +### Why this is not a mirrored skill + +The other skills in this repo are symlinked into `.agents/`, `.goose/` and +`.codex/`, and provisioning is runtime-agnostic, so mirroring looks right. It is +not, for two reasons. + +**The mirror is not a doc mirror, it is a shipping channel.** All four symlinks +point at `desktop/src-tauri/src/managed_agents/_skill.md`, which +`nest.rs:44` `include_str!`s into Buzz Desktop and installs for every managed +agent. Mirroring this would ship "mint a keypair, enrol it, publish a profile" +into agents that already have an identity Desktop minted and owns. That is a +capability increase aimed at the one audience that does not need it. + +**And it would have to split `lib.sh`.** Four of the six steps here are the same +functions `buzz-connect.sh` calls — `ensure_relay_membership`, `publish_profile`, +`resolve_channel`, `join_channel` — and the `BUZZ_AUTH_TAG` isolation that makes +auto-admit safe for an attested agent lives in the shared `_as_identity`. A +separate skill would either duplicate that or depend on this skill's scripts, and +there is no precedent for either: both mirrored skills are a single +self-contained `SKILL.md` with no scripts at all. + +So it stays here, `.claude/` only, alongside the identity code it is 90% made of. + +## The coordination protocol + +Message discipline is what keeps three agents from thrashing. Start every +message with one uppercase verb so peers (and the watcher's 400-char preview) +can triage without reading the whole thing. + +| Verb | Meaning | Example | +|------|---------|---------| +| `HELLO` | joining; who and where (sent for you by `buzz-connect.sh`) | `HELLO Auth Refactor A branch=feat/auth dir=wt-a` | +| `CLAIM` | taking exclusive ownership of a path glob | `CLAIM crates/buzz-auth/**` | +| `RELEASE` | done with a claim | `RELEASE crates/buzz-auth/**` | +| `STATUS` | progress, no reply needed | `STATUS tests green on auth` | +| `ASK` | question addressed to one peer | `ASK Auth Refactor B: did you rename Session?` | +| `ANSWER` | reply to an `ASK` | `ANSWER Auth Refactor A: yes, now AuthSession` | +| `BLOCKED` | stuck, needs someone | `BLOCKED waiting on RELEASE of migrations/**` | +| `DONE` | this session's work is finished | `DONE Auth Refactor A: pushed feat/auth` | + +Address peers by their session name — that is the name the user gave them with +`/rename` and the name on their Buzz profile, so it points at a session the +user can find. Prefer plain `@name` text over `--mention`: an unresolved or +ambiguous name **stops the send before publishing**. + +Rules that make it work: + +1. `HELLO` on arrival (automatic), `DONE` on exit (automatic — `leave` and + `disconnect` post it). A silent session is indistinguishable from a dead one, + so a session that vanishes without a `DONE` leaves peers waiting on it. +2. **`CLAIM` before editing shared paths.** If a peer has an unreleased `CLAIM` + overlapping yours, do not edit — `ASK` them or work elsewhere. Claims are + advisory; nothing enforces them but the agents. +3. Answer every `ASK` addressed to you, even with "don't know". A session + blocked on an unanswered question burns its whole budget waiting. +4. Never reply to your own message, and never `STATUS` on a timer — noise costs + every peer a notification and a wake-up. +5. `buzz-msg.sh read 50` before your first edit, to catch claims made before you + armed the watcher. + +## Scripts + +| Script | Role | +|--------|------| +| `buzz-connect.sh` | **the entry point.** `connect` / `join` / `status` / `leave` / `disconnect`, idempotently. | +| `buzz-msg.sh` | `send` / `read` on the coordination channel | +| `buzz-stream.sh` | **the receiver.** A daemon outside Monitor: holds the relay connection, filters, and appends notifications to `~/.buzz/stream/`. Started by `connect` and by the watcher | +| `buzz-watch.sh` | **the wake.** All Monitor runs: `tail -F` the receiver's log from a stored offset. `-` as the name resolves this session | +| `buzz-session.sh` | identity lifecycle — called by the others | +| `buzz-session-name.sh` | name resolution and sanitisation | +| `lib.sh` | shared helpers; sourced, never executed | + +Prerequisites: `buzz` on `PATH` or a release build in the checkout +(`cargo build --release -p buzz-cli`; override with `BUZZ_BIN`), `buzz-admin` +for keypair minting (`BUZZ_ADMIN_BIN`), and `python3` — already a `Justfile` +dependency — for JSON handling. + +Push delivery additionally needs a `buzz` that has `messages subscribe`. An +older binary — the one Buzz Desktop bundles, for instance — simply polls, and +says nothing about it because there is nothing wrong. Check with +`"$BUZZ" messages subscribe --help`. + +## Gotchas + +1. **One identity per session, never shared.** Two sessions on one key are + indistinguishable in the channel, and each filters out the other's messages + as "its own" — the two go permanently deaf to each other. +2. **`RUST_LOG` must not be `debug`/`trace` in a watcher shell** — tracing + output on stdout becomes notification spam. The scripts pin `error`. +3. **Watcher output is notifications, one line each.** Never widen its filter to + raw message dumps; Claude Code stops monitors that flood. `buzz messages + subscribe` writes raw NDJSON, which is a transport and not output — it must + always go through the filter, never straight to the Monitor. +4. **The relay URL's host:port must match the relay's configured community.** + `no community is configured for this host` is that mismatch, not a network + failure, and `buzz-connect.sh` says so. +5. **Secrets stay in the env file.** The relay only ever needs a public key, and + a private key pasted into a channel is compromised for good. +6. **The room is sticky.** Once a session runs `join `, a bare + `buzz-connect.sh` or `buzz-msg.sh` keeps using that room. That is the point — + but it means moving back is another `join`, not an omission. `leave` is what + clears the pin. +7. **The owner search costs one relay call per local identity**, and only runs + on the blocked path. `status --all` is how you find out what has accumulated + in `~/.buzz/sessions`; every one of those keys could authorise an admit. +8. **`--retire` is never implicit.** No verb archives an identity without it, + `leave` refuses the flag outright, and it only ever targets the running + session's own pubkey — there is no argument that could point it elsewhere. diff --git a/.claude/skills/buzz-multi-session/scripts/buzz-agent-provision.sh b/.claude/skills/buzz-multi-session/scripts/buzz-agent-provision.sh new file mode 100755 index 0000000000..76118ea662 --- /dev/null +++ b/.claude/skills/buzz-multi-session/scripts/buzz-agent-provision.sh @@ -0,0 +1,184 @@ +#!/usr/bin/env bash +# buzz-agent-provision.sh — give a non-Claude-Code agent an identity on the relay. +# +# buzz-agent-provision.sh [--channel ] [--command ] +# [--owner ] [--auth-tag ] +# [--force] +# +# buzz-acp runs goose, codex and other harnesses against a relay. What each of +# them needs to get there is identical, and it is exactly what buzz-connect.sh +# already automates for a Claude Code session: a keypair, relay membership, a +# published name, and channel membership. Doing that by hand is what keeps a +# hosted agent stuck. +# +# Three differences from a session identity, and they are the whole reason this +# is a separate command rather than a flag on buzz-connect.sh: +# +# 1. The name is given, not resolved. There is no /rename to follow and no +# transcript to read, so the identity is NOT bound to any session id — a +# later /rename in the terminal that provisioned it must not drag the +# agent's identity along with it. +# 2. No watcher. The harness is a daemon with its own event loop; a Monitor +# would be a second reader of the same channel. +# 3. The output is an env block for a Dockerfile, a fly secret or a systemd +# unit — not a session that starts talking. +# +# The private key is never printed. Its file path is, which is all anyone needs. +set -uo pipefail + +HERE="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib.sh +. "$HERE/lib.sh" + +usage() { + die "usage: $0 [--channel ] [--command ] + [--owner ] [--auth-tag ] [--force] + + the agent's name. It becomes ~/.buzz/sessions/.env and + the display name on the relay. + --channel join or create this channel and admit the agent to it + --command the buzz-acp harness this identity is for; recorded and + printed in the env block + --owner the human who owns this agent (see 'ownership' below) + --auth-tag a NIP-OA auth tag to record for the agent + --force re-publish the profile even if it has not changed" +} + +NAME="" +CHANNEL_ARG="" +COMMAND_ARG="" +OWNER_ARG="" +AUTH_TAG_ARG="" +FORCE=0 +while [ $# -gt 0 ]; do + case "$1" in + --channel) CHANNEL_ARG="${2:-}"; shift ;; + --command) COMMAND_ARG="${2:-}"; shift ;; + --owner) OWNER_ARG="${2:-}"; shift ;; + --auth-tag) AUTH_TAG_ARG="${2:-}"; shift ;; + --force) FORCE=1 ;; + -h|--help) usage ;; + -*) usage ;; + *) [ -n "$NAME" ] && usage; NAME="$1" ;; + esac + shift +done +[ -n "$NAME" ] || usage + +check_config_perms +require_buzz + +# --- 1. identity -------------------------------------------------------------- +# CLAUDE_CODE_SESSION_ID is deliberately dropped. buzz-session.sh binds an +# identity to the session that created it so a /rename can follow it; an agent +# identity must never be adopted that way, or renaming this terminal would rename +# a daemon's key out from under it. +IDENT=$(env -u CLAUDE_CODE_SESSION_ID "$HERE/buzz-session.sh" resolve "$NAME") || exit 1 +AGENT_NAME=$(printf '%s' "$IDENT" | cut -f1) +AGENT_DISPLAY=$(printf '%s' "$IDENT" | cut -f2) +PUBKEY=$(printf '%s' "$IDENT" | cut -f3) +IDFILE=$(printf '%s' "$IDENT" | cut -f4) +load_identity "$IDFILE" || die "could not load identity $IDFILE" +RELAY="${BUZZ_RELAY_URL:-http://localhost:3000}" +export RUST_LOG="${RUST_LOG:-error}" + +# --- 1b. ownership inputs, validated before anything is published ------------- +if [ -n "$OWNER_ARG" ]; then + case "$OWNER_ARG" in *[!0-9a-f]*) + die "--owner must be a 64-character lowercase hex pubkey" ;; + esac + [ "${#OWNER_ARG}" = 64 ] \ + || die "--owner must be a 64-character lowercase hex pubkey (got ${#OWNER_ARG})" +fi + +if [ -n "$AUTH_TAG_ARG" ]; then + TAG_OWNER=$(auth_tag_owner "$AUTH_TAG_ARG") || die \ +"--auth-tag is not a NIP-OA attestation. It must be exactly + [\"auth\", \"\", \"\", \"\"] +Mint one on a machine holding the owner's secret key: + cargo run --release --example compute_auth_tag -- $PUBKEY \"\"" + if [ -n "$OWNER_ARG" ] && [ "$OWNER_ARG" != "$TAG_OWNER" ]; then + die "--owner ($OWNER_ARG) is not the owner in --auth-tag ($TAG_OWNER). +Drop --owner: the tag is the authority, and a mismatch here would publish one +owner while attesting another." + fi + # Exported for every relay call below, deliberately. buzz verifies the tag + # against this identity's pubkey and refuses to run if it does not match, so a + # bad tag fails here rather than silently at deploy time; the profile publish + # then carries it onto the agent's kind:0, and the authenticated request is + # what makes the relay record users.agent_owner_pubkey. + export BUZZ_AUTH_TAG="$AUTH_TAG_ARG" +fi + +# Mark it as an agent so `buzz-connect.sh status --all` does not report it as a +# session that never ran. It is unbound on purpose. +meta_set "$AGENT_NAME" BUZZ_AGENT 1 +[ -n "$COMMAND_ARG" ] && meta_set "$AGENT_NAME" BUZZ_AGENT_COMMAND "$COMMAND_ARG" + +printf 'agent : %s\nidentity : %s\npubkey : %s\nrelay : %s\n' \ + "$AGENT_DISPLAY" "$AGENT_NAME" "$PUBKEY" "$RELAY" + +# --- 2. relay membership ------------------------------------------------------ +# With an attestation, whether this key is a DIRECT member decides whether the +# relay will ever record its owner, so find out before doing anything about it. +# The probe has to run without the tag: with it, Member and ViaOwner are the same +# 200 and the client cannot tell them apart. +DIRECT_MEMBER=0 +ALLOW_CLAIM=1 +if [ -n "$AUTH_TAG_ARG" ]; then + ALLOW_CLAIM=0 + SAVED_TAG="$BUZZ_AUTH_TAG" + unset BUZZ_AUTH_TAG + relay_probe && DIRECT_MEMBER=1 + export BUZZ_AUTH_TAG="$SAVED_TAG" +fi +ensure_relay_membership "$PUBKEY" "$RELAY" "$ALLOW_CLAIM" || exit $? + +# --- 3. profile --------------------------------------------------------------- +# No "Claude Code (...)" prefix: this is not a Claude Code session, and a wrong +# prefix in a channel listing is worse than none. +[ "$FORCE" = 1 ] && meta_unset "$AGENT_NAME" BUZZ_PROFILE_NAME +publish_profile "$AGENT_NAME" "$AGENT_DISPLAY" + +# --- 4. channel --------------------------------------------------------------- +if [ -n "$CHANNEL_ARG" ]; then + if ! resolve_channel "$CHANNEL_ARG" 1; then + note "could not find or create channel '${CHANNEL_NAME:-?}': ${BUZZ_ERR:-(no detail)}" + exit 2 + fi + if [ "$CHANNEL_CREATED" = 1 ]; then + echo "channel : created '$CHANNEL_NAME' ($CHANNEL)" + else + echo "channel : ${CHANNEL_NAME:-} ($CHANNEL)" + MEMBER="" + if buzz_run channels members --channel "$CHANNEL"; then + MEMBER=$(printf '%s' "$BUZZ_OUT" | ME="$PUBKEY" python3 -c ' +import json, os, sys +me = os.environ["ME"] +try: + rows = json.load(sys.stdin) +except Exception: + rows = [] +for row in rows if isinstance(rows, list) else []: + if isinstance(row, dict) and row.get("pubkey") == me: + sys.stdout.write("1"); break +') + fi + if [ -z "$MEMBER" ] \ + && ! join_channel "$CHANNEL" "${CHANNEL_NAME:-$CHANNEL}" "$PUBKEY"; then + diagnose_channel "$CHANNEL" "${CHANNEL_NAME:-$CHANNEL}" "$PUBKEY" "" + exit 4 + fi + fi + meta_set "$AGENT_NAME" BUZZ_SESSION_CHANNEL "$CHANNEL" + meta_set "$AGENT_NAME" BUZZ_SESSION_CHANNEL_NAME "${CHANNEL_NAME:-}" + meta_set "$AGENT_NAME" BUZZ_SESSION_CHANNEL_RELAY "$(relay_tag)" +fi + +# --- 5. ownership ------------------------------------------------------------- +agent_ownership_report "$AGENT_NAME" "$PUBKEY" "$OWNER_ARG" "$AUTH_TAG_ARG" \ + "$DIRECT_MEMBER" + +# --- 6. the env block --------------------------------------------------------- +agent_env_block "$AGENT_NAME" "$PUBKEY" "$RELAY" "$IDFILE" "$COMMAND_ARG" \ + "${CHANNEL:-}" "${CHANNEL_NAME:-}" diff --git a/.claude/skills/buzz-multi-session/scripts/buzz-connect.sh b/.claude/skills/buzz-multi-session/scripts/buzz-connect.sh new file mode 100755 index 0000000000..7fa89f68b1 --- /dev/null +++ b/.claude/skills/buzz-multi-session/scripts/buzz-connect.sh @@ -0,0 +1,410 @@ +#!/usr/bin/env bash +# buzz-connect.sh — this session's whole relationship with the coordination +# channel: joining it, checking it, and ending it. +# +# buzz-connect.sh [connect] connect (the default) +# buzz-connect.sh join open or enter a room for one piece of work +# buzz-connect.sh status [--all] am I connected, is the watcher alive +# buzz-connect.sh leave stop participating in the current channel +# buzz-connect.sh disconnect stop participating entirely +# +# The verbs live here rather than in a dispatcher because every one of them needs +# 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 re-implement them or immediately hand back here. +# +# Every flag still works, and the verbs are additions rather than replacements: +# `--status` is `status`, `--channel ` is `join `. +# +# buzz-connect.sh [--channel ] [--invite ] [--status] +# [--quiet-hello] [--all] [--leave-channel] [--retire] +# +# CONNECT (the default) does everything a session needs, in one step and +# idempotently: +# +# 1. resolves this session's name (the /rename title, see buzz-session-name.sh) +# 2. mints or adopts its Buzz identity, following a /rename rather than +# minting a second keypair +# 3. loads that identity itself — nothing is ever sourced by hand +# 4. enrols on the relay from an invite code if one is configured +# 5. publishes the display name so the session is findable in Buzz +# 6. finds or creates the coordination channel +# 7. announces HELLO +# 8. prints the exact Monitor command to arm, or reports the live watcher +# +# JOIN is connect with a room named: it joins that channel or creates it, +# admits this session, and pins the room so a bare `buzz-msg.sh send` posts +# there. The UUID is cached per name, so a machine can hold several rooms at +# once. If the channel already exists and its owner's key is in ~/.buzz/sessions, +# this session is admitted automatically and told so (BUZZ_AUTO_ADMIT=0 turns +# that off). +# +# LEAVE and DISCONNECT do only the two unambiguous things: post DONE so peers +# know this session is gone rather than slow, and print the TaskStop that stops +# the watcher. Everything that cannot be undone by re-running connect is an +# explicit opt-in: +# +# --leave-channel give up channel membership (`buzz channels leave`). On a +# private channel the owner must re-admit you afterwards. +# --retire archive this session's identity (NIP-IA kind:9035). For a +# throwaway worktree, never for anything resumable. +# +# STATUS --all lists every identity on this machine, whether the relay still +# counts it as a member, and whether anything is listening for it. It prunes +# nothing. +# +# The only step that cannot be automated is authorising a new pubkey on a closed +# relay with no invite code available. That produces one clearly worded ask. +# +# Configuration, all optional, from the environment or ~/.buzz/config +# (KEY=value, parsed not sourced, chmod 600 — it holds a bearer token): +# BUZZ_RELAY_URL relay base URL [http://localhost:3000] +# BUZZ_INVITE_CODE invite code every session self-enrols with +# BUZZ_COORD_CHANNEL_NAME default channel name [agent-coordination] +# BUZZ_AUTO_ADMIT 0 disables admitting with a local owner key [1] +# +# Anything a relay minted is cached per relay, because a channel UUID and an +# invite code are both meaningless on a different one — and a UUID is +# structurally valid everywhere, so the wrong relay is silent rather than an +# error. These keys are written, not set by hand: +# BUZZ_COORD_CHANNEL__ default channel's UUID on that relay +# BUZZ_CHANNEL___ a dedicated channel's UUID on that relay +# BUZZ_INVITE_CODE__ the code that worked on that relay +# The unscoped BUZZ_COORD_CHANNEL, BUZZ_CHANNEL_ and BUZZ_INVITE_CODE are +# still read, so an existing config keeps working, and are adopted into the +# scoped form on first use. +set -uo pipefail + +HERE="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib.sh +. "$HERE/lib.sh" + +USAGE="usage: $0 [connect|join |status|leave|disconnect] [options] + connect (default) join the current room and arm a watcher + join open or enter a room for one piece of work + status [--all] am I connected, is the watcher alive + leave [--leave-channel] stop participating in the current channel + disconnect [--leave-channel] [--retire] + stop participating entirely +options: --channel --invite --status --quiet-hello" + +# Verbs are an addition, not a replacement: --status is still status, and +# --channel is still join . A bare run is still connect. +VERB=connect +case "${1:-}" in + connect|status|leave|disconnect) VERB="$1"; shift ;; + join) + VERB="join"; shift + case "${1:-}" in + ''|-*) die "usage: $0 join " ;; + esac + CHANNEL_ARG="$1"; shift ;; +esac + +STATUS_ONLY=0 +SAY_HELLO=1 +CHANNEL_ARG="${CHANNEL_ARG:-}" +INVITE_ARG="" +SHOW_ALL=0 +LEAVE_CHANNEL=0 +RETIRE=0 +while [ $# -gt 0 ]; do + case "$1" in + --channel) CHANNEL_ARG="${2:-}"; shift ;; + --invite) INVITE_ARG="${2:-}"; shift ;; + --status) [ "$VERB" = connect ] && VERB=status ;; + --quiet-hello) SAY_HELLO=0 ;; + --all) SHOW_ALL=1 ;; + --leave-channel) LEAVE_CHANNEL=1 ;; + --retire) RETIRE=1 ;; + -h|--help) sed -n '2,66p' "$0"; exit 0 ;; + *) die "$USAGE" ;; + esac + shift +done + +# Refuse a flag that does not belong to the verb rather than ignoring it. A +# --retire that silently did nothing would be the worst possible outcome here, +# and so would one that fired on a verb the caller did not think was destructive. +case "$VERB" in + status) + [ "$RETIRE" = 0 ] && [ "$LEAVE_CHANNEL" = 0 ] \ + || die "--retire and --leave-channel are not status flags; see 'disconnect'" ;; + leave) + [ "$RETIRE" = 0 ] \ + || die "--retire is not a 'leave' flag. Leaving a room does not retire the +identity that was in it. If this session is finished for good: + $0 disconnect --retire" ;; + connect|join) + [ "$RETIRE" = 0 ] && [ "$LEAVE_CHANNEL" = 0 ] \ + || die "--retire and --leave-channel are teardown flags; see 'leave' and 'disconnect'" ;; +esac +[ "$VERB" = status ] || [ "$SHOW_ALL" = 0 ] || die "--all is a 'status' flag" +if [ "$VERB" = status ]; then STATUS_ONLY=1; SAY_HELLO=0; fi + +check_config_perms +require_buzz + +# --- 0. an invite handed in by the user -------------------------------------- +# What a human has is whatever "Invite to community → Copy link" put on their +# clipboard: a whole URL. Asking them to strip the code out of it by hand is the +# kind of step that makes this developer-only, so take either form, and persist +# it so this is the last time anyone is asked. +if [ -n "$INVITE_ARG" ]; then + code=${INVITE_ARG##*/invite/} # URL → code; a bare code is unchanged + code=${code%%[?#]*} # drop any query string or fragment + code=$(printf '%s' "$code" | tr -d '[:space:]') + case "$code" in + ""|*[!A-Za-z0-9._~-]*) + die "that does not look like an invite code or link. + Expected the whole link from Buzz Desktop → Invite to community → Copy link, + e.g. https://relay.example/invite/v2.abc123 — or just the code after /invite/." ;; + esac + # Saved against this relay AND unscoped. The scoped key is what a later relay + # change reads, so switching relays cannot retry a code the new one never + # minted; the unscoped one keeps older copies of these scripts working. + config_set "$(invite_cache_key)" "$code" + config_set BUZZ_INVITE_CODE "$code" + echo "invite : saved to $CONFIG_FILE for $(setting BUZZ_RELAY_URL '') —" + echo " every future session on this relay enrols itself" +fi + +# --- 1-3. identity ----------------------------------------------------------- +IDENT=$("$HERE/buzz-session.sh" resolve) || exit 1 +SESSION_NAME=$(printf '%s' "$IDENT" | cut -f1) +SESSION_DISPLAY=$(printf '%s' "$IDENT" | cut -f2) +PUBKEY=$(printf '%s' "$IDENT" | cut -f3) +IDFILE=$(printf '%s' "$IDENT" | cut -f4) +load_identity "$IDFILE" || die "could not load identity $IDFILE" +RELAY="${BUZZ_RELAY_URL:-http://localhost:3000}" +export RUST_LOG="${RUST_LOG:-error}" + +# The published name says what kind of member this is, not just which one. In a +# channel listing a bare "spec-kit-arch-governance-init" is indistinguishable +# from a human; "Claude Code (spec-kit-arch-governance-init)" tells a reader at +# a glance that it is an agent session and which terminal to go find. Override +# the prefix with BUZZ_PROFILE_PREFIX, or set it empty for the bare name. +PROFILE_PREFIX=$(setting BUZZ_PROFILE_PREFIX "Claude Code") +if [ -n "$PROFILE_PREFIX" ]; then + SESSION_DISPLAY="$PROFILE_PREFIX ($SESSION_DISPLAY)" +fi + +printf 'session : %s\nidentity : %s\npubkey : %s\nrelay : %s\n' \ + "$SESSION_DISPLAY" "$SESSION_NAME" "$PUBKEY" "$RELAY" + +# --- teardown ---------------------------------------------------------------- +# leave and disconnect stop here. They deliberately skip the relay probe, the +# profile publish and the auto-admit: a session that is going away should not +# enrol itself or get itself readmitted on the way out, and the two things that +# always have to happen — DONE, and stopping the watcher — must still happen when +# the relay is unreachable. +if [ "$VERB" = leave ] || [ "$VERB" = disconnect ]; then + if ! resolve_channel "$CHANNEL_ARG" 0; then + note "note: no channel to leave (looked for '${CHANNEL_NAME:-?}')." + note " Stopping the watcher and clearing local state anyway." + CHANNEL="" + else + echo "channel : ${CHANNEL_NAME:-} ($CHANNEL)" + fi + teardown "$VERB" "$LEAVE_CHANNEL" "$RETIRE" + exit $? +fi + +# --- 4. relay membership ----------------------------------------------------- +ensure_relay_membership "$PUBKEY" "$RELAY" || exit $? + +# --- 5. profile -------------------------------------------------------------- +publish_profile "$SESSION_NAME" "$SESSION_DISPLAY" + +# --- 6. channel -------------------------------------------------------------- +# status reports; it does not act. It must not create a channel and — the case +# that actually bites — it must not re-admit a session that has just left one, +# which would make `leave` look like it silently failed. +CREATE=1 +[ "$STATUS_ONLY" = 1 ] && CREATE=0 +if ! resolve_channel "$CHANNEL_ARG" "$CREATE"; then + if [ "$STATUS_ONLY" = 1 ]; then + echo "channel : none — this session is not in a room. Join one with" + echo " '$(basename "$0")' for the default channel, or" + echo " '$(basename "$0") join ' for a room of its own." + [ "$SHOW_ALL" = 1 ] && roster_report + exit 2 + fi + note "could not find or create channel '${CHANNEL_NAME:-?}': ${BUZZ_ERR:-(no detail)}" + exit 2 +fi +if [ "$CHANNEL_CREATED" = 1 ]; then + echo "channel : created '$CHANNEL_NAME' ($CHANNEL)" + echo " recorded as $CHANNEL_KEY in $CONFIG_FILE, so other" + echo " sessions on this machine join it rather than creating their own." +else + echo "channel : ${CHANNEL_NAME:-} ($CHANNEL)" +fi +# Which relay this channel belongs to is invisible in a UUID, and getting it +# wrong is silent, so status says it out loud. +if [ "$STATUS_ONLY" = 1 ]; then + echo " on $RELAY" + [ -n "${CHANNEL_KEY:-}" ] && echo " cached as $CHANNEL_KEY" +fi + +# --- 6b. channel membership -------------------------------------------------- +# The second gate. Relay membership does not imply channel membership, and the +# symptom of missing it is an empty channel with no error at all. +# +# A non-member gets [] from `channels members`, not a 403, so "empty list" and +# "not allowed to look" are the same response. Treat both as not-a-member and +# let join_channel work out whether it can be fixed here. +if [ "$CHANNEL_CREATED" = 0 ]; then + MEMBER="" + OWNER="" + if buzz_run channels members --channel "$CHANNEL"; then + CHECK=$(printf '%s' "$BUZZ_OUT" | ME="$PUBKEY" python3 -c ' +import json, os, sys +me = os.environ["ME"] +try: + rows = json.load(sys.stdin) +except Exception: + rows = [] +member, owner = "", "" +for row in rows if isinstance(rows, list) else []: + if not isinstance(row, dict): + continue + if row.get("pubkey") == me: + member = "1" + if row.get("role") == "owner" and not owner: + owner = row.get("pubkey") or "" +sys.stdout.write("%s\t%s" % (member, owner)) +') + MEMBER=$(printf '%s' "$CHECK" | cut -f1) + OWNER=$(printf '%s' "$CHECK" | cut -f2) + fi + if [ -z "$MEMBER" ]; then + if [ "$STATUS_ONLY" = 1 ]; then + echo "channel : NOT a member of '${CHANNEL_NAME:-$CHANNEL}'. Peers' messages" + echo " cannot reach this session and its sends will be refused." + echo " Rejoin with: $(basename "$0") join ${CHANNEL_NAME:-$CHANNEL}" + [ "$SHOW_ALL" = 1 ] && roster_report + exit 4 + fi + if ! join_channel "$CHANNEL" "${CHANNEL_NAME:-$CHANNEL}" "$PUBKEY"; then + diagnose_channel "$CHANNEL" "${CHANNEL_NAME:-$CHANNEL}" "$PUBKEY" "$OWNER" + exit 4 + fi + fi +fi + +# Pin the room to this session, so buzz-msg.sh posts where this session +# actually is. A session that opened a dedicated channel stays in it until it +# is pointed somewhere else with --channel; the machine-wide default is not +# allowed to drag it back. +meta_set "$SESSION_NAME" BUZZ_SESSION_CHANNEL "$CHANNEL" +meta_set "$SESSION_NAME" BUZZ_SESSION_CHANNEL_NAME "${CHANNEL_NAME:-}" +# The relay the pin belongs to. Without it, a relay change leaves the session +# posting a valid-looking UUID into a channel that does not exist there. +meta_set "$SESSION_NAME" BUZZ_SESSION_CHANNEL_RELAY "$(relay_tag)" + +# --- 7. HELLO ---------------------------------------------------------------- +if [ "$SAY_HELLO" = 1 ]; then + # --abbrev-ref prints "HEAD" *and* fails on an unborn branch; check the value. + BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null) + case "$BRANCH" in ''|HEAD) BRANCH=$(git branch --show-current 2>/dev/null) ;; esac + [ -n "$BRANCH" ] || BRANCH="(no branch)" + WHERE=$(basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)") + if buzz_run messages send --channel "$CHANNEL" \ + --content "HELLO $SESSION_DISPLAY branch=$BRANCH dir=$WHERE"; then + echo "hello : announced" + else + note "warning: HELLO failed: ${BUZZ_ERR:-(no detail)}" + fi +fi + +# --- 8. receiver -------------------------------------------------------------- +# Reception is started here, before anything is armed, and it is a separate +# concern from waking. It runs outside Monitor and keeps fetching messages into +# a log whatever happens to the Monitor task; see lib.sh for why. +WATCH_CMD="$HERE/buzz-watch.sh - $CHANNEL 5" +STREAM_LOG=$(stream_log "$SESSION_NAME" "$CHANNEL") +RECV_BAD=0 +case "$(receiver_state "$SESSION_NAME" "$CHANNEL")" in + live*) ;; + *) ensure_receiver "$SESSION_NAME" "$CHANNEL" 5 || RECV_BAD=1 ;; +esac +RSTATE=$(receiver_state "$SESSION_NAME" "$CHANNEL") +case "$RSTATE" in + live*) + echo "receiver : $(printf '%s' "$RSTATE" | awk '{print "live pid " $2 ", heartbeat " $3 "s ago"}')" + echo " queueing to $STREAM_LOG" ;; + *) + RECV_BAD=1 + cat >&2 </dev/null | tr -d ' ') + DONE=$(cat "$(stream_pos "$SESSION_NAME" "$CHANNEL")" 2>/dev/null) + case "$HAVE" in ''|*[!0-9]*) HAVE=0 ;; esac + case "$DONE" in ''|*[!0-9]*) DONE=0 ;; esac + [ "$HAVE" -gt "$DONE" ] && QUEUED=$(( HAVE - DONE )) +fi + +if [ "$STATUS_ONLY" = 1 ]; then + # Exit non-zero so "connected but deaf" is a checkable state, not prose. + echo "watcher : NOT ARMED — nothing will wake this session." + if [ "$QUEUED" != 0 ]; then + echo " $QUEUED message(s) already queued; arming delivers them." + fi + echo " Arm it with: Monitor(command: \"$WATCH_CMD\", persistent: true)" + [ "$SHOW_ALL" = 1 ] && roster_report + [ "$RECV_BAD" = 0 ] || exit 6 + exit 1 +fi + +cat <" + if [ "$ARGS" = "-" ]; then + buzz_run messages send --channel "$CHANNEL" --content - + else + buzz_run messages send --channel "$CHANNEL" --content "$ARGS" + fi + rc=$? + if [ "$rc" != 0 ]; then + note "send failed: ${BUZZ_ERR:-(no detail)}" + case "$BUZZ_ERR" in + *relay_membership_required*) diagnose_relay "$rc" "$BUZZ_ERR" "$PUBKEY" "$RELAY" ;; + *) diagnose_channel "$CHANNEL" "${CHANNEL_NAME:-$CHANNEL}" "$PUBKEY" "" ;; + esac + exit "$rc" + fi + echo "sent to ${CHANNEL_NAME:-$CHANNEL} as $SESSION_NAME" + ;; + + read) + LIMIT="${ARGS:-50}" + case "$LIMIT" in ''|*[!0-9]*) LIMIT=50 ;; esac + if ! buzz_run messages get --channel "$CHANNEL" --limit "$LIMIT"; then + rc=$? + diagnose_channel "$CHANNEL" "${CHANNEL_NAME:-$CHANNEL}" "$PUBKEY" "" + exit "$rc" + fi + LOG=$(printf '%s' "$BUZZ_OUT" | ME="$PUBKEY" python3 -c ' +import json, os, sys +me = os.environ["ME"] +try: + rows = json.load(sys.stdin) +except Exception: + rows = [] +rows = [r for r in rows if isinstance(r, dict)] +rows.sort(key=lambda r: r.get("created_at", 0)) +for r in rows: + who = r.get("pubkey", "") + tag = "you " if who == me else (who[:8] or "?") + body = " ".join((r.get("content") or "").split()) + print("%s %s" % (tag, body)) +') + # An empty channel is the classic symptom of the channel-membership gate, + # so say so rather than printing nothing and letting the agent guess. + if [ -n "$LOG" ]; then + printf '%s\n' "$LOG" + else + echo "(no messages in ${CHANNEL_NAME:-$CHANNEL})" + note "" + note " If peers say they have posted, this session is probably not a" + note " channel member — relay membership is a separate gate." + note " Check with: $HERE/buzz-connect.sh --status" + fi + ;; + + *) die "usage: $0 {send |read [limit]} [--channel ]" ;; +esac diff --git a/.claude/skills/buzz-multi-session/scripts/buzz-session-name.sh b/.claude/skills/buzz-multi-session/scripts/buzz-session-name.sh new file mode 100755 index 0000000000..efc6617367 --- /dev/null +++ b/.claude/skills/buzz-multi-session/scripts/buzz-session-name.sh @@ -0,0 +1,196 @@ +#!/usr/bin/env bash +# buzz-session-name.sh — resolve the name of the Claude Code session running it. +# +# buzz-session-name.sh print the identity slug (filename-safe) +# buzz-session-name.sh --display print the display name (human-readable) +# buzz-session-name.sh --both print "\t" +# +# A Buzz identity belongs to a *session*, not to a directory, so the name has to +# come from the session itself. Claude Code exports CLAUDE_CODE_SESSION_ID and +# writes the session transcript to +# ~/.claude/projects//.jsonl +# where the title set by /rename appears as "customTitle" (and "agentName"). +# /rename can be run repeatedly, so the LAST occurrence in the file wins. +# +# Resolution order — the first tier whose slug is non-empty wins, and both the +# slug and the display name are then derived from that same tier so the two can +# never disagree: +# +# 1. customTitle (else agentName) from this session's transcript +# 2. the git worktree directory name +# 3. session- +# 4. session- — no session id and no git repo +# +# This never fails and never prints an empty name: a session with no /rename, +# no session id and no git repo still gets a stable identity from tier 4. +# +# Sanitisation. A /rename title is free text — spaces, emoji, slashes, 300 +# characters of it. The slug is lowercased, reduced to [a-z0-9._-], collapsed, +# stripped of leading/trailing "-._" and cut to 64 chars, which makes path +# traversal impossible (a "/" can only ever become "-", and a leading ".." is +# stripped). The display name keeps the original characters — emoji included — +# with control characters removed, whitespace collapsed, and a 64-character cap. +set -uo pipefail + +MODE=slug +FROM_STDIN=0 +while [ $# -gt 0 ]; do + case "$1" in + ''|--slug) MODE=slug ;; + --display) MODE=display ;; + --both) MODE=both ;; + # --sanitize applies the rules below to a string on stdin instead of + # resolving one. Used for explicit names, which land in a filename too. + --sanitize) FROM_STDIN=1 ;; + -h|--help) sed -n '2,32p' "$0"; exit 0 ;; + *) printf 'usage: %s [--slug|--display|--both] [--sanitize]\n' "$0" >&2; exit 2 ;; + esac + shift +done + +TAB=$'\t' + +# --- sanitiser --------------------------------------------------------------- +# Reads the raw candidate on stdin, prints "\t". +PY_SANITIZE=' +import re, sys, unicodedata + +raw = sys.stdin.buffer.read().decode("utf-8", "replace") +kept = [] +for ch in raw: + cat = unicodedata.category(ch) + if ch in "\t\n\r" or cat in ("Zs", "Zl", "Zp"): + kept.append(" ") + elif cat in ("Cc", "Cf", "Cs", "Co", "Cn"): + continue # control, format, surrogate, private-use, unassigned + else: + kept.append(ch) + +display = re.sub(r"\s+", " ", "".join(kept)).strip()[:64].strip() + +slug = re.sub(r"[^A-Za-z0-9._-]+", "-", display).lower() +slug = re.sub(r"-{2,}", "-", slug) +slug = re.sub(r"\.{2,}", ".", slug) +slug = slug.strip("-._")[:64].strip("-._") + +sys.stdout.write("%s\t%s\n" % (slug, display)) +' + +HAVE_PYTHON=0 +command -v python3 >/dev/null 2>&1 && HAVE_PYTHON=1 + +sanitize() { + if [ "$HAVE_PYTHON" = 1 ]; then + printf '%s' "$1" | python3 -c "$PY_SANITIZE" 2>/dev/null && return 0 + fi + # Pure-shell fallback so the resolver still works without python3. Byte-wise, + # so non-ASCII collapses to "-" in the slug; the display keeps what tr leaves. + local s slug + s=$(printf '%s' "$1" | tr '\t\n\r' ' ' | tr -d '\000-\037\177') + s=$(printf '%s' "$s" | tr -s ' ' | sed 's/^ *//; s/ *$//' | cut -c1-64) + s=$(printf '%s' "$s" | sed 's/ *$//') + slug=$(printf '%s' "$s" | tr -c 'A-Za-z0-9._-' '-' | tr '[:upper:]' '[:lower:]' | tr -s '.-') + slug=$(printf '%s' "$slug" | sed 's/^[-._]*//; s/[-._]*$//' | cut -c1-64 | sed 's/[-._]*$//') + printf '%s\t%s\n' "$slug" "$s" +} + +emit() { # $1 slug, $2 display + case "$MODE" in + slug) printf '%s\n' "$1" ;; + display) printf '%s\n' "$2" ;; + both) printf '%s%s%s\n' "$1" "$TAB" "$2" ;; + esac + exit 0 +} + +# Accept a raw candidate; emit and exit if it sanitises to a usable slug. +consider() { + [ -n "${1:-}" ] || return 1 + local pair slug display + pair=$(sanitize "$1") || return 1 + slug=${pair%%"$TAB"*} + display=${pair#*"$TAB"} + [ -n "$slug" ] || return 1 + emit "$slug" "$display" +} + +if [ "$FROM_STDIN" = 1 ]; then + consider "$(cat)" + emit "" "" # sanitised to nothing — the caller decides what that means +fi + +# --- tier 1: the /rename title from this session's transcript ---------------- +PY_TITLE=' +import json, sys + +name = "" +with open(sys.argv[1], "rb") as fh: + for line in fh: + # Cheap byte pre-filter: transcripts run to megabytes and only a few + # lines carry a title, so do not pay for json.loads on every line. + if b"customTitle" not in line and b"agentName" not in line: + continue + try: + rec = json.loads(line.decode("utf-8", "replace")) + except Exception: + continue + if not isinstance(rec, dict): + continue + for key in ("customTitle", "agentName"): + val = rec.get(key) + if isinstance(val, str) and val.strip(): + name = val # last occurrence wins: /rename can be re-run + break +sys.stdout.write(name) +' + +transcript_title() { + local sid="${CLAUDE_CODE_SESSION_ID:-}" + [ -n "$sid" ] || return 1 + [ "$HAVE_PYTHON" = 1 ] || return 1 + # The id becomes a path component and a find pattern; refuse anything odd. + case "$sid" in + ''|*[!a-zA-Z0-9._-]*) return 1 ;; + esac + + local root="$HOME/.claude/projects" + [ -d "$root" ] || return 1 + + local candidates proj t title + proj=$(pwd -P 2>/dev/null | tr '/.' '--') + candidates="$root/$proj/$sid.jsonl" + # The session may have changed directory since it started, in which case the + # cwd-derived project directory is not where its transcript lives. + candidates="$candidates +$(find "$root" -maxdepth 2 -type f -name "$sid.jsonl" 2>/dev/null)" + + printf '%s\n' "$candidates" | while IFS= read -r t; do + [ -n "$t" ] && [ -f "$t" ] || continue + title=$(python3 -c "$PY_TITLE" "$t" 2>/dev/null) || continue + [ -n "$title" ] || continue + printf '%s' "$title" + break + done +} + +consider "$(transcript_title)" + +# --- tier 2: the git worktree directory -------------------------------------- +consider "$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null)" + +# --- tier 3: the session id -------------------------------------------------- +SID="$(printf '%s' "${CLAUDE_CODE_SESSION_ID:-}" | tr -cd 'a-zA-Z0-9' | cut -c1-8)" +[ -n "$SID" ] && consider "session-$SID" + +# --- tier 4: the working directory, hashed ----------------------------------- +# Stable across runs and distinct per directory, so two nameless sessions in +# different trees still get different identities. +if [ "$HAVE_PYTHON" = 1 ]; then + consider "session-$(pwd -P 2>/dev/null \ + | python3 -c 'import hashlib,sys;sys.stdout.write(hashlib.sha256(sys.stdin.buffer.read()).hexdigest()[:8])' 2>/dev/null)" +fi +consider "session-$(pwd -P 2>/dev/null | cksum | cut -d' ' -f1 | cut -c1-8)" + +# Unreachable in practice: cksum and cut are POSIX. Kept so the contract +# "never prints an empty name" holds even if it is. +emit "buzz-session" "buzz-session" diff --git a/.claude/skills/buzz-multi-session/scripts/buzz-session.sh b/.claude/skills/buzz-multi-session/scripts/buzz-session.sh new file mode 100755 index 0000000000..d8d3c5b01d --- /dev/null +++ b/.claude/skills/buzz-multi-session/scripts/buzz-session.sh @@ -0,0 +1,220 @@ +#!/usr/bin/env bash +# buzz-session.sh — the Buzz identity of one Claude Code session. +# +# buzz-session.sh ensure [name] mint or adopt this session's identity +# buzz-session.sh resolve [name] "\t\t\t" +# buzz-session.sh pubkey [name] the public key, safe to paste anywhere +# buzz-session.sh profile [name] publish the display name to the relay +# buzz-session.sh list every known session identity +# +# You normally do not run this. buzz-connect.sh calls it, and buzz-connect.sh is +# the skill's only entry point. +# +# The identity belongs to the SESSION, not to a directory. With no explicit +# name, the name comes from buzz-session-name.sh — the title set with /rename, +# falling back to the worktree directory and then the session id. The env file +# records CLAUDE_CODE_SESSION_ID, so when /rename changes the name the existing +# identity is renamed with it rather than a second keypair being minted. +# +# Identities live in ~/.buzz/sessions/.env (keys, sourced) plus +# .meta (session id and display name, never sourced), both mode 600. The +# secret key is never printed — only the public key, which is all a relay owner +# needs. +set -uo pipefail + +HERE="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib.sh +. "$HERE/lib.sh" + +NAME_SCRIPT="$HERE/buzz-session-name.sh" +TAB=$'\t' + +usage() { die "usage: $0 {ensure|resolve|pubkey|profile|list} [name] [--force]"; } + +FORCE=0 +cmd=${1:-ensure} +[ $# -gt 0 ] && shift +ARG="" +while [ $# -gt 0 ]; do + case "$1" in + --force) FORCE=1 ;; + -*) usage ;; + *) [ -n "$ARG" ] && usage; ARG="$1" ;; + esac + shift +done + +# --- name resolution --------------------------------------------------------- +SESSION_NAME="" +SESSION_DISPLAY="" + +resolve_names() { + if [ -n "$ARG" ]; then + # An explicit name still gets sanitised: it lands in a filename. + local pair + pair=$(printf '%s' "$ARG" | "$NAME_SCRIPT" --sanitize --both 2>/dev/null) + SESSION_NAME=${pair%%"$TAB"*} + SESSION_DISPLAY=${pair#*"$TAB"} + [ -n "$SESSION_NAME" ] || die "name '$ARG' sanitises to nothing — choose another" + return 0 + fi + local pair + pair=$("$NAME_SCRIPT" --both) + SESSION_NAME=${pair%%"$TAB"*} + SESSION_DISPLAY=${pair#*"$TAB"} + [ -n "$SESSION_NAME" ] || die "could not resolve a session name (this should be impossible)" +} + +# Follow a /rename: if this session already has an identity filed under a +# different name, move it rather than minting a second keypair. +adopt_for_session() { + [ -n "$ARG" ] && return 0 # explicit name: no adoption, no surprises + local old + old=$(identity_for_session) || return 0 + [ "$old" = "$SESSION_NAME" ] && return 0 + if [ -e "$(identity_file "$SESSION_NAME")" ]; then + note "note: '$SESSION_NAME' is already taken by another identity; keeping '$old'" + SESSION_NAME="$old" + return 0 + fi + mv "$(identity_file "$old")" "$(identity_file "$SESSION_NAME")" \ + || die "could not rename identity '$old' -> '$SESSION_NAME'" + [ -f "$(meta_file "$old")" ] && mv "$(meta_file "$old")" "$(meta_file "$SESSION_NAME")" + note "session renamed: $old -> $SESSION_NAME" +} + +mint() { + local file="$1" admin + admin=$(resolve_bin buzz-admin BUZZ_ADMIN_BIN) || die \ +"buzz-admin not found — it mints the keypair. + Fix: cargo build --release -p buzz-admin + Or: export BUZZ_ADMIN_BIN=/path/to/buzz-admin" + # The relay URL is written into a file that gets sourced, so it is checked + # rather than trusted, however it reached us. + case "$RELAY" in + *[!A-Za-z0-9:/._~%+-]*|'') die "relay URL '$RELAY' contains characters that are not allowed" ;; + esac + mkdir -p "$SESSION_DIR" + umask 077 + # generate-key prints 'Public key: ' / 'Secret key: '. Pipe it + # straight into the file writer so the secret never reaches a terminal, a + # shell variable, or the agent's transcript. + "$admin" generate-key | FILE="$file" RELAY="$RELAY" python3 -c ' +import os, re, sys +pub = sec = None +for line in sys.stdin: + m = re.search(r"([0-9a-f]{64})", line) + if not m: + continue + if "Public" in line: + pub = m.group(1) + elif "Secret" in line: + sec = m.group(1) +if not (pub and sec): + sys.exit("could not parse keypair from buzz-admin generate-key") +with open(os.environ["FILE"], "w") as fh: + fh.write( + "# Buzz session identity. Sourced, so it holds only hex keys and a URL;\n" + "# the display name lives in the .meta file beside it.\n" + "BUZZ_PRIVATE_KEY=%s\n" + "BUZZ_PUBKEY=%s\n" + "BUZZ_RELAY_URL=%s\n" % (sec, pub, os.environ["RELAY"]) + ) +' || die "keypair generation failed" + chmod 600 "$file" +} + +ensure_identity() { + resolve_names + adopt_for_session + RELAY=$(setting BUZZ_RELAY_URL "http://localhost:3000") + IDFILE=$(identity_file "$SESSION_NAME") + if [ ! -f "$IDFILE" ]; then + mint "$IDFILE" + IDENTITY_CREATED=1 + else + IDENTITY_CREATED=0 + fi + # Bind the identity to this session and keep the display name current, so a + # later /rename moves this identity instead of minting another one. + # + # Only for a resolved name. An explicit name already opts out of adoption, and + # binding it anyway would mean a /rename in the terminal that happened to run + # `ensure ` renames that identity too — which is wrong for a named agent + # that outlives the session, and is how a daemon loses its key. + [ -z "$ARG" ] && [ -n "${CLAUDE_CODE_SESSION_ID:-}" ] \ + && [ "$(meta_get "$SESSION_NAME" BUZZ_SESSION_ID)" != "${CLAUDE_CODE_SESSION_ID}" ] \ + && meta_set "$SESSION_NAME" BUZZ_SESSION_ID "$CLAUDE_CODE_SESSION_ID" + [ "$(meta_get "$SESSION_NAME" BUZZ_SESSION_DISPLAY_NAME)" = "$SESSION_DISPLAY" ] \ + || meta_set "$SESSION_NAME" BUZZ_SESSION_DISPLAY_NAME "$SESSION_DISPLAY" + + PUBKEY=$(identity_field "$IDFILE" BUZZ_PUBKEY) \ + || die "identity file $IDFILE has no BUZZ_PUBKEY — delete it and re-run" + RELAY=$(identity_field "$IDFILE" BUZZ_RELAY_URL || printf '%s' "$RELAY") + RELAY=$(setting BUZZ_RELAY_URL "$RELAY") +} + +# --- subcommands ------------------------------------------------------------- +case "$cmd" in + list) + found=0 + for f in "$SESSION_DIR"/*.env; do + [ -f "$f" ] || continue + found=1 + live="" + watcher_pid "$(basename "$f" .env)" >/dev/null 2>&1 && live=" [watching]" + printf '%-28s %s %s%s\n' \ + "$(basename "$f" .env)" \ + "$(identity_field "$f" BUZZ_PUBKEY || echo '?')" \ + "$(meta_get "$(basename "$f" .env)" BUZZ_SESSION_DISPLAY_NAME || echo '-')" \ + "$live" + done + [ "$found" = 1 ] || echo "(no session identities yet — run buzz-connect.sh)" + ;; + + resolve) + ensure_identity + printf '%s\t%s\t%s\t%s\n' "$SESSION_NAME" "$SESSION_DISPLAY" "$PUBKEY" "$IDFILE" + ;; + + pubkey) + ensure_identity + printf '%s\n' "$PUBKEY" + ;; + + profile) + ensure_identity + require_buzz + published=$(meta_get "$SESSION_NAME" BUZZ_PROFILE_NAME || printf '') + if [ "$published" = "$SESSION_DISPLAY" ] && [ "$FORCE" = 0 ]; then + echo "profile already published as '$SESSION_DISPLAY'" + exit 0 + fi + load_identity "$IDFILE" || die "could not load $IDFILE" + if buzz_run users set-profile --name "$SESSION_DISPLAY"; then + meta_set "$SESSION_NAME" BUZZ_PROFILE_NAME "$SESSION_DISPLAY" + echo "profile published: $SESSION_DISPLAY" + else + rc=$? + note "could not publish profile as '$SESSION_DISPLAY'" + diagnose_relay "$rc" "$BUZZ_ERR" "$PUBKEY" "$RELAY" + exit "$rc" + fi + ;; + + ensure|new) + ensure_identity + [ "$IDENTITY_CREATED" = 1 ] && echo "created identity '$SESSION_NAME'" \ + || echo "reusing identity '$SESSION_NAME'" + cat < [poll-seconds] +# +# The RECEIVER. Holds the relay connection, filters, and appends one line per new +# peer message to ~/.buzz/stream/..log. It is a daemon: it is +# started with nohup by buzz-connect.sh and by buzz-watch.sh, it runs outside any +# Monitor task, and it is meant to outlive both. +# +# Nothing reads its stdout. Waking a session is buzz-watch.sh's job — it tails +# this log under Monitor. The split is the guardrail: Monitor-hosted watchers +# have been seen dying (exit 144) where the same command under nohup did not, so +# the part that must never miss a message is kept out of Monitor entirely. A +# Monitor death now costs the wake, not the messages: they keep arriving here and +# re-arming replays them from the stored offset. +# +# --- how a message gets into the log ------------------------------------------ +# Two sources, never running at the same time, feeding one dedupe: +# +# PUSH `buzz messages subscribe` holds a NIP-42-authenticated WebSocket open +# and prints one JSON object per line the instant the relay pushes an +# event. Delivery is a socket write, not an interval. +# SWEEP `buzz messages get --since` over HTTP, once before every stream and +# again every time one ends. The safety net — and the ONLY source on a +# build of buzz with no `subscribe` verb, in which case this is exactly +# the polling loop this skill has always used. +# +# The sweep is not redundant. A subscription the relay has quietly stopped +# matching against is silent and still heartbeating, exactly like a quiet +# channel. `--reconnect-after` ends a healthy stream on a schedule so the sweep +# gets a turn to find out which one it was, and `--since` covers the gap while +# the socket was down. +# +# Seven details this encodes; do not "simplify" them away: +# 1. `buzz messages get --since ` is INCLUSIVE. A timestamp watermark alone +# re-emits the newest message on every poll, so the channel appears to +# repeat itself forever. Dedupe on event id; --since only bounds the query. +# That dedupe is also what lets push and HTTP feed one filter safely. +# 2. The seen-set is primed from history at startup, so starting a receiver +# does not append the whole backlog. A prime that FAILED is not a prime that +# found an empty channel — see below. +# 3. A session must never react to its own messages — filter on own pubkey. +# This is also why the log is per identity and not per channel. +# 4. The stream runs as a backgrounded job under `set -m`, waited on rather +# than run in the foreground. Bash defers a trap until a foreground command +# returns, so a foreground stream would ignore TERM for as long as it lived +# and leave an authenticated WebSocket behind. `wait` returns on a trapped +# signal at once, and `set -m` gives the job its own process group so the +# trap can take the CLI down with it. +# 5. Never run the stream inside $(...): command substitution captures stdout, +# and stdout is where the notifications are. +# 6. The heartbeat is what makes this observable. A receiver wedged on a socket +# is still a running process, so a pid check alone would call it healthy. +# 7. Losing push is written to the log, once, and so is getting it back. +set -uo pipefail +set -m # each background job in its own process group — see note 4 + +# Watchers hosted by Monitor have been observed exiting 144 (128+16 = SIGURG on +# Darwin). No mechanism is claimed, and one reading is already ruled out: bash +# ignores SIGURG by default, so a bare SIGURG cannot produce 144 on its own — +# verified on Darwin 25. This is cheap insurance, not a fix. An ignored +# disposition is inherited across fork AND exec, so it covers the CLI, python3 +# and every subshell below here. Nothing in this script wants SIGURG. +trap '' URG + +HERE="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib.sh +. "$HERE/lib.sh" + +NAME="${1:?usage: buzz-stream.sh [poll-seconds]}" +CH="${2:?usage: buzz-stream.sh [poll-seconds]}" +SLEEP="${3:-5}" + +require_buzz + +if [ "$NAME" = "-" ]; then + IDENT=$("$HERE/buzz-session.sh" resolve) || exit 1 + NAME=$(printf '%s' "$IDENT" | cut -f1) + ENV_FILE=$(printf '%s' "$IDENT" | cut -f4) +else + ENV_FILE=$(identity_file "$NAME") +fi +[ -f "$ENV_FILE" ] || die \ +"no identity '$NAME' in $SESSION_DIR — run $HERE/buzz-connect.sh first" + +load_identity "$ENV_FILE" || die "could not load $ENV_FILE" +export RUST_LOG="${RUST_LOG:-error}" # keep tracing out of the log + +mkdir -p "$STREAM_DIR" +chmod 700 "$STREAM_DIR" 2>/dev/null || true +LOG=$(stream_log "$NAME" "$CH") +ERR=$(stream_err "$NAME" "$CH") +HB=$(stream_hb "$NAME" "$CH") +PIDF=$(stream_pidf "$NAME" "$CH") +touch "$LOG" + +# Everything this process says goes to a file that outlives it. Monitor reaps its +# own task output, which is why three watcher deaths produced no diagnosis; this +# receiver is not under Monitor and its stderr is kept deliberately. +exec 2>>"$ERR" +printf '%s start pid=%s identity=%s channel=%s\n' \ + "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$$" "$NAME" "$CH" >&2 + +# --- one receiver per identity per channel ------------------------------------ +# Two receivers on one log would double every notification. `mkdir` is the lock +# because it is atomic; a lock whose recorded pid is gone was left by a receiver +# that was killed rather than asked to stop, and is taken over. +LOCK="$(stream_base "$NAME" "$CH").lock" +if ! mkdir "$LOCK" 2>/dev/null; then + OTHER=$(cat "$PIDF" 2>/dev/null) + case "$OTHER" in + ''|*[!0-9]*) ;; + *) if kill -0 "$OTHER" 2>/dev/null; then + printf '%s exit: receiver pid=%s already holds this channel\n' \ + "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$OTHER" >&2 + exit 0 + fi ;; + esac + printf '%s taking over a lock left by a dead receiver (pid=%s)\n' \ + "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "${OTHER:-unknown}" >&2 +fi +printf '%s\n' "$$" > "$PIDF" + +# --- clear out what a killed predecessor left running ------------------------- +# Nothing runs in a SIGKILLed process, so its stream job — an authenticated +# WebSocket and a filter, in their own process group — is orphaned and keeps +# appending to this very log. That is not merely a leak: alongside a fresh +# receiver it duplicates every message, because the orphan holds its own copy of +# a seen-set nothing will ever reconcile. Whoever starts next is the only thing +# that can clean it up, so this is where it happens. +SUBPGF="$(stream_base "$NAME" "$CH").subpg" +if [ -f "$SUBPGF" ]; then + OLDPG=$(cat "$SUBPGF" 2>/dev/null) + case "$OLDPG" in + ''|*[!0-9]*) ;; + *) if kill -0 -- "-$OLDPG" 2>/dev/null; then + printf '%s killing a stream job orphaned by a previous receiver (pgid=%s)\n' \ + "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$OLDPG" >&2 + kill -TERM -- "-$OLDPG" 2>/dev/null + fi ;; + esac + rm -f "$SUBPGF" +fi + +SEEN=$(mktemp -t buzz-stream-seen) +SUBERR=$(mktemp -t buzz-stream-err) +STREAM_JOB="" +HB_JOB="" +cleanup() { + local rc=$? + trap - EXIT INT TERM + [ -n "$STREAM_JOB" ] && kill -TERM -- "-$STREAM_JOB" 2>/dev/null + [ -n "$HB_JOB" ] && kill -TERM "$HB_JOB" 2>/dev/null + printf '%s exit status=%s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$rc" >> "$ERR" + rm -f "$SEEN" "$SUBERR" "$PIDF" "$HB" "$SUBPGF" + rmdir "$LOCK" 2>/dev/null + exit 0 +} +trap cleanup EXIT INT TERM + +# --- heartbeat ---------------------------------------------------------------- +# The only thing that distinguishes a working receiver from a wedged one. It has +# to tick faster than the loop, because a healthy stream blocks for minutes and +# a per-iteration heartbeat would look stale for most of that time. +# +# The loop condition is load-bearing. Nothing runs in a SIGKILLed process, so a +# receiver killed outright leaves this child behind, and a heartbeat that kept +# ticking for a pid that no longer exists made liveness flap between the orphan's +# writes and its replacement's. It stops within one tick of its parent going. +TICK=$(stream_tick) +PARENT=$$ +heartbeat() { + while kill -0 "$PARENT" 2>/dev/null; do + printf '%s\n%s\n' "$PARENT" "$(date +%s)" > "$HB" + sleep "$TICK" + done +} +heartbeat & +HB_JOB=$! + +# --- prime: everything already in the channel counts as backlog --------------- +# A prime that FAILS is not a prime that found an empty channel, and conflating +# them is expensive. An unreachable relay at start leaves the seen-set empty, and +# the first read that does succeed then appends the entire backlog as if it were +# news. So record the failure and let the first successful sweep do the priming. +# Nothing is lost by swallowing that batch: the relay was down, so nothing in it +# was posted after this receiver started. +SWEEP_MODE=array +if PRIME=$("$BUZZ" messages get --channel "$CH" --limit 200 2>/dev/null); then + printf '%s' "$PRIME" | python3 -c ' +import json, sys +try: + for m in json.load(sys.stdin): + if m.get("id"): + print(m["id"]) +except Exception: + pass +' > "$SEEN" 2>/dev/null || true +else + SWEEP_MODE=prime + note "could not read history to prime the seen-set — the first successful read" + note "will be treated as backlog rather than appended as new messages." +fi +unset PRIME + +# One filter for every source. MODE=array reads the JSON array `messages get` +# returns; MODE=stream reads the newline-delimited objects `messages subscribe` +# writes; MODE=prime reads an array and records it as seen without emitting. +# Everything after parsing is shared, so a message is logged exactly once +# whichever path carried it. +# +# Lines beginning with '#' are this script reporting on the connection. They are +# not events and never come from the relay. +FILTER=$(cat <<'PY' +import json, os, sys + +me = os.environ["ME"] +path = os.environ["SEEN"] +mode = os.environ.get("MODE", "array") + +with open(path) as fh: + seen = set(fh.read().split()) +fresh = [] + + +def remember(): + global fresh + if fresh: + with open(path, "a") as fh: + fh.write("\n".join(fresh) + "\n") + fresh = [] + + +def emit(m): + eid = m.get("id") + if not eid or eid in seen: + return + seen.add(eid) + fresh.append(eid) + if mode == "prime": # record as backlog, wake nobody + return + if m.get("pubkey") == me: # never react to our own messages + return + if m.get("kind") not in (9, 1): # chat kinds only + return + who = m.get("pubkey", "")[:8] + body = " ".join(m.get("content", "").split())[:400] + print("[buzz] %s: %s" % (who, body), flush=True) + + +if mode in ("array", "prime"): + try: + msgs = json.load(sys.stdin) + except Exception: + msgs = [] + for m in sorted(msgs, key=lambda x: x.get("created_at", 0)): + emit(m) +else: + # readline, not `for line in sys.stdin`: iteration reads ahead, and a line + # sitting in a read-ahead buffer is a message not yet in the log. + for line in iter(sys.stdin.readline, ""): + line = line.strip() + if not line: + continue + if line.startswith("#"): + print("[buzz] %s" % line[1:].strip(), flush=True) + continue + try: + emit(json.loads(line)) + except Exception: + continue + # Persist per event, not at exit: this process is killed, not asked to + # stop, and a seen-set lost on TERM replays the room on the next start. + remember() + +remember() +PY +) + +# run_filter MODE — one short-lived filter appending to the log. Line-buffered +# through the append so a tail sees each notification as it is written. +run_filter() { + ME="$BUZZ_PUBKEY" SEEN="$SEEN" MODE="$1" python3 -u -c "$FILTER" >> "$LOG" +} + +# say — a connection-state note, through the same filter so the wording +# that reaches a session is decided in exactly one place. +say() { printf '#%s\n' "$1" | run_filter stream; } + +# Does this build of buzz have the streaming verb? A prebuilt CLI predating it +# does not, and that must degrade to polling rather than fail: latency is a +# nicety, hearing your peers is not. +PUSH=0 +if "$BUZZ" messages subscribe --help >/dev/null 2>&1; then PUSH=1; fi +printf '%s mode=%s poll=%ss\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \ + "$([ "$PUSH" = 1 ] && echo push+sweep || echo poll-only)" "$SLEEP" >&2 + +# The sweep's --since watermark. Bounded by BUZZ_WATCH_WINDOW on the first pass +# only; after that it tracks the previous sweep, so however long a stream runs +# between sweeps it cannot open a hole the next query does not cover. The skew +# absorbs clock drift between this machine and the relay. +WINDOW="${BUZZ_WATCH_WINDOW:-300}" +SKEW=30 +LAST_SWEEP=$(( $(date +%s) - WINDOW )) + +# How long a healthy stream may run before it is torn down so a sweep can happen. +RESUB="${BUZZ_WATCH_RESUBSCRIBE:-300}" +# Silence longer than this, heartbeats included, means the socket is dead. The +# relay heartbeats every 30s, so this must clear several of those. +IDLE="${BUZZ_WATCH_IDLE:-90}" +# A stream that lasted this long definitely connected, authenticated and +# subscribed. It has to sit above the CLI's 20s NIP-42 challenge timeout, or a +# relay that accepts the TCP connection and never sends a challenge would score +# as a healthy connection. +UP_AFTER="${BUZZ_WATCH_UP_AFTER:-25}" + +FAILS=0 +DOWN=0 + +# stream_once — hold the WebSocket open until it stops delivering, then leave the +# seconds it lasted in STREAM_RAN. That duration is the only signal for telling +# "the relay is refusing us" from "the connection was fine and the schedule ended +# it": both arrive as a non-zero exit. +STREAM_RAN=0 +stream_once() { + local started resub="$RESUB" + # While degraded, cut the schedule right down. Recovery is only observable + # when a stream ENDS having lasted, so on the normal schedule a receiver that + # got its push path back would record that up to RESUB seconds late — and + # until then the log's last word is that it is polling, which is false. + [ "$DOWN" = 1 ] && resub=$(( UP_AFTER + 5 )) + started=$(date +%s) + : > "$SUBERR" + { + "$BUZZ" messages subscribe --channel "$CH" \ + --since "$(( LAST_SWEEP - SKEW ))" \ + --idle-timeout "$IDLE" --reconnect-after "$resub" 2>"$SUBERR" \ + | run_filter stream + } & + STREAM_JOB=$! + # Recorded so a successor can kill this group if this receiver is killed + # outright and never gets to clean up after itself. + printf '%s\n' "$STREAM_JOB" > "$SUBPGF" + wait "$STREAM_JOB" 2>/dev/null + STREAM_JOB="" + rm -f "$SUBPGF" + cat "$SUBERR" >> "$ERR" 2>/dev/null + STREAM_RAN=$(( $(date +%s) - started )) +} + +while true; do + # 1. Sweep. Closes whatever gap the last stream left behind, and carries the + # whole receiver when there is no push path. + NOW=$(date +%s) + OUT=$("$BUZZ" messages get --channel "$CH" --since "$(( LAST_SWEEP - SKEW ))" \ + --limit 200 2>/dev/null) || OUT="" + if [ -n "$OUT" ]; then + LAST_SWEEP="$NOW" + printf '%s' "$OUT" | run_filter "$SWEEP_MODE" + SWEEP_MODE=array # only the first read after a failed prime is backlog + fi + + if [ "$PUSH" != 1 ]; then + sleep "$SLEEP" + continue + fi + + # 2. Push. Blocks here for as long as the relay keeps the subscription alive. + stream_once + + # A stream that lasted was a working connection, whatever ended it. One that + # died sooner was refused, or never established. + if [ "$STREAM_RAN" -ge "$UP_AFTER" ]; then + FAILS=0 + if [ "$DOWN" = 1 ]; then + say "relay stream restored — back to push delivery" + DOWN=0 + fi + continue + fi + + FAILS=$(( FAILS + 1 )) + # Say it once, on the second consecutive failure. A single blip is not worth + # waking anyone for, and a flapping link must not become a notification storm. + if [ "$DOWN" = 0 ] && [ "$FAILS" -ge 2 ]; then + WHY=$(tr -d '\r' < "$SUBERR" | tail -n 1 | cut -c1-160) + say "relay stream is down, polling every ${SLEEP}s instead — ${WHY:-no detail}" + DOWN=1 + fi + + # Back off, but never past the poll interval: while the stream is down the + # sweep at the top of the loop is the only thing delivering, and slowing that + # below the interval the caller asked for would make this worse than polling. + BACKOFF=$(( FAILS * FAILS )) + [ "$BACKOFF" -gt "$SLEEP" ] && BACKOFF="$SLEEP" + sleep "$BACKOFF" +done diff --git a/.claude/skills/buzz-multi-session/scripts/buzz-watch.sh b/.claude/skills/buzz-multi-session/scripts/buzz-watch.sh new file mode 100755 index 0000000000..cdabb758a8 --- /dev/null +++ b/.claude/skills/buzz-multi-session/scripts/buzz-watch.sh @@ -0,0 +1,160 @@ +#!/usr/bin/env bash +# buzz-watch.sh [session-name|-] [poll-seconds] +# +# The WAKE path, and nothing else. Designed to be the `command` of Claude Code's +# Monitor tool with persistent: true — each stdout line becomes one notification, +# so this must be quiet unless something genuinely new arrived. +# +# It does not talk to the relay. `buzz-stream.sh` does that, as a daemon outside +# Monitor, appending one line per new peer message to +# ~/.buzz/stream/..log. All this does is make sure that +# receiver is running and then `tail -f` the log from a stored line offset. +# +# That split is the guardrail. Monitor-hosted watchers have been observed dying +# with exit 144 where the identical command under nohup stayed healthy on the +# same channel, and Monitor reaps a task's output before anyone can read it, so +# three deaths produced no diagnosis. Rather than explain it, this removes the +# consequence: if this process dies, messages keep landing in the log, and +# re-arming replays every one of them from the offset. What a Monitor death now +# costs is the wake, not the messages. +# +# Interface is unchanged: same three arguments, same one-line-per-message output, +# and the same `-` for "resolve this session's identity yourself", so a Monitor +# command recorded before the split still works after it. +# +# Three details this encodes; do not "simplify" them away: +# 1. The offset is persisted per line delivered, not at exit. This process is +# killed rather than asked to stop, and an offset lost on death means the +# next arm either replays the whole log or skips what it missed. +# 2. `tail -n +K` counts lines, not bytes. Byte offsets and multi-byte content +# drift apart; line counts do not. +# 3. The receiver is (re)started here as well as at connect. Whichever runs +# later is the one that repairs it, and re-arming a watcher is exactly when +# a session is asking to be able to hear again. +set -uo pipefail + +# See buzz-stream.sh: bash ignores SIGURG by default, so it cannot by itself +# explain exit 144 — this is inherited-across-exec insurance, not a fix. +trap '' URG + +HERE="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib.sh +. "$HERE/lib.sh" + +NAME="${1:?usage: buzz-watch.sh [session-name|-] [poll-seconds]}" +CH="${2:?usage: buzz-watch.sh [session-name|-] [poll-seconds]}" +SLEEP="${3:-5}" + +if [ "$NAME" = "-" ]; then + IDENT=$("$HERE/buzz-session.sh" resolve) || exit 1 + NAME=$(printf '%s' "$IDENT" | cut -f1) +fi +[ -f "$(identity_file "$NAME")" ] || die \ +"no identity '$NAME' in $SESSION_DIR — run $HERE/buzz-connect.sh first" + +LOG=$(stream_log "$NAME" "$CH") +POS=$(stream_pos "$NAME" "$CH") + +# --- the receiver has to exist before there is anything to tail --------------- +if ! ensure_receiver "$NAME" "$CH" "$SLEEP"; then + die "could not start the receiver for '$NAME' on ${CH:0:8}. + Its own log is the only place this is explained: + $(stream_err "$NAME" "$CH") + Nothing is listening and nothing is being fetched. Fix that before relying on + this channel — until then peers cannot reach this session at all." +fi + +# Liveness marker so buzz-connect.sh status can tell "watcher not armed" from +# "watcher armed and the channel is quiet" — two states that look identical. +MARKER=$(watch_marker "$NAME") +mkdir -p "$SESSION_DIR" +printf '%s\n%s\n' "$$" "$CH" > "$MARKER" + +TAIL_PID="" +TAILPIDF="$(stream_base "$NAME" "$CH").tailpid" +cleanup() { + trap - EXIT INT TERM + [ -n "$TAIL_PID" ] && kill -TERM "$TAIL_PID" 2>/dev/null + rm -f "$MARKER" "$TAILPIDF" + exit 0 +} +trap cleanup EXIT INT TERM + +# A tail left behind by a watcher that was killed rather than stopped holds the +# log open and would double every notification once a new watcher arms. Nothing +# runs in a SIGKILLed process, so this is where that is cleaned up: on the way +# in, by whoever arms next. +if [ -f "$TAILPIDF" ]; then + OLD=$(cat "$TAILPIDF" 2>/dev/null) + case "$OLD" in + ''|*[!0-9]*) ;; + *) if kill -0 "$OLD" 2>/dev/null; then + note "stopping a tail left by a previous watcher (pid $OLD)" + kill -TERM "$OLD" 2>/dev/null + fi ;; + esac + rm -f "$TAILPIDF" +fi + +# --- where to resume from ----------------------------------------------------- +# No offset file means this session has never armed a watcher on this channel, so +# start at the end: the log may hold everything since connect, and arming for the +# first time must not replay it as a burst. +# +# An offset that exists is the promise this whole design makes. Everything the +# receiver appended while no Monitor was alive is delivered now, in order, once. +if [ -f "$POS" ]; then + START=$(cat "$POS" 2>/dev/null) + case "$START" in ''|*[!0-9]*) START=0 ;; esac +else + START=$(wc -l < "$LOG" 2>/dev/null | tr -d ' ') + case "$START" in ''|*[!0-9]*) START=0 ;; esac + printf '%s\n' "$START" > "$POS" +fi + +# A log that was truncated or replaced under a stored offset would silently skip +# everything up to it. Restart from the beginning of the new log instead. +HAVE=$(wc -l < "$LOG" 2>/dev/null | tr -d ' ') +case "$HAVE" in ''|*[!0-9]*) HAVE=0 ;; esac +if [ "$START" -gt "$HAVE" ]; then + note "stored offset $START is past the end of the log ($HAVE lines) — it was" + note "rotated or replaced. Resuming from the start of the current log." + START=0 + printf '%s\n' "$START" > "$POS" +fi + +# `tail -n +K` starts AT line K, so the first undelivered line is START+1. -F +# rather than -f so a rotated log is picked up instead of tailing a stale inode. +# +# The tail feeds a FIFO and the delivery loop runs in THIS shell rather than in a +# pipeline subshell. That is not a style choice, and reverting it silently breaks +# the guarantee this whole design exists for: +# +# - A pipeline subshell survives its parent. When the watcher was SIGKILLed — +# which is what an unexplained exit 144 looks like from the outside — the +# orphaned subshell went on reading the log and advancing the offset with +# nobody receiving the lines. Re-arming then resumed past messages that had +# never been delivered: silent loss, caused by the very code meant to +# prevent it. Observed, not theorised. +# - A loop in this shell dies exactly when this shell dies, so the offset can +# never run ahead of what was delivered. +# - `read` is a builtin, so bash services a trapped signal while it is blocked. +# A foreground external command would defer the trap forever and leave the +# marker claiming a watcher that is gone. +FIFO=$(mktemp -u -t buzz-watch-fifo) +mkfifo "$FIFO" || die "could not create $FIFO" +tail -n "+$(( START + 1 ))" -F "$LOG" 2>/dev/null > "$FIFO" & +TAIL_PID=$! +printf '%s\n' "$TAIL_PID" > "$TAILPIDF" +exec 3< "$FIFO" +rm -f "$FIFO" # unlinked; both ends stay open until this process exits + +DELIVERED="$START" +while IFS= read -r line <&3; do + # Advance the offset only after the line is actually out. If the consumer has + # gone, stop rather than mark undelivered messages as delivered — the next arm + # is then a replay, which is recoverable, instead of a gap, which is not. + printf '%s\n' "$line" || break + DELIVERED=$(( DELIVERED + 1 )) + printf '%s\n' "$DELIVERED" > "$POS" +done diff --git a/.claude/skills/buzz-multi-session/scripts/lib.sh b/.claude/skills/buzz-multi-session/scripts/lib.sh new file mode 100644 index 0000000000..34a03482b0 --- /dev/null +++ b/.claude/skills/buzz-multi-session/scripts/lib.sh @@ -0,0 +1,1422 @@ +# lib.sh — shared helpers for the buzz-multi-session scripts. +# Sourced, never executed. Every function here is safe to call more than once. +# shellcheck shell=bash + +# Directory holding these scripts, regardless of the caller's cwd. +BUZZ_SKILL_SCRIPTS="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +export BUZZ_SKILL_SCRIPTS + +SESSION_DIR="${BUZZ_SESSION_DIR:-$HOME/.buzz/sessions}" +CONFIG_FILE="${BUZZ_CONFIG:-$HOME/.buzz/config}" + +note() { printf '%s\n' "$*" >&2; } +die() { printf '%s\n' "$*" >&2; exit 1; } + +# --- configuration ----------------------------------------------------------- +# ~/.buzz/config is shared by every session on the machine. It is parsed, never +# sourced — it holds an invite code, so it must not be able to run code. +# Format: KEY=value, one per line, '#' comments, blank lines ignored. +config_get() { + local key="$1" line + [ -f "$CONFIG_FILE" ] || return 1 + line=$(grep -E "^[[:space:]]*${key}[[:space:]]*=" "$CONFIG_FILE" 2>/dev/null | tail -n 1) || return 1 + [ -n "$line" ] || return 1 + line=${line#*=} + # trim surrounding whitespace and one layer of quotes + line=$(printf '%s' "$line" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' \ + -e 's/^"\(.*\)"$/\1/' -e "s/^'\(.*\)'\$/\1/") + [ -n "$line" ] || return 1 + printf '%s' "$line" +} + +# setting KEY [DEFAULT] — environment wins, then ~/.buzz/config, then default. +setting() { + local key="$1" default="${2:-}" val + val="${!key:-}" + [ -n "$val" ] && { printf '%s' "$val"; return 0; } + val=$(config_get "$key") && { printf '%s' "$val"; return 0; } + printf '%s' "$default" +} + +# config_set KEY VALUE — record a value for every session on this machine. +# This is what stops the second session creating a duplicate channel: a private +# channel is invisible to a non-member, so "find it by name" cannot work and the +# creator has to publish the UUID somewhere the others read. +config_set() { + local key="$1" val="$2" tmp + mkdir -p "$(dirname "$CONFIG_FILE")" + [ -f "$CONFIG_FILE" ] || { : > "$CONFIG_FILE"; chmod 600 "$CONFIG_FILE"; } + tmp=$(mktemp -t buzz-config) || return 1 + chmod 600 "$tmp" + KEY="$key" VAL="$val" python3 -c ' +import os, re, sys +key, val = os.environ["KEY"], os.environ["VAL"] +out, seen = [], False +for line in open(sys.argv[1]): + if re.match(r"^\s*%s\s*=" % re.escape(key), line): + if seen: + continue + out.append("%s=%s\n" % (key, val)); seen = True + else: + out.append(line) +if not seen: + out.append("%s=%s\n" % (key, val)) +sys.stdout.write("".join(out)) +' "$CONFIG_FILE" > "$tmp" && mv "$tmp" "$CONFIG_FILE" && chmod 600 "$CONFIG_FILE" +} + +# Warn once if the config file is world/group readable — it can hold an invite +# code, which is a bearer token for relay membership. +check_config_perms() { + [ -f "$CONFIG_FILE" ] || return 0 + local mode + # shellcheck disable=SC2012 # fixed filename; ls -l mode chars are portable + mode=$(ls -l "$CONFIG_FILE" 2>/dev/null | cut -c5-10) + case "$mode" in + ---------|'') ;; + *[rwx]*) note "warning: $CONFIG_FILE is readable by others — chmod 600 it (it can hold an invite code)" ;; + esac +} + +# --- binaries ---------------------------------------------------------------- +# PATH first, then a release/debug build in the enclosing checkout, which is the +# common case for someone hacking on block/buzz. +resolve_bin() { + local name="$1" var="$2" found root cand + found="${!var:-}" + if [ -n "$found" ]; then printf '%s' "$found"; return 0; fi + # ~/.buzz/config outranks PATH deliberately. 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's features. That shadowing is + # invisible and produces a wrong-but-plausible failure: the script reports the + # feature missing, which is true of the binary it picked and false of the one + # the user configured. Config also lets a session coordinating worktrees of + # some *other* repo find binaries that live in a Buzz checkout. + if found=$(config_get "$var") && [ -n "$found" ]; then + printf '%s' "$found"; return 0 + fi + if found=$(command -v "$name" 2>/dev/null); then printf '%s' "$found"; return 0; fi + if root=$(git rev-parse --show-toplevel 2>/dev/null); then + for cand in "$root/target/release/$name" "$root/target/debug/$name"; do + [ -x "$cand" ] && { printf '%s' "$cand"; return 0; } + done + fi + return 1 +} + +require_buzz() { + BUZZ=$(resolve_bin buzz BUZZ_BIN) || die \ +"buzz not found. + Fix: cargo build --release -p buzz-cli (then it is picked up from target/release) + Or: export BUZZ_BIN=/path/to/buzz" + export BUZZ +} + +# --- session identities ------------------------------------------------------ +# Two files per identity, and the split is deliberate: +# +# .env sourced into the environment, so it holds ONLY shell-safe +# values: two 64-char hex keys and a validated relay URL. +# .meta never sourced, only grepped. Everything derived from free text +# lives here — a /rename title is arbitrary user input, and a +# title like "x$(rm -rf ~)" in a sourced file would execute. +identity_file() { printf '%s/%s.env' "$SESSION_DIR" "$1"; } +meta_file() { printf '%s/%s.meta' "$SESSION_DIR" "$1"; } + +# field FILE KEY — read one value without exposing the rest of the file. +identity_field() { + [ -f "$1" ] || return 1 + local v + v=$(grep -E "^$2=" "$1" 2>/dev/null | tail -n 1 | cut -d= -f2-) || return 1 + [ -n "$v" ] || return 1 + printf '%s' "$v" +} + +# meta_get NAME KEY / meta_set NAME KEY VALUE — the free-text sidecar. +meta_get() { identity_field "$(meta_file "$1")" "$2"; } + +meta_set() { + local f tmp + f=$(meta_file "$1") + mkdir -p "$SESSION_DIR" + [ -f "$f" ] || { : > "$f"; chmod 600 "$f"; } + tmp=$(mktemp -t buzz-meta) || return 1 + chmod 600 "$tmp" + KEY="$2" VAL="$3" python3 -c ' +import os, sys +key, val = os.environ["KEY"], os.environ["VAL"].replace("\n", " ") +out, seen = [], False +for line in open(sys.argv[1]): + if line.startswith(key + "="): + if seen: + continue + out.append("%s=%s\n" % (key, val)); seen = True + else: + out.append(line) +if not seen: + out.append("%s=%s\n" % (key, val)) +sys.stdout.write("".join(out)) +' "$f" > "$tmp" && mv "$tmp" "$f" && chmod 600 "$f" +} + +# meta_unset NAME KEY — drop a key. Used when a session leaves a room: a stale +# pin would keep buzz-msg.sh posting into a channel this session is no longer in. +meta_unset() { + local f tmp + f=$(meta_file "$1") + [ -f "$f" ] || return 0 + tmp=$(mktemp -t buzz-meta) || return 1 + chmod 600 "$tmp" + KEY="$2" python3 -c ' +import os, sys +key = os.environ["KEY"] + "=" +with open(sys.argv[1]) as fh: + sys.stdout.write("".join(l for l in fh if not l.startswith(key))) +' "$f" > "$tmp" && mv "$tmp" "$f" && chmod 600 "$f" +} + +# The identity belongs to the session, not to its current name: /rename changes +# the name, so look the identity up by the session id it was minted under. +identity_for_session() { + local sid="${CLAUDE_CODE_SESSION_ID:-}" f + [ -n "$sid" ] || return 1 + [ -d "$SESSION_DIR" ] || return 1 + for f in "$SESSION_DIR"/*.meta; do + [ -f "$f" ] || continue + if [ "$(identity_field "$f" BUZZ_SESSION_ID 2>/dev/null)" = "$sid" ]; then + printf '%s' "$(basename "$f" .meta)"; return 0 + fi + done + return 1 +} + +# Load an identity into the environment. Scripts call this themselves — nobody +# is ever told to `set -a; . file; set +a` by hand. +# +# The file is validated before it is sourced. Anything other than a comment or +# one of the three known keys with a conservative value is refused rather than +# executed: a sourced file is code, and this one is written by a script. +load_identity() { + local f="$1" env_relay="${BUZZ_RELAY_URL:-}" cfg_relay bad minted + [ -f "$f" ] || return 1 + # Read the config's relay BEFORE sourcing, because sourcing puts the file's + # value in the environment and `setting` would then just read it back. + cfg_relay=$(config_get BUZZ_RELAY_URL || printf '') + bad=$(grep -vE '^[[:space:]]*(#.*)?$|^BUZZ_(PRIVATE_KEY|PUBKEY)=[0-9a-fA-F]{64}$|^BUZZ_RELAY_URL=[A-Za-z0-9:/._~%+-]+$' "$f") + if [ -n "$bad" ]; then + note "refusing to source $f — unexpected content. Delete it and re-run buzz-connect.sh." + return 1 + fi + set -a + # shellcheck disable=SC1090 # runtime path, one identity file per session + . "$f" + set +a + minted="${BUZZ_RELAY_URL:-}" + # The relay in the identity file is a record of where the key was MINTED, not + # a configuration source. It must not outrank the machine's current config: + # before this, editing BUZZ_RELAY_URL in ~/.buzz/config did nothing at all for + # any existing identity, and every session silently kept talking to the relay + # it was born on. Precedence is environment, then config, then the mint record. + if [ -n "$env_relay" ]; then + export BUZZ_RELAY_URL="$env_relay" + elif [ -n "$cfg_relay" ]; then + export BUZZ_RELAY_URL="$cfg_relay" + fi + # A keypair is relay-agnostic, but membership is not: the same key is a + # stranger on a relay it was never enrolled on. Say so rather than letting it + # surface as an unexplained relay_membership_required. + if [ -n "$minted" ] && [ "$minted" != "${BUZZ_RELAY_URL:-}" ]; then + note "relay differs from where this identity was minted." + note " minted on : $minted" + note " using : ${BUZZ_RELAY_URL:-}" + note " The keypair carries over; relay membership does not. This identity" + note " needs enrolling on the new relay, and channel UUIDs from the old one" + note " mean nothing here. The mint record is left alone, so switching back" + note " needs no repair." + fi + return 0 +} + +# --- running the CLI --------------------------------------------------------- +# buzz_run — stdout in BUZZ_OUT, stderr in BUZZ_ERR, status returned. +# shellcheck disable=SC2034 # both are read by the scripts that source this +BUZZ_OUT="" +# shellcheck disable=SC2034 +BUZZ_ERR="" +buzz_run() { + local err rc + err=$(mktemp -t buzz-err) || return 127 + BUZZ_OUT=$("$BUZZ" "$@" 2>"$err"); rc=$? + # shellcheck disable=SC2034 # read by the scripts that source this + BUZZ_ERR=$(cat "$err" 2>/dev/null) + rm -f "$err" + return $rc +} + +# --- getting onto the relay --------------------------------------------------- +# Shared by buzz-connect.sh and buzz-agent-provision.sh, because a session and a +# daemon agent need exactly the same thing here: a key the relay will accept. +# Needs $BUZZ and an already-loaded identity. + +# A single cheap authenticated read is the membership probe. +relay_probe() { buzz_run channels list --limit 1; } + +# An invite is minted by one relay and is meaningless to another, so the code is +# cached per relay too. The unscoped BUZZ_INVITE_CODE is still honoured — it is +# what every existing config has — but only as a fallback. +invite_cache_key() { printf 'BUZZ_INVITE_CODE__%s' "$(relay_tag)"; } +invite_code_for_relay() { + local code + code=$(setting "$(invite_cache_key)" "") + [ -n "$code" ] || code=$(setting BUZZ_INVITE_CODE "") + printf '%s' "$code" +} + +# ensure_relay_membership PUBKEY RELAY [ALLOW_CLAIM] +# Returns 0 when the relay accepts this key, having claimed the configured invite +# if that was what was missing. On failure it has already printed the diagnosis, +# and its status is the exit code the caller should use. +# +# ALLOW_CLAIM defaults to 1. Provisioning an owner-attested agent passes 0, +# because claiming an invite makes the key a direct relay member and a direct +# member's owner is never recorded — see agent_ownership_report. +ensure_relay_membership() { + local pubkey="$1" relay="$2" allow_claim="${3:-1}" rc claimed=0 code + # Capture the status directly: after `if ! cmd`, $? is the negation, not cmd's. + relay_probe; rc=$? + if [ "$rc" != 0 ]; then + case "$BUZZ_ERR" in + *relay_membership_required*) + code=$(invite_code_for_relay) + if [ "$allow_claim" = 0 ]; then + code="" + note "" + note " Not claiming the configured invite: this agent has a NIP-OA" + note " attestation, and a key that enrols itself becomes a direct relay" + note " member, whose owner the relay then never records." + fi + if [ -n "$code" ]; then + if "$BUZZ" invites --help >/dev/null 2>&1; then + if buzz_run invites claim --code "$code"; then + echo "relay : enrolled from the configured invite code" + # Pin the code to this relay, so a later relay change does not + # silently retry a code that cannot work there. + config_set "$(invite_cache_key)" "$code" + claimed=1 + else + note "invite claim failed: ${BUZZ_ERR:-(no detail)}" + if [ "$(setting "$(invite_cache_key)" "")" != "$code" ]; then + note "" + note " That code is not recorded against this relay — it came from" + note " the unscoped BUZZ_INVITE_CODE, which an earlier setup wrote" + note " for whatever relay was configured then. An invite is minted" + note " by one relay and is meaningless to another, so if you have" + note " switched relays this is expected, not a broken code." + note " relay: $relay" + note " Ask for a fresh link from THIS relay and run:" + note " buzz-connect.sh --invite \"\"" + fi + fi + else + note "" + note " An invite code is configured but this build of buzz has no" + note " 'invites' subcommand (it lands with block/buzz#4479). Until then" + note " the relay operator must add the pubkey below by hand." + fi + fi + ;; + esac + if [ "$claimed" = 1 ]; then + relay_probe || { diagnose_relay "$?" "$BUZZ_ERR" "$pubkey" "$relay"; return 3; } + else + diagnose_relay "$rc" "$BUZZ_ERR" "$pubkey" "$relay" + return "$rc" + fi + fi + echo "relay : member" + return 0 +} + +# publish_profile NAME DISPLAY — idempotent, and it refreshes after a /rename +# because the published name is recorded in the identity's .meta and compared on +# every run. Never fatal: a nameless member still coordinates. +publish_profile() { + local name="$1" display="$2" published + published=$(meta_get "$name" BUZZ_PROFILE_NAME || printf '') + if [ "$published" = "$display" ]; then + echo "profile : '$display' (already published)" + elif buzz_run users set-profile --name "$display"; then + meta_set "$name" BUZZ_PROFILE_NAME "$display" + if [ -n "$published" ]; then + echo "profile : renamed '$published' -> '$display'" + else + echo "profile : published as '$display'" + fi + else + note "warning: could not publish the display name: ${BUZZ_ERR:-(no detail)}" + note " coordination still works; peers will see the pubkey prefix." + fi +} + +# --- the coordination channel ------------------------------------------------ +is_uuid() { + case "$1" in + [0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]-[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]-*-*-*) return 0 ;; + *) return 1 ;; + esac +} + +default_channel_name() { setting BUZZ_COORD_CHANNEL_NAME "agent-coordination"; } + +# --- everything cached is relay-specific -------------------------------------- +# A channel UUID means nothing on a different relay, and neither does an invite +# code — but a UUID is structurally valid everywhere, so a cache written against +# relay A and read against relay B produces no error at all. The session posts +# into a channel that does not exist and goes quiet, which is the failure mode +# this whole skill exists to eliminate. +# +# So the cache keys carry the relay. Scoping rather than invalidating, because: +# - verifying a cached UUID on every resolve costs a relay round trip on the +# hot path, and buzz-msg.sh resolves on every single send; +# - invalidating on mismatch throws the old value away, so switching back to +# the first relay re-creates a duplicate channel. Scoped keys mean switching +# back finds the original room; +# - keys that cannot collide beat detecting a collision after the fact. +# +# relay_tag [URL] — a stable config-key suffix for a relay. The scheme is +# dropped, so wss:// and https:// on the same host are the same relay; the hash +# keeps two hosts with a common 24-character prefix apart. +relay_tag() { + local url host short hash + url="${1:-${BUZZ_RELAY_URL:-}}" + host=${url#*://} + host=${host%%/*} + [ -n "$host" ] || host="unset" + short=$(printf '%s' "$host" | LC_ALL=C tr '[:lower:]' '[:upper:]' \ + | LC_ALL=C tr -c 'A-Z0-9' '_') + hash=$(printf '%s' "$host" | python3 -c \ + 'import hashlib,sys;sys.stdout.write(hashlib.sha256(sys.stdin.buffer.read()).hexdigest()[:8].upper())' \ + 2>/dev/null) || hash="" + printf '%s_%s' "${short:0:24}" "${hash:-NOHASH}" +} + +# channel_cache_key NAME — the ~/.buzz/config key that caches this channel's +# UUID. One slot per channel name per relay: a single BUZZ_COORD_CHANNEL could +# not hold two rooms either, and opening a second dedicated channel overwrote the +# first while the sessions still pointing at the old UUID went quiet. +channel_cache_key() { + printf '%s__%s' "$(channel_cache_key_unscoped "$1")" "$(relay_tag)" +} + +# The pre-relay-scoping key. Still read, once, so an existing ~/.buzz/config +# keeps working — but only after the UUID is confirmed to exist on the relay +# that is configured now, because the whole point is that it might not. +channel_cache_key_unscoped() { + local name="$1" slug + [ "$name" = "$(default_channel_name)" ] && { printf 'BUZZ_COORD_CHANNEL'; return 0; } + slug=$(printf '%s' "$name" | LC_ALL=C tr '[:lower:]' '[:upper:]' | LC_ALL=C tr -c 'A-Z0-9' '_') + printf 'BUZZ_CHANNEL_%s' "${slug:0:48}" +} + +# channel_exists_here UUID — does the current relay know this channel? +# `channels get` returns null (not an error) for a channel a non-member cannot +# see, so "null" has to be treated as unknown rather than absent: a private +# channel on the right relay looks identical to one on the wrong relay. +# Returns 0 = exists, 1 = definitely not on this relay, 2 = cannot tell. +channel_exists_here() { + buzz_run channels get --channel "$1" || return 2 + case "$(printf '%s' "$BUZZ_OUT" | tr -d '[:space:]')" in + ''|null) return 2 ;; + *) return 0 ;; + esac +} + +# resolve_channel +# Sets CHANNEL, CHANNEL_NAME, CHANNEL_CREATED, CHANNEL_KEY. +# Needs $BUZZ and a loaded identity; reads $SESSION_NAME if it is set. +CHANNEL="" +CHANNEL_NAME="" +# shellcheck disable=SC2034 # read by the scripts that source this +CHANNEL_CREATED=0 +# shellcheck disable=SC2034 +CHANNEL_KEY="" +resolve_channel() { + local want="${1:-}" create="${2:-0}" cached pin pin_relay legacy legacy_key + CHANNEL=""; CHANNEL_CREATED=0; CHANNEL_KEY="" + if [ -n "$want" ] && is_uuid "$want"; then + CHANNEL="$want"; CHANNEL_NAME="" + return 0 + fi + + if [ -z "$want" ]; then + # A UUID in the environment is an explicit decision; it outranks everything. + if is_uuid "${BUZZ_COORD_CHANNEL:-}"; then + CHANNEL="$BUZZ_COORD_CHANNEL" + CHANNEL_NAME=$(default_channel_name) + CHANNEL_KEY=BUZZ_COORD_CHANNEL + return 0 + fi + # Then the room this session last connected to. Pinned per *session*, not + # per machine: session A can sit in the default channel while session B + # works in pp-refactor, and buzz-msg.sh in each posts where that session + # actually is rather than where the machine's default points. + # + # The pin records its relay. A pin is per-session state rather than a cache + # worth keeping, so a relay change drops it and says so — unlike the config + # cache, which is scoped and survives switching back. + if [ -n "${SESSION_NAME:-}" ]; then + pin=$(meta_get "$SESSION_NAME" BUZZ_SESSION_CHANNEL || printf '') + pin_relay=$(meta_get "$SESSION_NAME" BUZZ_SESSION_CHANNEL_RELAY || printf '') + if is_uuid "$pin" && [ -n "$pin_relay" ] && [ "$pin_relay" != "$(relay_tag)" ]; then + note "relay changed since this session pinned its room." + note " pinned on : $pin_relay" + note " now : $(relay_tag) ($(setting BUZZ_RELAY_URL ''))" + note " A channel UUID means nothing on another relay, so the pin is being" + note " ignored rather than used to post into a room that does not exist" + note " there. Re-join with: buzz-connect.sh join " + meta_unset "$SESSION_NAME" BUZZ_SESSION_CHANNEL + meta_unset "$SESSION_NAME" BUZZ_SESSION_CHANNEL_NAME + meta_unset "$SESSION_NAME" BUZZ_SESSION_CHANNEL_RELAY + pin="" + fi + if is_uuid "$pin"; then + CHANNEL="$pin" + CHANNEL_NAME=$(meta_get "$SESSION_NAME" BUZZ_SESSION_CHANNEL_NAME || printf '') + [ -n "$CHANNEL_NAME" ] || CHANNEL_NAME=$(default_channel_name) + CHANNEL_KEY=$(channel_cache_key "$CHANNEL_NAME") + return 0 + fi + fi + want=$(default_channel_name) + fi + + CHANNEL_NAME="$want" + CHANNEL_KEY=$(channel_cache_key "$CHANNEL_NAME") + cached=$(setting "$CHANNEL_KEY" "") + if is_uuid "$cached"; then CHANNEL="$cached"; return 0; fi + + # No relay-scoped entry. There may be a pre-scoping one, written by an earlier + # version of this script against whatever relay was configured then. Adopt it + # for this relay, but check first where checking is decisive: `channels get` + # returning an object proves the channel is here, while null proves nothing — + # a private channel a non-member cannot see looks the same as one that is not + # on this relay at all. So adopt silently when proven, and announce when not. + legacy_key=$(channel_cache_key_unscoped "$CHANNEL_NAME") + legacy=$(setting "$legacy_key" "") + if is_uuid "$legacy"; then + if channel_exists_here "$legacy"; then + CHANNEL="$legacy" + config_set "$CHANNEL_KEY" "$CHANNEL" + return 0 + fi + note "note: adopting $legacy_key=$legacy for this relay as $CHANNEL_KEY." + note " The relay could not confirm the channel — a private channel this" + note " identity cannot see is indistinguishable from one that is not" + note " here. If this UUID belongs to a relay you have switched away" + note " from, delete $legacy_key from $CONFIG_FILE and re-run to create" + note " or find '$CHANNEL_NAME' on $(setting BUZZ_RELAY_URL '')." + CHANNEL="$legacy" + config_set "$CHANNEL_KEY" "$CHANNEL" + return 0 + fi + + buzz_run channels list --limit 500 || return 2 + CHANNEL=$(printf '%s' "$BUZZ_OUT" | WANT="$CHANNEL_NAME" python3 -c ' +import json, os, sys +want = os.environ["WANT"] +try: + rows = json.load(sys.stdin) +except Exception: + rows = [] +for row in rows if isinstance(rows, list) else []: + if isinstance(row, dict) and row.get("name") == want: + sys.stdout.write(row.get("channel_id") or row.get("id") or "") + break +') + if [ -n "$CHANNEL" ]; then config_set "$CHANNEL_KEY" "$CHANNEL"; return 0; fi + [ "$create" = 1 ] || return 1 + + buzz_run channels create --name "$CHANNEL_NAME" --type stream \ + --visibility private --description "Claude Code multi-session coordination" \ + || return 2 + CHANNEL=$(printf '%s' "$BUZZ_OUT" | python3 -c ' +import json, sys +try: + sys.stdout.write(json.load(sys.stdin).get("channel_id") or "") +except Exception: + pass +') + [ -n "$CHANNEL" ] || return 2 + # shellcheck disable=SC2034 + CHANNEL_CREATED=1 + # Publish the UUID so the next session on this machine joins this channel + # instead of creating a second one with the same name that nobody shares. + config_set "$CHANNEL_KEY" "$CHANNEL" +} + +# --- joining a channel someone else owns -------------------------------------- +# Relay membership and channel membership are separate gates, and only the +# channel's owner can open the second one. When the owner's key is already on +# this machine — the normal case, because every session here mints its identity +# into ~/.buzz/sessions — asking a human to run `channels add-member` is asking +# them to be a relay for a decision they already made. So use the key. +# +# The safety model is three rules, enforced below and nowhere else: +# 1. Only keys already in $SESSION_DIR. Nothing is minted, fetched or derived. +# 2. Only `channels add-member --role member`, only on the channel being +# joined. The role is a literal, the channel is the one just resolved. +# 3. Every use is printed: which identity authorised it, and what it ran. +# Relay membership is deliberately NOT in scope — see SKILL.md. + +# _as_identity — one CLI call under another local +# key, in a subshell so the caller's identity is never replaced in this process. +# Every call site passes a literal verb, and there are exactly three: +# `channels members` and `channels list` (reads, used to find an owner and to +# probe relay membership for the roster) and `channels add-member --role member` +# — the only verb here that changes anything, and the only one that is printed +# before it runs. +AS_OUT="" +AS_ERR="" +_as_identity() { + local ident="$1"; shift + local f err rc + f=$(identity_file "$ident") + err=$(mktemp -t buzz-as) || return 127 + # BUZZ_AUTH_TAG must not cross into another key's call. The CLI verifies the + # tag against its own pubkey and hard-fails when it does not match, so an + # attestation left in the environment by a provisioning run would break every + # owner-key call here with an error about the wrong identity entirely. + AS_OUT=$( { unset BUZZ_AUTH_TAG + load_identity "$f" 2>/dev/null || exit 127; "$BUZZ" "$@"; } 2>"$err" ) + rc=$? + AS_ERR=$(cat "$err" 2>/dev/null) + rm -f "$err" + return $rc +} + +# find_local_channel_owner — print "\t" for a +# key in $SESSION_DIR that the relay reports as this channel's owner, else fail. +# +# It has to be done in this order. A non-member cannot see a private channel at +# all: `channels get` returns null and `channels members` returns [] with exit +# 0, so the blocked session cannot read the member list and look the owner up. +# The only identity that can read it is one that is already in the channel, so +# each local key is asked in turn and the one the relay calls "owner" wins. +find_local_channel_owner() { + local chan="$1" f name pk + [ -d "$SESSION_DIR" ] || return 1 + for f in "$SESSION_DIR"/*.env; do + [ -f "$f" ] || continue + name=$(basename "$f" .env) + pk=$(identity_field "$f" BUZZ_PUBKEY) || continue + _as_identity "$name" channels members --channel "$chan" || continue + printf '%s' "$AS_OUT" | ME="$pk" python3 -c ' +import json, os, sys +me = os.environ["ME"] +try: + rows = json.load(sys.stdin) +except Exception: + sys.exit(1) +for row in rows if isinstance(rows, list) else []: + if isinstance(row, dict) and row.get("pubkey") == me and row.get("role") == "owner": + sys.exit(0) +sys.exit(1) +' || continue + printf '%s\t%s' "$name" "$pk" + return 0 + done + return 1 +} + +# join_channel +# Returns 0 only if this session is a member of the channel afterwards. +join_channel() { + local chan="$1" cname="$2" me="$3" mode owner ident opk tab=$'\t' + # Self-service first: it is what works on an open channel, and it is not a + # privileged action, so it needs no announcement. + if buzz_run channels add-member --channel "$chan" --pubkey "$me" --role member; then + echo "channel : joined '$cname' as a member" + return 0 + fi + mode=$(setting BUZZ_AUTO_ADMIT 1) + case "$mode" in + 0|no|off|false) + note "auto-admit: off (BUZZ_AUTO_ADMIT=$mode) — not looking for an owner key" + return 1 ;; + esac + echo "auto-admit: not a member of '$cname'; checking whether a key in" + echo " $SESSION_DIR owns it" + owner=$(find_local_channel_owner "$chan") || { + echo "auto-admit: no local key owns '$cname'" + return 1 + } + ident=${owner%%"$tab"*} + opk=${owner#*"$tab"} + cat < acp` whether or not that means anything to it. +acp_has_default_args() { + case "$1" in + goose|codex|codex-acp|claude-agent-acp|claude-code-acp|claude-code|claudecode|buzz-agent) + return 0 ;; + *) return 1 ;; + esac +} + +# auth_tag_owner JSON — the owner pubkey out of a NIP-OA tag, or fail. +# The tag is exactly ["auth", , , ]. +auth_tag_owner() { + printf '%s' "$1" | python3 -c ' +import json, re, sys +try: + tag = json.load(sys.stdin) +except Exception: + sys.exit(1) +if (not isinstance(tag, list) or len(tag) != 4 or tag[0] != "auth" + or not re.fullmatch(r"[0-9a-f]{64}", str(tag[1])) + or not re.fullmatch(r"[0-9a-f]{128}", str(tag[3]))): + sys.exit(1) +sys.stdout.write(tag[1]) +' +} + +# agent_ownership_report NAME PUBKEY OWNER AUTH_TAG DIRECT_MEMBER +# Ownership is the part of provisioning that cannot be automated, so the job here +# is to say exactly which state this agent ended up in and what it costs. +agent_ownership_report() { + local name="$1" pubkey="$2" owner="$3" tag="$4" direct="${5:-0}" tag_owner mint + mint="cargo run --release --example compute_auth_tag -- \\ + $pubkey \"\"" + + if [ -n "$tag" ]; then + tag_owner=$(auth_tag_owner "$tag") || return 0 # validated before we got here + meta_set "$name" BUZZ_AGENT_AUTH_TAG "$tag" + meta_set "$name" BUZZ_AGENT_OWNER "$tag_owner" + cat <'. +EOF + return 0 + fi + + cat <, and the relay gates engrams on author-or-'p', not + on a registered owner. Memory works unowned. + The agent cannot fix this itself. Ownership needs a signature only the + owner's secret key can produce, and nothing in the repo carries that + request to an owner except Buzz Desktop's create-agent flow, which + already assumes one. On a machine holding the owner's SECRET key: + $mint + then re-run this with --auth-tag ''. +EOF +} + +# agent_env_block NAME PUBKEY RELAY IDFILE COMMAND CHANNEL CHANNEL_NAME +# The deliverable: what goes in a Dockerfile, a fly secret or a systemd unit. +agent_env_block() { + local name="$1" pubkey="$2" relay="$3" idfile="$4" cmd="$5" chan="$6" cname="$7" + local ident args_line="" owner auth in_room="" + cmd=${cmd:-goose} + [ -n "$cname" ] && in_room=" + This agent is a member of '$cname'." + ident=$(acp_command_identity "$cmd") + owner=$(meta_get "$name" BUZZ_AGENT_OWNER || printf '') + auth=$(meta_get "$name" BUZZ_AGENT_AUTH_TAG || printf '') + + cat < --stdin +EOF + + [ -n "$args_line" ] && cat <&2 < Copy link. Paste it here." + + When they paste it, run: + buzz-connect.sh --invite "" + + Do not offer alternatives, do not present options, and do not reason about + other routes. In particular do not propose 'buzz-admin add-member': it writes + to the relay's Postgres directly, so it does nothing from a machine that is + not the relay host, and it is the operator's call regardless. One ask, one + paste, then this is solved for every session on this machine forever. +EOF + return 0 ;; + esac + case "$1" in + 3) note "" + note " BLOCKED: the relay rejected this identity's signature (exit 3)." + note " ${2:-(no detail)}" + note " Fix: delete $(identity_file "${SESSION_NAME:-}") and re-run" + note " buzz-connect.sh to mint a fresh identity." ;; + 2) note "" + note " BLOCKED: the relay at $4 did not answer usefully (exit 2)." + note " ${2:-(no detail)}" + note " 'no community is configured for this host' means the URL's host:port" + note " does not match the relay's configured community — fix BUZZ_RELAY_URL." + note " 'Connection refused' means nothing is listening: start a relay, or set" + note " BUZZ_RELAY_URL in $CONFIG_FILE." ;; + *) note "" + note " BLOCKED: buzz exited $1." + note " ${2:-(no error output)}" ;; + esac +} + +diagnose_channel() { # $1 channel uuid, $2 channel name, $3 pubkey, $4 owner-or-empty + cat >&2 < " | "stale " | +# "dead " | "none". Age is seconds since the last heartbeat. +# +# Liveness is the heartbeat, not just the pid: a receiver wedged on a socket is +# still a running process, and "the process exists" would call that healthy. +# +# The pidfile is authoritative for WHICH process is the receiver, and the +# heartbeat only for whether it is well. They were briefly the same file and it +# produced a flapping answer: a receiver killed with SIGKILL runs no trap, so its +# heartbeat child was orphaned and went on stamping a fresh timestamp against a +# dead pid, alternating with the replacement receiver's own writes. Whoever wrote +# last decided the answer, and `disconnect` duly reported "not running" about a +# receiver that was running, and left it behind. +receiver_pid() { + local pid + pid=$(cat "$(stream_pidf "$1" "$2")" 2>/dev/null) || return 1 + case "$pid" in ''|*[!0-9]*) return 1 ;; esac + printf '%s' "$pid" +} + +receiver_state() { + local hb pid ts age now + pid=$(receiver_pid "$1" "$2") || { printf 'none'; return 0; } + kill -0 "$pid" 2>/dev/null || { printf 'dead %s' "$pid"; return 0; } + hb=$(stream_hb "$1" "$2") + ts=$(sed -n '2p' "$hb" 2>/dev/null) + case "$ts" in ''|*[!0-9]*) ts=0 ;; esac + now=$(date +%s) + age=$(( now - ts )) + [ "$age" -lt 0 ] && age=0 + if [ "$age" -gt "$(stream_stale)" ]; then + printf 'stale %s %s' "$pid" "$age" + else + printf 'live %s %s' "$pid" "$age" + fi +} + +# ensure_receiver NAME CHANNEL POLL — start the receiver unless one is already +# healthy. Idempotent, and safe to call from connect, from the watcher, and from +# status alike; that redundancy is the point, because whichever of them runs +# next is the one that repairs a receiver that stopped. +ensure_receiver() { + local name="$1" chan="$2" poll="${3:-5}" state pid i + state=$(receiver_state "$name" "$chan") + case "$state" in + live*) return 0 ;; + stale*|dead*) + pid=$(printf '%s' "$state" | cut -d' ' -f2) + # A wedged receiver holds the lock and the relay connection, so it has to + # go before a replacement can work. A dead one is already gone. + kill -TERM "$pid" 2>/dev/null + sleep 1 + ;; + esac + mkdir -p "$STREAM_DIR" + chmod 700 "$STREAM_DIR" 2>/dev/null || true + # nohup + background: the receiver must outlive both this shell and any + # Monitor task, which is the entire reason it is a separate process. + nohup "$BUZZ_SKILL_SCRIPTS/buzz-stream.sh" "$name" "$chan" "$poll" \ + >/dev/null 2>&1 & + disown 2>/dev/null || true + for i in 1 2 3 4 5 6 7 8 9 10; do + sleep 1 + case "$(receiver_state "$name" "$chan")" in live*) return 0 ;; esac + : "$i" + done + return 1 +} + +# stop_receiver NAME CHANNEL — used by leave and disconnect. A receiver left +# running holds an authenticated relay connection open for a session that has +# finished, and goes on appending to a log nobody will read. +# +# Deliberately keyed on the pidfile and not on health. A wedged receiver is the +# one it is most important to be able to stop, and asking "is it well?" before +# "is it there?" is how one got left behind. +stop_receiver() { + local pid + pid=$(receiver_pid "$1" "$2") || return 1 + kill -0 "$pid" 2>/dev/null || return 1 + kill -TERM "$pid" 2>/dev/null + printf '%s' "$pid" + return 0 +} + +# --- watcher liveness -------------------------------------------------------- +# Keyed on the session id, not the name, so a /rename mid-watch does not orphan +# the marker and make an armed watcher look unarmed. +watch_marker() { + printf '%s/.watch-%s' "$SESSION_DIR" "${CLAUDE_CODE_SESSION_ID:-$1}" +} + +# watcher_warning NAME CHANNEL — print a warning when this session cannot +# currently hear its peers, else print nothing. Returns 0 when something is +# wrong, so a caller can also change its exit status. +# +# It is called before every send and every read, because those are the moments a +# session is actually relying on the channel, and "connected but deaf" is +# indistinguishable from "nobody is talking" until something says otherwise. +watcher_warning() { + local name="$1" chan="$2" rstate wpid bad=1 + rstate=$(receiver_state "$name" "$chan") + case "$rstate" in + live*) ;; + *) + bad=0 + note "" + note " WARNING: nothing is receiving messages for this session." + note " receiver: $rstate (per-channel, runs outside Monitor)" + note " Messages posted by peers are not being fetched at all." + note " Fix: $BUZZ_SKILL_SCRIPTS/buzz-connect.sh status" + note " restarts it and prints the Monitor to re-arm." + ;; + esac + if ! wpid=$(watcher_pid "$name"); then + if [ "$bad" != 0 ]; then + bad=0 + note "" + note " WARNING: this session's watcher is not armed." + note " Messages ARE still being received and are queued in" + note " $(stream_log "$name" "$chan")" + note " but nothing will wake this session when one arrives. Re-arm and" + note " every message queued since it died is delivered:" + note " $BUZZ_SKILL_SCRIPTS/buzz-connect.sh status" + fi + else + : "$wpid" + fi + return "$bad" +} + +watcher_pid() { # prints the pid of a live watcher for this session, else fails + local m pid + m=$(watch_marker "$1") + [ -f "$m" ] || return 1 + pid=$(head -n 1 "$m" 2>/dev/null) + case "$pid" in ''|*[!0-9]*) rm -f "$m"; return 1 ;; esac + kill -0 "$pid" 2>/dev/null || { rm -f "$m"; return 1; } # stale: process gone + printf '%s' "$pid" +} + +# The roster looks at OTHER identities, so it cannot use CLAUDE_CODE_SESSION_ID. +# It goes the long way round: the identity's .meta records the session id it was +# minted under, and the marker is named after that. An identity with no .meta was +# never adopted by a Claude Code session at all, which is worth saying out loud. +identity_watch_state() { # "live " | "stale " | "none" | "unbound" | "daemon" + local name="$1" sid m pid ch + # A provisioned agent identity is unbound on purpose and has no watcher by + # design — the harness is its own event loop. Saying "unbound" about it would + # be true and misleading. + [ "$(meta_get "$name" BUZZ_AGENT || printf '')" = 1 ] && { printf 'daemon'; return 0; } + sid=$(meta_get "$name" BUZZ_SESSION_ID) || { printf 'unbound'; return 0; } + m="$SESSION_DIR/.watch-$sid" + [ -f "$m" ] || { printf 'none'; return 0; } + pid=$(sed -n '1p' "$m" 2>/dev/null) + ch=$(sed -n '2p' "$m" 2>/dev/null) + case "$pid" in ''|*[!0-9]*) printf 'none'; return 0 ;; esac + if kill -0 "$pid" 2>/dev/null; then printf 'live %s %s' "$pid" "$ch" + else printf 'stale %s' "$pid"; fi +} + +# identity_relay_state NAME — one authenticated read under that identity's key. +# The same probe buzz-connect.sh uses for itself, which is why it is trustworthy: +# "member" here means exactly what "relay : member" means on connect. +identity_relay_state() { + local name="$1" + if _as_identity "$name" channels list --limit 1; then printf 'member'; return 0; fi + case "$AS_ERR" in + *relay_membership_required*) printf 'not-a-member' ;; + *) printf 'unknown' ;; + esac +} + +# archived_pubkeys — the relay's current NIP-IA archive snapshot (kind 13535), +# one pubkey per line. `agents archived` verifies the snapshot's authorship and +# signature itself and fails rather than returning a false empty. +archived_pubkeys() { + buzz_run agents archived || return 1 + printf '%s' "$BUZZ_OUT" | python3 -c ' +import json, sys +try: + doc = json.load(sys.stdin) +except Exception: + sys.exit(0) +rows = doc.get("archived", []) if isinstance(doc, dict) else doc +for row in rows if isinstance(rows, list) else []: + if isinstance(row, str): + print(row) + elif isinstance(row, dict): + pk = row.get("pubkey") or row.get("target") or row.get("target_pubkey") or "" + if pk: + print(pk) +' +} + +# roster_report — every identity on this machine, whether the relay still counts +# it as a member, and whether anything is listening on its behalf. +# +# It never deletes anything. The point is that relay membership has no expiry, so +# identities accumulate silently; the fix is to make them visible, not to guess +# which ones the user is finished with. +roster_report() { + local f name pk pid ch archived state relay room live=0 unbound=0 total=0 orphan=0 + archived=$(archived_pubkeys 2>/dev/null || printf '') + echo "" + echo "roster : identities in $SESSION_DIR" + echo " (one relay call per identity, so this is only done on request)" + echo "" + printf ' %-30s %-18s %-16s %-14s %s\n' IDENTITY PUBKEY RELAY WATCHER ROOM + for f in "$SESSION_DIR"/*.env; do + [ -f "$f" ] || continue + total=$((total + 1)) + name=$(basename "$f" .env) + pk=$(identity_field "$f" BUZZ_PUBKEY || printf '?') + relay=$(identity_relay_state "$name") + case "$archived" in *"$pk"*) relay="$relay/archived" ;; esac + state=$(identity_watch_state "$name") + room=$(meta_get "$name" BUZZ_SESSION_CHANNEL_NAME || printf '') + case "$state" in + "live "*) + live=$((live + 1)) + pid=${state#live }; ch=${pid#* }; pid=${pid%% *} + state="live pid $pid" + # A watcher still listening to a room the identity is no longer pinned + # to is the leak this whole verb exists for: the session went away, the + # Monitor did not, and it holds a relay connection open until the Claude + # Code session ends. + if [ -z "$room" ]; then + room="none pinned; still watching ${ch:0:8}" + orphan=$((orphan + 1)) + fi ;; + unbound) + unbound=$((unbound + 1)) ;; + esac + printf ' %-30s %-18s %-16s %-14s %s\n' \ + "$name" "${pk:0:16}" "$relay" "$state" "${room:--}" + done + [ "$total" != 0 ] || { echo " (none — run buzz-connect.sh)"; return 0; } + cat <" +) + + If that id is lost, 'kill $pid' also ends it — the watcher clears its + own marker on TERM — but TaskStop is what stops Claude Code tracking + the task. Confirm with: buzz-connect.sh status +EOF + else + echo "watcher : not running — nothing to stop." + fi + + # 4. Unpin the room. The pin is what makes a bare `buzz-msg.sh send` post here; + # leaving it set after a leave would route messages into a room this session + # is no longer in, which fails with 'not a channel member' and reads as a bug. + meta_unset "$SESSION_NAME" BUZZ_SESSION_CHANNEL + meta_unset "$SESSION_NAME" BUZZ_SESSION_CHANNEL_NAME + echo "room : unpinned. 'buzz-msg.sh send' no longer posts to $room." + + # 5. Opt-in: leave the channel. + if [ "$do_leave" = 1 ] && [ -n "$CHANNEL" ]; then + if buzz_run channels leave --channel "$CHANNEL"; then + cat <` followed by `disconnect --leave-channel`, + # not an edge case. The relay refuses because a room with no owner can never + # admit anyone again. + case "$BUZZ_ERR" in + *"last owner"*) + cat >&2 < --role owner + end the room buzz channels delete --channel $CHANNEL +EOF + ;; + *) note "warning: could not leave '$room': ${BUZZ_ERR:-(no detail)}" ;; + esac + fi + elif [ "$verb" = disconnect ] && [ -n "$CHANNEL" ]; then + echo "channel : still a member of '$room' — pass --leave-channel to give up" + echo " the membership. Keeping it is what makes a reconnect free." + fi + + # 6. Opt-in: retire the identity. + if [ "$do_retire" = 1 ]; then + retire_identity || return 5 + fi + + # 7. What is left, said plainly. Every teardown leaves residue and pretending + # otherwise is how six identities accumulated in the first place. + cat < + Check whether this Claude Code session is actually connected to its Buzz room + and whether its watcher is alive — "connected but deaf" looks identical to a + quiet channel. With --all, lists every Buzz identity on this machine, which are + still relay members, and which have no live watcher. Reports only; changes + nothing. +version: 1 +--- + +# Am I connected, and is anyone listening? + +```bash +# project install (this repo), from the repo root: +.claude/skills/buzz-multi-session/scripts/buzz-connect.sh status +.claude/skills/buzz-multi-session/scripts/buzz-connect.sh status --all + +# user install, from anywhere: +~/.claude/skills/buzz-multi-session/scripts/buzz-connect.sh status [--all] +``` + +Exit codes are the answer, so this is checkable rather than something you have to +read: **1** the watcher is not armed, **4** this session is not a channel member, +**2** it is in no room at all, **0** everything is live. + +**`status` reports; it never acts.** It will not create a channel and it will not +re-admit a session that has just left one — a status call that silently undid a +`leave` would make `leave` look broken. + +`--all` is the roster: every identity in `~/.buzz/sessions`, whether the relay +still counts it as a member, and whether anything is listening for it. It costs +one relay call per identity, which is why it is on request. Three states are +worth acting on: `unbound` (no Claude Code session ever adopted it, yet it is +still a relay member holding a key that can authorise a channel admit), `none +pinned; still watching` (a watcher whose session moved on — `TaskStop` it), and +`member/archived` (retired, and still able to write). + +**It prunes nothing.** An identity with no watcher is usually a session between +runs, and nothing here can tell the difference. + +Two separate things can be broken, and the exit code says which: **1** the +Monitor is not armed, so messages are queueing and nothing is waking this +session; **6** the receiver itself is down or wedged, so messages are not being +fetched at all. This verb restarts a dead receiver and reprints the `Monitor(...)` +to arm — re-arming replays everything queued since it died. + +This skill is one entry point to `buzz-connect.sh` and adds no behaviour of its +own. The full model is documented once, in the **`buzz-multi-session`** skill.