From 366855a2afc6fa8711bac3d1c733363740ef1b02 Mon Sep 17 00:00:00 2001 From: Ash Brener Date: Sun, 2 Aug 2026 23:24:35 +0200 Subject: [PATCH 01/10] feat(skills): add buzz-multi-session skill for cross-session coordination MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parallel Claude Code sessions — typically three git worktrees of one repo — have no channel between them today, so the human becomes the message bus: copying answers between terminals and discovering conflicting edits late. This adds a Claude Code skill that gives each session its own Buzz identity, joins them all to one channel, and arms Claude Code's Monitor tool on that channel so a peer's message wakes the session directly. Two bundled scripts carry the details that are easy to get wrong: - scripts/buzz-session.sh mints or reuses a per-session identity named after the git worktree, stored mode 600 under ~/.buzz/sessions. buzz-admin generate-key is piped straight into the file so the secret never reaches a terminal or an agent transcript; only the public key is ever printed. - scripts/buzz-watch.sh is the Monitor command. `messages get --since` is inclusive, so a timestamp watermark alone replays the newest message on every poll — it dedupes on event id instead, primes the seen-set from existing history so arming the watcher does not dump the backlog, filters out the session's own pubkey so it cannot react to itself, and keeps only chat kinds 9 and 1. Poll interval is 5s, the relay rate-limit floor. SKILL.md documents the two-gate rule that causes the most confusion (relay membership and channel membership are separate; a relay member still sees nothing until the channel owner runs `channels add-member`), and defines a verb-prefixed message protocol — HELLO / CLAIM / RELEASE / STATUS / ASK / ANSWER / BLOCKED / DONE — so peers can triage without reading every message and can avoid editing paths another session has claimed. Enrolment is written against `buzz invites claim --code ` from #3014, which is not yet implemented; the skill says so explicitly and documents `buzz-admin add-member` as the interim path. The directory name avoids the `buzz-cli` rename landing in #2525. Placed in .claude/skills only, not the other harness roots, because the workflow depends on the Monitor tool. Signed-off-by: Ash Brener --- .claude/skills/buzz-multi-session/SKILL.md | 227 ++++++++++++++++++ .../scripts/buzz-session.sh | 147 ++++++++++++ .../buzz-multi-session/scripts/buzz-watch.sh | 101 ++++++++ 3 files changed, 475 insertions(+) create mode 100644 .claude/skills/buzz-multi-session/SKILL.md create mode 100755 .claude/skills/buzz-multi-session/scripts/buzz-session.sh create mode 100755 .claude/skills/buzz-multi-session/scripts/buzz-watch.sh diff --git a/.claude/skills/buzz-multi-session/SKILL.md b/.claude/skills/buzz-multi-session/SKILL.md new file mode 100644 index 0000000000..bb6a766ccc --- /dev/null +++ b/.claude/skills/buzz-multi-session/SKILL.md @@ -0,0 +1,227 @@ +--- +name: buzz-multi-session +description: > + Coordinate several independent Claude Code sessions — typically parallel git + worktrees of one repo — over a shared Buzz channel: per-session identities, + channel enrolment, and a Monitor watcher so peers wake on new messages + instead of the human relaying between terminals. +version: 1 +--- + +# 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**, all of them join **one channel**, and each arms a **Monitor** on +that channel. A peer's message becomes a notification in your session — you +wake, read it, act, reply. No polling loops in the foreground, no copy-paste. + +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. + +## Prerequisites + +- `buzz` on `PATH`, or a release build in the checkout + (`cargo build --release -p buzz-cli`). Bundled scripts fall back to + `/target/release/buzz`; override with `BUZZ_BIN`. +- `buzz-admin` for keypair minting (`cargo build --release -p buzz-admin`), + or `BUZZ_ADMIN_BIN`. +- `python3` (already a `Justfile` dependency) for JSON handling in the scripts. +- A relay you can reach, plus **either** an invite code **or** an owner willing + to run `buzz-admin add-member`. See [Enrolment](#step-2--enrol-on-the-relay). + +## The Two-Gate Rule + +The single most common failure. **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: + +```bash +buzz channels add-member --channel --pubkey --role member +``` + +Symptom of forgetting: `buzz messages get` returns `[]` forever, no error, and +the watcher stays silent while peers chat happily. If a session reports "the +channel is empty", check `buzz channels members --channel ` for its +pubkey **before** debugging anything else. + +## Step 1 — Mint a per-session identity + +Run once per session, inside its worktree: + +```bash +.claude/skills/buzz-multi-session/scripts/buzz-session.sh new +``` + +The name defaults to `-`, so parallel worktrees get +distinct, attributable identities without anyone inventing names. Pass an +explicit name for sessions that are not worktrees. + +Identities are stored at `~/.buzz/sessions/.env`, mode 600, holding +`BUZZ_PRIVATE_KEY`, `BUZZ_PUBKEY`, `BUZZ_RELAY_URL`. + +**Never print, cat, grep, echo or otherwise surface `BUZZ_PRIVATE_KEY`** — not +into the transcript, not into a Buzz message, not into a log. The script pipes +`buzz-admin generate-key` straight into the 600-mode file for exactly this +reason. Only the **public** key is ever quotable. Other subcommands: + +```bash +buzz-session.sh pubkey # public key only — safe to paste anywhere +buzz-session.sh env # prints the `set -a; . ; set +a` line +buzz-session.sh list # every known session identity + pubkey +``` + +Load it into the session's shell — every later `buzz` call reads these: + +```bash +set -a; . ~/.buzz/sessions/.env; set +a +``` + +## Step 2 — Enrol on the relay + +**Preferred (pending [#3014](https://github.com/block/buzz/issues/3014)):** + +```bash +buzz invites claim --code +``` + +The relay endpoints `POST /api/invites` and `POST /api/invites/claim` exist +today, and claim is deliberately exempt from the relay-membership gate — but +**`buzz invites` is not yet in the CLI**. Until #3014 lands, +`buzz invites claim` exits 1 with `unrecognized subcommand 'invites'`. Do not +try to work around it by hand-rolling NIP-98 requests. + +**Fallback until then** — the relay operator runs, once per session pubkey: + +```bash +buzz-admin add-member --pubkey --role member +``` + +Then, either way, the channel owner runs the `channels add-member` from +[The Two-Gate Rule](#the-two-gate-rule). `buzz channels join --channel ` +publishes a kind:9021 join request and is rejected with +`403 relay_membership_required` for a non-member, so it is not a substitute for +either gate. + +## Step 3 — Create or find the channel + +One session (or the human) creates the coordination channel once: + +```bash +buzz channels create --name refactor-auth --type stream --visibility private \ + --description "3-worktree coordination: auth refactor" +``` + +`buzz channels list` gives the UUID to everyone else. Export it so both the +watcher and your sends agree: + +```bash +export BUZZ_COORD_CHANNEL= +``` + +Use `--visibility private` for real work. Consider `--ttl ` to make +the channel ephemeral — the relay archives it after that long without a +message, which is a good fit for a coordination channel that outlives nothing. + +## Step 4 — Arm the watcher + +Use the **Monitor** tool, persistent, with the bundled poller: + +``` +Monitor( + command: ".claude/skills/buzz-multi-session/scripts/buzz-watch.sh 5", + description: "buzz coordination channel ", + persistent: true +) +``` + +Each new peer message arrives as one notification line: +`[buzz] a1b2c3d4: CLAIM crates/buzz-auth/**`. + +**Poll interval: 5 seconds.** 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. + +Three things the watcher does that a naive `messages get --since` loop does not +— preserve them if you rewrite it: + +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. +2. **Prime the seen-set from existing history at startup**, or arming the + watcher dumps the entire backlog as notifications in one burst. +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. + +It also keeps only chat kinds (`9`, `1`); reactions, presence and other kinds +are noise here. Stop a watcher with `TaskStop`. + +## Step 5 — Post + +```bash +buzz messages send --channel "$BUZZ_COORD_CHANNEL" --content "STATUS worktree-a: auth middleware extracted, tests green" +``` + +Long content: `--content -` reads stdin, which avoids shell-quoting pain for +diffs and stack traces. Content is GitHub-flavored Markdown, so fenced code +blocks render properly. Max 65,536 bytes. + +Prefer plain `@name` text over `--mention` for peer sessions: an unresolved or +ambiguous name **stops the send before publishing**, and session identities +usually have no profile name set. Give a session a readable name once with +`buzz users set-profile --name worktree-a` if you want mentions to resolve. + +## 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 | `HELLO worktree-a branch=feat/auth` | +| `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 worktree-b: did you rename Session?` | +| `ANSWER` | reply to an `ASK` | `ANSWER worktree-a: yes, now AuthSession` | +| `BLOCKED` | stuck, needs someone | `BLOCKED waiting on RELEASE of migrations/**` | +| `DONE` | this session's work is finished | `DONE worktree-a: pushed feat/auth` | + +Rules that make it work: + +1. `HELLO` on arrival, `DONE` on exit. A silent session is indistinguishable + from a dead one. +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. Read the channel before your first edit: `buzz messages get --channel + "$BUZZ_COORD_CHANNEL" --limit 50` catches up on claims made before you + armed the watcher. + +## Gotchas + +1. **Empty channel, no error** — almost always the channel-membership gate, not + the relay one. See [The Two-Gate Rule](#the-two-gate-rule). +2. **`--since` is inclusive** — the top cause of a watcher that appears to + repeat the same message every 5 seconds. +3. **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. +4. **`RUST_LOG` must not be `debug`/`trace` in a watcher shell** — tracing + output on stdout becomes notification spam. The script pins `error`. +5. **Watcher output is notifications, one line each.** Never widen its filter + to raw message dumps; Claude Code stops monitors that flood. +6. **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. +7. **Kill the watcher when the work ends** (`TaskStop`) — a persistent monitor + outlives the task otherwise and keeps polling a dead channel. 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..96a65eb8bf --- /dev/null +++ b/.claude/skills/buzz-multi-session/scripts/buzz-session.sh @@ -0,0 +1,147 @@ +#!/usr/bin/env bash +# buzz-session.sh — give one Claude Code session its own Buzz identity. +# +# buzz-session.sh new [name] mint (or reuse) an identity, print its pubkey +# buzz-session.sh env [name] print the shell snippet that loads it +# buzz-session.sh pubkey [name] print just the pubkey +# buzz-session.sh list list known session identities +# +# `name` defaults to "-" derived from the current git +# worktree, so three worktrees of one repo get three distinct, attributable +# identities without anyone having to invent names. +# +# Identities live in ~/.buzz/sessions/.env, mode 600. The secret key is +# never printed — only the public key, which is what a relay owner needs. +set -euo pipefail + +DIR="${BUZZ_SESSION_DIR:-$HOME/.buzz/sessions}" + +die() { printf '%s\n' "$*" >&2; exit 1; } + +# Resolve the binaries: PATH first, then a release build in the enclosing +# checkout (the common case for someone hacking on block/buzz). +resolve_bin() { + local name="$1" var="$2" found + found="${!var:-}" + if [ -n "$found" ]; then printf '%s' "$found"; return 0; fi + if found=$(command -v "$name" 2>/dev/null); then printf '%s' "$found"; return 0; fi + local root + 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 +} + +derive_name() { + git rev-parse --is-inside-work-tree >/dev/null 2>&1 \ + || die "not inside a git worktree — pass an explicit session name" + local root common repo wt + root=$(git rev-parse --show-toplevel) + common=$(cd "$(git rev-parse --git-common-dir)" && pwd) + repo=$(basename "$(dirname "$common")") + wt=$(basename "$root") + printf '%s' "$repo-$wt" | tr -c 'a-zA-Z0-9._-' '-' +} + +file_for() { printf '%s/%s.env' "$DIR" "$1"; } + +pubkey_of() { + local f; f=$(file_for "$1") + [ -f "$f" ] || die "no identity for '$1' — run: $0 new $1" + grep '^BUZZ_PUBKEY=' "$f" | cut -d= -f2 +} + +cmd=${1:-new} +[ $# -gt 0 ] && shift || true + +case "$cmd" in + list) + shopt -s nullglob + found=0 + for f in "$DIR"/*.env; do + found=1 + printf '%-32s %s\n' "$(basename "$f" .env)" "$(grep '^BUZZ_PUBKEY=' "$f" | cut -d= -f2)" + done + [ "$found" = 1 ] || echo "(no session identities yet)" + exit 0 + ;; + + env) + name=${1:-$(derive_name)} + f=$(file_for "$name") + [ -f "$f" ] || die "no identity for '$name' — run: $0 new $name" + echo "set -a; . $f; set +a" + exit 0 + ;; + + pubkey) + pubkey_of "${1:-$(derive_name)}" + exit 0 + ;; + + new) + name=${1:-$(derive_name)} + ;; + + *) + die "usage: $0 {new|env|pubkey|list} [name]" + ;; +esac + +BUZZ=$(resolve_bin buzz BUZZ_BIN) \ + || die "buzz not found on PATH — build it with 'cargo build --release -p buzz-cli' or set BUZZ_BIN" +RELAY="${BUZZ_RELAY_URL:-http://localhost:3000}" +FILE=$(file_for "$name") +mkdir -p "$DIR" + +if [ -f "$FILE" ]; then + echo "reusing existing identity '$name'" +else + ADMIN=$(resolve_bin buzz-admin BUZZ_ADMIN_BIN) \ + || die "buzz-admin not found — build it with 'cargo build --release -p buzz-admin' or set BUZZ_ADMIN_BIN" + 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 \ + | NAME="$name" 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: %s\n" + "BUZZ_PRIVATE_KEY=%s\n" + "BUZZ_PUBKEY=%s\n" + "BUZZ_RELAY_URL=%s\n" % (os.environ["NAME"], sec, pub, os.environ["RELAY"]) + ) +' + chmod 600 "$FILE" + echo "created identity '$name'" +fi + +PUB=$(pubkey_of "$name") +cat < --limit 20 +EOF 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..b7f2b6fdb6 --- /dev/null +++ b/.claude/skills/buzz-multi-session/scripts/buzz-watch.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +# buzz-watch.sh [poll-seconds] +# +# Emits one line per NEW message from a peer in the channel. 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. +# +# Three 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 single poll. We dedupe on +# event id instead and only use --since to bound the query. +# 2. The seen-set is primed from existing history on startup, so arming the +# watcher does not replay the backlog as a burst of notifications. +# 3. A session must never react to its own messages — filter on own pubkey. +set -uo pipefail + +NAME="${1:?usage: buzz-watch.sh [poll-seconds]}" +CH="${2:?usage: buzz-watch.sh [poll-seconds]}" +SLEEP="${3:-5}" + +SESSION_DIR="${BUZZ_SESSION_DIR:-$HOME/.buzz/sessions}" +ENV_FILE="$SESSION_DIR/$NAME.env" +[ -f "$ENV_FILE" ] || { echo "no identity '$NAME' in $SESSION_DIR" >&2; exit 1; } + +resolve_bin() { + local name="$1" var="$2" found + found="${!var:-}" + if [ -n "$found" ]; then printf '%s' "$found"; return 0; fi + if found=$(command -v "$name" 2>/dev/null); then printf '%s' "$found"; return 0; fi + local root + 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 +} +BUZZ=$(resolve_bin buzz BUZZ_BIN) || { echo "buzz not found on PATH (set BUZZ_BIN)" >&2; exit 1; } + +# shellcheck source=/dev/null +set -a; . "$ENV_FILE"; set +a +export RUST_LOG="${RUST_LOG:-error}" # keep tracing off stdout + +SEEN=$(mktemp -t buzz-watch-seen) +trap 'rm -f "$SEEN"' EXIT + +# --- prime: everything already in the channel counts as seen ----------------- +"$BUZZ" messages get --channel "$CH" --limit 200 2>/dev/null \ + | 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 + +FILTER=$(cat <<'PY' +import json, os, sys +me = os.environ["ME"] +path = os.environ["SEEN"] +with open(path) as fh: + seen = set(fh.read().split()) +try: + msgs = json.load(sys.stdin) +except Exception: + sys.exit(0) +fresh = [] +for m in sorted(msgs, key=lambda x: x.get("created_at", 0)): + eid = m.get("id") + if not eid or eid in seen: + continue + seen.add(eid) + fresh.append(eid) + if m.get("pubkey") == me: # never react to our own messages + continue + if m.get("kind") not in (9, 1): # chat kinds only + continue + who = m.get("pubkey", "")[:8] + body = " ".join(m.get("content", "").split())[:400] + print("[buzz] %s: %s" % (who, body), flush=True) +if fresh: + with open(path, "a") as fh: + fh.write("\n".join(fresh) + "\n") +PY +) + +# Bound each query to a short trailing window; correctness comes from the +# id dedupe above, not from this watermark. +WINDOW="${BUZZ_WATCH_WINDOW:-300}" + +while true; do + SINCE=$(( $(date +%s) - WINDOW )) + OUT=$("$BUZZ" messages get --channel "$CH" --since "$SINCE" --limit 100 2>/dev/null) || OUT="" + if [ -n "$OUT" ]; then + printf '%s' "$OUT" | ME="$BUZZ_PUBKEY" SEEN="$SEEN" python3 -c "$FILTER" + fi + sleep "$SLEEP" +done From a2e664a4b32fa1961186d18ca70106e1b9d0192d Mon Sep 17 00:00:00 2001 From: Ash Brener Date: Mon, 3 Aug 2026 16:44:02 +0200 Subject: [PATCH 02/10] feat(skills): bind a Buzz identity to the session that owns it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The identity name was derived from -, so a message in the coordination channel pointed at a directory. Two sessions in one worktree collided, and the name told the user nothing about which of their sessions had spoken. A session can name itself. Claude Code exports CLAUDE_CODE_SESSION_ID and writes the transcript to ~/.claude/projects//.jsonl, where the title set by /rename appears as customTitle. buzz-session-name.sh reads it — last occurrence wins, because /rename can be run more than once — and falls back to the worktree directory, then the session id, then a hash of the cwd. It never fails and never returns an empty name: a session with no /rename, no session id and no git repo still gets a stable, per-directory identity. Two forms come out of the same tier so they cannot disagree. The slug is lowercased, reduced to [a-z0-9._-], collapsed and capped at 64 characters, because it becomes ~/.buzz/sessions/.env — a "/" can only ever become "-" and a leading ".." is stripped, so a title cannot escape the directory. The display name keeps the original characters, emoji included, with control and format characters (bidi overrides among them) removed. The identity now follows a /rename rather than forking. The session id is recorded alongside the keypair, so a later run finds the existing identity under its old name and renames the file. The keypair is preserved: a /rename is not a new member. Free text and a sourced file do not mix. The .env holds only two hex keys and a validated relay URL, and is checked against that shape before being sourced; the session id, display name and published-profile name live in a .meta sidecar that is only ever grepped. Without the split, a /rename title of "x$(rm -rf ~)" would execute on the next connect — which it did, in testing, as a stray "command not found" before the shell-safe values were separated out. lib.sh carries the shared helpers so the four scripts agree on where an identity lives, how ~/.buzz/config is read (parsed, never sourced — it holds an invite code), and how a relay failure is explained. Signed-off-by: Ash Brener --- .../scripts/buzz-session-name.sh | 196 ++++++++++ .../scripts/buzz-session.sh | 294 +++++++++------ .../skills/buzz-multi-session/scripts/lib.sh | 336 ++++++++++++++++++ 3 files changed, 713 insertions(+), 113 deletions(-) create mode 100755 .claude/skills/buzz-multi-session/scripts/buzz-session-name.sh create mode 100644 .claude/skills/buzz-multi-session/scripts/lib.sh 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 index 96a65eb8bf..e0312c1394 100755 --- a/.claude/skills/buzz-multi-session/scripts/buzz-session.sh +++ b/.claude/skills/buzz-multi-session/scripts/buzz-session.sh @@ -1,112 +1,105 @@ #!/usr/bin/env bash -# buzz-session.sh — give one Claude Code session its own Buzz identity. +# buzz-session.sh — the Buzz identity of one Claude Code session. # -# buzz-session.sh new [name] mint (or reuse) an identity, print its pubkey -# buzz-session.sh env [name] print the shell snippet that loads it -# buzz-session.sh pubkey [name] print just the pubkey -# buzz-session.sh list list known session identities +# 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 # -# `name` defaults to "-" derived from the current git -# worktree, so three worktrees of one repo get three distinct, attributable -# identities without anyone having to invent names. +# You normally do not run this. buzz-connect.sh calls it, and buzz-connect.sh is +# the skill's only entry point. # -# Identities live in ~/.buzz/sessions/.env, mode 600. The secret key is -# never printed — only the public key, which is what a relay owner needs. -set -euo pipefail - -DIR="${BUZZ_SESSION_DIR:-$HOME/.buzz/sessions}" - -die() { printf '%s\n' "$*" >&2; exit 1; } - -# Resolve the binaries: PATH first, then a release build in the enclosing -# checkout (the common case for someone hacking on block/buzz). -resolve_bin() { - local name="$1" var="$2" found - found="${!var:-}" - if [ -n "$found" ]; then printf '%s' "$found"; return 0; fi - if found=$(command -v "$name" 2>/dev/null); then printf '%s' "$found"; return 0; fi - local root - 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 +# 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 - return 1 -} - -derive_name() { - git rev-parse --is-inside-work-tree >/dev/null 2>&1 \ - || die "not inside a git worktree — pass an explicit session name" - local root common repo wt - root=$(git rev-parse --show-toplevel) - common=$(cd "$(git rev-parse --git-common-dir)" && pwd) - repo=$(basename "$(dirname "$common")") - wt=$(basename "$root") - printf '%s' "$repo-$wt" | tr -c 'a-zA-Z0-9._-' '-' + 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)" } -file_for() { printf '%s/%s.env' "$DIR" "$1"; } - -pubkey_of() { - local f; f=$(file_for "$1") - [ -f "$f" ] || die "no identity for '$1' — run: $0 new $1" - grep '^BUZZ_PUBKEY=' "$f" | cut -d= -f2 +# 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" } -cmd=${1:-new} -[ $# -gt 0 ] && shift || true - -case "$cmd" in - list) - shopt -s nullglob - found=0 - for f in "$DIR"/*.env; do - found=1 - printf '%-32s %s\n' "$(basename "$f" .env)" "$(grep '^BUZZ_PUBKEY=' "$f" | cut -d= -f2)" - done - [ "$found" = 1 ] || echo "(no session identities yet)" - exit 0 - ;; - - env) - name=${1:-$(derive_name)} - f=$(file_for "$name") - [ -f "$f" ] || die "no identity for '$name' — run: $0 new $name" - echo "set -a; . $f; set +a" - exit 0 - ;; - - pubkey) - pubkey_of "${1:-$(derive_name)}" - exit 0 - ;; - - new) - name=${1:-$(derive_name)} - ;; - - *) - die "usage: $0 {new|env|pubkey|list} [name]" - ;; -esac - -BUZZ=$(resolve_bin buzz BUZZ_BIN) \ - || die "buzz not found on PATH — build it with 'cargo build --release -p buzz-cli' or set BUZZ_BIN" -RELAY="${BUZZ_RELAY_URL:-http://localhost:3000}" -FILE=$(file_for "$name") -mkdir -p "$DIR" - -if [ -f "$FILE" ]; then - echo "reusing existing identity '$name'" -else - ADMIN=$(resolve_bin buzz-admin BUZZ_ADMIN_BIN) \ - || die "buzz-admin not found — build it with 'cargo build --release -p buzz-admin' or set BUZZ_ADMIN_BIN" +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 + # 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 \ - | NAME="$name" FILE="$FILE" RELAY="$RELAY" python3 -c ' + "$admin" generate-key | FILE="$file" RELAY="$RELAY" python3 -c ' import os, re, sys pub = sec = None for line in sys.stdin: @@ -121,27 +114,102 @@ 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: %s\n" + "# 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" % (os.environ["NAME"], sec, pub, os.environ["RELAY"]) + "BUZZ_RELAY_URL=%s\n" % (sec, pub, os.environ["RELAY"]) ) -' - chmod 600 "$FILE" - echo "created identity '$name'" -fi +' || die "keypair generation failed" + chmod 600 "$file" +} -PUB=$(pubkey_of "$name") -cat </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" + ;; -Load it in this session's shell: - set -a; . $FILE; set +a + pubkey) + ensure_identity + printf '%s\n' "$PUBKEY" + ;; -Then (once the relay owner has added this pubkey to the relay AND the channel): - $BUZZ messages get --channel --limit 20 + 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 <&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 + 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" +} + +# 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:-}" bad + [ -f "$f" ] || return 1 + 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 + # An explicit BUZZ_RELAY_URL in the caller's environment outranks the value + # recorded when the key was minted. + [ -n "$env_relay" ] && export BUZZ_RELAY_URL="$env_relay" + 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 +} + +# --- 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 +} + +# resolve_channel +# Sets CHANNEL, CHANNEL_NAME, CHANNEL_CREATED. Needs $BUZZ and a loaded identity. +CHANNEL="" +CHANNEL_NAME="" +# shellcheck disable=SC2034 # read by the scripts that source this +CHANNEL_CREATED=0 +resolve_channel() { + local want="${1:-}" create="${2:-0}" + CHANNEL=""; CHANNEL_CREATED=0 + if [ -n "$want" ] && is_uuid "$want"; then + CHANNEL="$want"; CHANNEL_NAME="" + return 0 + fi + CHANNEL=$(setting BUZZ_COORD_CHANNEL "") + is_uuid "$CHANNEL" || CHANNEL="" + CHANNEL_NAME="${want:-$(setting BUZZ_COORD_CHANNEL_NAME "agent-coordination")}" + [ -n "$CHANNEL" ] && return 0 + + 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 +') + [ -n "$CHANNEL" ] && return 0 + [ "$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 BUZZ_COORD_CHANNEL "$CHANNEL" +} + +# --- self-diagnosis ---------------------------------------------------------- +# The three failures that cost real time are indistinguishable from "the agent +# is ignoring me" unless they are named. Never let a bare 403 through. +diagnose_relay() { # $1 exit code, $2 stderr, $3 pubkey, $4 relay + case "$2" in + *relay_membership_required*) + cat >&2 <" >> $CONFIG_FILE && chmod 600 $CONFIG_FILE + Then re-run buzz-connect.sh — each session claims it and enrols itself. + 2. Ask the relay operator to run, once, for the pubkey above: + buzz-admin add-member --pubkey $3 --role member +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 </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" +} From 9f79728a156008b7cac380949ea2152cfe2f5751 Mon Sep 17 00:00:00 2001 From: Ash Brener Date: Mon, 3 Aug 2026 16:44:24 +0200 Subject: [PATCH 03/10] feat(skills): make connecting a session a single step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Invoking the skill was a reading exercise: mint an identity, source an env file, find a channel UUID, paste it into a Monitor command, and do it again in every terminal. Each of those steps is derivable, and every one of them was a chance to get it wrong quietly. buzz-connect.sh is now the only thing anyone runs. It resolves the session name, mints or adopts the identity, loads it, enrols from an invite code if one is configured, publishes the display name, finds or creates the channel, announces HELLO, and prints the exact Monitor call to arm. It is idempotent, so re-running it is the way to check state rather than a risk. buzz-msg.sh send/read and buzz-watch.sh load the identity themselves — nobody is told to source anything by hand, and "buzz-watch.sh - " resolves the session so the Monitor command stays correct across a /rename. Profile publication is part of connecting and refreshes on rename: the published name is recorded in the identity's .meta sidecar and compared each run, so a session renamed mid-flight republishes and keeps its keypair. Live against a relay with membership enforcement, a session renamed from "Auth Refactor A" to "Auth Refactor A v2" republished and kept pubkey 492d3f89. Authorising a new pubkey on a closed relay is the one step that cannot be derived, so it is handled rather than papered over. An invite code in ~/.buzz/config — read by every session on the machine, parsed rather than sourced, warned about if it is group-readable — lets each session self-enrol via #4479's `buzz invites claim`. Only with no code does the skill surface the pubkey and make a single, exact ask. The channel UUID is written back to ~/.buzz/config on creation. Without that, the second session cannot see the first one's private channel — a non-member gets no rows from `channels list` — and silently creates a second "agent-coordination" that nobody shares. That failure appeared in end-to-end testing and is precisely the kind of quiet divergence the skill exists to prevent. The three failures that cost real time now name themselves: not a relay member, a relay member but not a channel member, and a watcher that was never armed. Each prints the command that fixes it. `--status` exits non-zero when the watcher is down, so "connected but deaf" is checkable. The watcher writes a liveness marker keyed on the session id, because otherwise "not armed" and "channel is quiet" are indistinguishable. Two smaller fixes found by running it: `git rev-parse --abbrev-ref HEAD` prints "HEAD" *and* fails on an unborn branch, which put "branch=HEAD no-git" into HELLO; and the relay's "no community is configured for this host" 404 was being reported as "cannot reach the relay", so the relay's own words are now always included. Signed-off-by: Ash Brener --- .claude/skills/buzz-multi-session/SKILL.md | 311 ++++++++++-------- .../scripts/buzz-connect.sh | 214 ++++++++++++ .../buzz-multi-session/scripts/buzz-msg.sh | 100 ++++++ .../buzz-multi-session/scripts/buzz-watch.sh | 54 +-- 4 files changed, 524 insertions(+), 155 deletions(-) create mode 100755 .claude/skills/buzz-multi-session/scripts/buzz-connect.sh create mode 100755 .claude/skills/buzz-multi-session/scripts/buzz-msg.sh diff --git a/.claude/skills/buzz-multi-session/SKILL.md b/.claude/skills/buzz-multi-session/SKILL.md index bb6a766ccc..94194031d1 100644 --- a/.claude/skills/buzz-multi-session/SKILL.md +++ b/.claude/skills/buzz-multi-session/SKILL.md @@ -2,10 +2,11 @@ name: buzz-multi-session description: > Coordinate several independent Claude Code sessions — typically parallel git - worktrees of one repo — over a shared Buzz channel: per-session identities, - channel enrolment, and a Monitor watcher so peers wake on new messages - instead of the human relaying between terminals. -version: 1 + 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. +version: 2 --- # Buzz Multi-Session Coordination @@ -15,131 +16,171 @@ 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**, all of them join **one channel**, and each arms a **Monitor** on -that channel. A peer's message becomes a notification in your session — you -wake, read it, act, reply. No polling loops in the foreground, no copy-paste. +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. -## Prerequisites +## Connect — one command, run by you, not by the user -- `buzz` on `PATH`, or a release build in the checkout - (`cargo build --release -p buzz-cli`). Bundled scripts fall back to - `/target/release/buzz`; override with `BUZZ_BIN`. -- `buzz-admin` for keypair minting (`cargo build --release -p buzz-admin`), - or `BUZZ_ADMIN_BIN`. -- `python3` (already a `Justfile` dependency) for JSON handling in the scripts. -- A relay you can reach, plus **either** an invite code **or** an owner willing - to run `buzz-admin add-member`. See [Enrolment](#step-2--enrol-on-the-relay). +```bash +.claude/skills/buzz-multi-session/scripts/buzz-connect.sh +``` + +That is the whole setup. It is idempotent, so run it again whenever you are +unsure of the state. In one pass it: -## The Two-Gate Rule +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 coordination channel, +6. announces `HELLO`, +7. prints the exact `Monitor(...)` call to arm — **arm it immediately**. -The single most common failure. **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: +**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. The only thing a human is ever asked for is authorising a new pubkey on a +closed relay, and only when no invite code is available. + +After connecting, post and catch up with: ```bash -buzz channels add-member --channel --pubkey --role member +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? ``` -Symptom of forgetting: `buzz messages get` returns `[]` forever, no error, and -the watcher stays silent while peers chat happily. If a session reports "the -channel is empty", check `buzz channels members --channel ` for its -pubkey **before** debugging anything else. +`--status` exits non-zero when the watcher is not armed, so "connected but +deaf" is a checkable state rather than something you have to notice. -## Step 1 — Mint a per-session identity +## An identity belongs to a session, and the name follows `/rename` -Run once per session, inside its worktree: +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. -```bash -.claude/skills/buzz-multi-session/scripts/buzz-session.sh new -``` +`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: -The name defaults to `-`, so parallel worktrees get -distinct, attributable identities without anyone inventing names. Pass an -explicit name for sessions that are not worktrees. +| | 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` | -Identities are stored at `~/.buzz/sessions/.env`, mode 600, holding -`BUZZ_PRIVATE_KEY`, `BUZZ_PUBKEY`, `BUZZ_RELAY_URL`. +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. -**Never print, cat, grep, echo or otherwise surface `BUZZ_PRIVATE_KEY`** — not -into the transcript, not into a Buzz message, not into a log. The script pipes -`buzz-admin generate-key` straight into the 600-mode file for exactly this -reason. Only the **public** key is ever quotable. Other subcommands: +**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. -```bash -buzz-session.sh pubkey # public key only — safe to paste anywhere -buzz-session.sh env # prints the `set -a; . ; set +a` line -buzz-session.sh list # every known session identity + pubkey -``` +Two forms of the name, from the same source: -Load it into the session's shell — every later `buzz` call reads these: +- **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. -```bash -set -a; . ~/.buzz/sessions/.env; set +a -``` +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. -## Step 2 — Enrol on the relay +### Secrets -**Preferred (pending [#3014](https://github.com/block/buzz/issues/3014)):** +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. -```bash -buzz invites claim --code +``` +BUZZ_RELAY_URL=https://relay.example +BUZZ_INVITE_CODE= # sessions self-enrol with this +BUZZ_COORD_CHANNEL= # written automatically by the creator +BUZZ_COORD_CHANNEL_NAME=agent-coordination ``` -The relay endpoints `POST /api/invites` and `POST /api/invites/claim` exist -today, and claim is deliberately exempt from the relay-membership gate — but -**`buzz invites` is not yet in the CLI**. Until #3014 lands, -`buzz invites claim` exits 1 with `unrecognized subcommand 'invites'`. Do not -try to work around it by hand-rolling NIP-98 requests. +Environment variables override the file. -**Fallback until then** — the relay operator runs, once per session pubkey: +**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. -```bash +**Without a code**, `buzz-connect.sh` makes exactly one request of the human, +naming the command and the pubkey: + +``` buzz-admin add-member --pubkey --role member ``` -Then, either way, the channel owner runs the `channels add-member` from -[The Two-Gate Rule](#the-two-gate-rule). `buzz channels join --channel ` -publishes a kind:9021 join request and is rejected with -`403 relay_membership_required` for a non-member, so it is not a substitute for -either gate. +Relay the ask once and move on. Do not turn it into a setup procedure. -## Step 3 — Create or find the channel +## The two gates, and the three failures worth naming -One session (or the human) creates the coordination channel once: +**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. -```bash -buzz channels create --name refactor-auth --type stream --visibility private \ - --description "3-worktree coordination: auth refactor" -``` +`buzz-connect.sh` checks both and names the fix rather than surfacing a 403. +The three states, and what you will see: -`buzz channels list` gives the UUID to everyone else. Export it so both the -watcher and your sends agree: +| State | What is printed | +|-------|-----------------| +| Not a relay member | `BLOCKED: this session is not a member of the relay yet` + the invite-code route and the exact `buzz-admin add-member` line | +| Relay member, not a channel member | `BLOCKED: relay membership is not channel membership` + the exact `buzz channels add-member` line for the channel owner | +| Connected, watcher not armed | `watcher : NOT ARMED` + the exact `Monitor(...)` to run; `--status` exits 1 | -```bash -export BUZZ_COORD_CHANNEL= -``` +`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. -Use `--visibility private` for real work. Consider `--ttl ` to make -the channel ephemeral — the relay archives it after that long without a -message, which is a good fit for a coordination channel that outlives nothing. +## The channel -## Step 4 — Arm the watcher +`buzz-connect.sh` finds `agent-coordination` or creates it (`stream`, +`private`), then **records the UUID as `BUZZ_COORD_CHANNEL` in `~/.buzz/config`**. +This matters: a private channel is invisible to a non-member, so a second +session cannot find it by name and would otherwise create a duplicate with the +same name that nobody shares. Writing the UUID back is what makes the second +session join the first one's channel. -Use the **Monitor** tool, persistent, with the bundled poller: +Sessions on a *different* machine need that UUID copied across — the one piece +of state that cannot be derived. Pass it with `--channel `. -``` -Monitor( - command: ".claude/skills/buzz-multi-session/scripts/buzz-watch.sh 5", - description: "buzz coordination channel ", - persistent: true -) -``` +## The watcher -Each new peer message arrives as one notification line: +`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/**`. **Poll interval: 5 seconds.** That is the relay's rate-limit floor and it is @@ -147,7 +188,7 @@ 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. -Three things the watcher does that a naive `messages get --since` loop does not +Four things the watcher does that a naive `messages get --since` loop does not — preserve them if you rewrite it: 1. **`--since` is inclusive.** A timestamp watermark alone re-emits the newest @@ -157,24 +198,13 @@ Three things the watcher does that a naive `messages get --since` loop does not watcher dumps the entire backlog as notifications in one burst. 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. +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. -It also keeps only chat kinds (`9`, `1`); reactions, presence and other kinds -are noise here. Stop a watcher with `TaskStop`. - -## Step 5 — Post - -```bash -buzz messages send --channel "$BUZZ_COORD_CHANNEL" --content "STATUS worktree-a: auth middleware extracted, tests green" -``` - -Long content: `--content -` reads stdin, which avoids shell-quoting pain for -diffs and stack traces. Content is GitHub-flavored Markdown, so fenced code -blocks render properly. Max 65,536 bytes. - -Prefer plain `@name` text over `--mention` for peer sessions: an unresolved or -ambiguous name **stops the send before publishing**, and session identities -usually have no profile name set. Give a session a readable name once with -`buzz users set-profile --name worktree-a` if you want mentions to resolve. +It keeps only chat kinds (`9`, `1`); reactions and presence are noise here. +Stop a watcher with `TaskStop` — a persistent monitor otherwise outlives the +task and keeps polling a dead channel. ## The coordination protocol @@ -184,44 +214,61 @@ can triage without reading the whole thing. | Verb | Meaning | Example | |------|---------|---------| -| `HELLO` | joining; who and where | `HELLO worktree-a branch=feat/auth` | +| `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 worktree-b: did you rename Session?` | -| `ANSWER` | reply to an `ASK` | `ANSWER worktree-a: yes, now AuthSession` | +| `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 worktree-a: pushed feat/auth` | +| `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, `DONE` on exit. A silent session is indistinguishable - from a dead one. -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. +1. `HELLO` on arrival (automatic), `DONE` on exit. A silent session is + indistinguishable from a dead one. +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. Read the channel before your first edit: `buzz messages get --channel - "$BUZZ_COORD_CHANNEL" --limit 50` catches up on claims made before you +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.** Everything above, idempotently. | +| `buzz-msg.sh` | `send` / `read` on the coordination channel | +| `buzz-watch.sh` | the Monitor poller; `-` 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. + ## Gotchas -1. **Empty channel, no error** — almost always the channel-membership gate, not - the relay one. See [The Two-Gate Rule](#the-two-gate-rule). -2. **`--since` is inclusive** — the top cause of a watcher that appears to - repeat the same message every 5 seconds. -3. **One identity per session, never shared.** Two sessions on one key are +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. -4. **`RUST_LOG` must not be `debug`/`trace` in a watcher shell** — tracing - output on stdout becomes notification spam. The script pins `error`. -5. **Watcher output is notifications, one line each.** Never widen its filter - to raw message dumps; Claude Code stops monitors that flood. -6. **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. -7. **Kill the watcher when the work ends** (`TaskStop`) — a persistent monitor - outlives the task otherwise and keeps polling a dead channel. +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. +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. 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..86f8d6bf66 --- /dev/null +++ b/.claude/skills/buzz-multi-session/scripts/buzz-connect.sh @@ -0,0 +1,214 @@ +#!/usr/bin/env bash +# buzz-connect.sh — connect this Claude Code session to the coordination channel. +# +# buzz-connect.sh [--channel ] [--status] [--quiet-hello] +# +# This is the skill's only entry point. Running it 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 +# +# 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 channel UUID, if you already have one +# BUZZ_COORD_CHANNEL_NAME channel to find or create [agent-coordination] +set -uo pipefail + +HERE="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib.sh +. "$HERE/lib.sh" + +STATUS_ONLY=0 +SAY_HELLO=1 +CHANNEL_ARG="" +while [ $# -gt 0 ]; do + case "$1" in + --channel) CHANNEL_ARG="${2:-}"; shift ;; + --status) STATUS_ONLY=1; SAY_HELLO=0 ;; + --quiet-hello) SAY_HELLO=0 ;; + -h|--help) sed -n '2,27p' "$0"; exit 0 ;; + *) die "usage: $0 [--channel ] [--status] [--quiet-hello]" ;; + esac + shift +done + +check_config_perms +require_buzz + +# --- 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}" + +printf 'session : %s\nidentity : %s\npubkey : %s\nrelay : %s\n' \ + "$SESSION_DISPLAY" "$SESSION_NAME" "$PUBKEY" "$RELAY" + +# --- 4. relay membership ----------------------------------------------------- +# A single cheap authenticated read is the membership probe. +relay_probe() { buzz_run channels list --limit 1; } + +# Capture the status directly: after `if ! cmd`, $? is the negation, not cmd's. +relay_probe; RC=$? +if [ "$RC" != 0 ]; then + claimed=0 + case "$BUZZ_ERR" in + *relay_membership_required*) + code=$(setting BUZZ_INVITE_CODE "") + 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" + claimed=1 + else + note "invite claim failed: ${BUZZ_ERR:-(no detail)}" + 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"; exit 3; } + else + diagnose_relay "$RC" "$BUZZ_ERR" "$PUBKEY" "$RELAY" + exit "$RC" + fi +fi +echo "relay : member" + +# --- 5. profile -------------------------------------------------------------- +# Idempotent, and it refreshes after a /rename because the published name is +# recorded in the identity file and compared on every run. +PUBLISHED=$(meta_get "$SESSION_NAME" BUZZ_PROFILE_NAME || printf '') +if [ "$PUBLISHED" = "$SESSION_DISPLAY" ]; then + echo "profile : '$SESSION_DISPLAY' (already published)" +elif buzz_run users set-profile --name "$SESSION_DISPLAY"; then + meta_set "$SESSION_NAME" BUZZ_PROFILE_NAME "$SESSION_DISPLAY" + if [ -n "$PUBLISHED" ]; then + echo "profile : renamed '$PUBLISHED' -> '$SESSION_DISPLAY'" + else + echo "profile : published as '$SESSION_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 + +# --- 6. channel -------------------------------------------------------------- +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)" + echo " recorded as BUZZ_COORD_CHANNEL in $CONFIG_FILE, so other" + echo " sessions on this machine join it rather than creating their own." +else + echo "channel : ${CHANNEL_NAME:-} ($CHANNEL)" +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. +if [ "$CHANNEL_CREATED" = 0 ]; then + 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) + if [ -z "$MEMBER" ]; then + # Being the owner of the channel is the one case we can fix ourselves. + if [ "$OWNER" = "$PUBKEY" ] || ! buzz_run channels add-member \ + --channel "$CHANNEL" --pubkey "$PUBKEY" --role member; then + diagnose_channel "$CHANNEL" "${CHANNEL_NAME:-$CHANNEL}" "$PUBKEY" "$OWNER" + exit 4 + fi + echo "channel : added this session as a member" + fi + else + diagnose_channel "$CHANNEL" "${CHANNEL_NAME:-$CHANNEL}" "$PUBKEY" "" + exit 4 + fi +fi + +# --- 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. watcher -------------------------------------------------------------- +WATCH_CMD="$HERE/buzz-watch.sh - $CHANNEL 5" +if PID=$(watcher_pid "$SESSION_NAME"); then + echo "watcher : running (pid $PID)" + exit 0 +fi + +if [ "$STATUS_ONLY" = 1 ]; then + # Exit non-zero so "connected but deaf" is a checkable state, not prose. + echo "watcher : NOT ARMED — peers' messages cannot wake this session." + echo " Arm it with: Monitor(command: \"$WATCH_CMD\", persistent: true)" + 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-watch.sh b/.claude/skills/buzz-multi-session/scripts/buzz-watch.sh index b7f2b6fdb6..aeb8b2ef44 100755 --- a/.claude/skills/buzz-multi-session/scripts/buzz-watch.sh +++ b/.claude/skills/buzz-multi-session/scripts/buzz-watch.sh @@ -1,11 +1,15 @@ #!/usr/bin/env bash -# buzz-watch.sh [poll-seconds] +# buzz-watch.sh [session-name|-] [poll-seconds] # # Emits one line per NEW message from a peer in the channel. 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. # +# Pass "-" as the session name (what buzz-connect.sh prints) and the watcher +# resolves this session's identity itself, so the command stays correct after a +# /rename. It loads the identity file too; nothing is sourced by hand. +# # Three 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 single poll. We dedupe on @@ -15,35 +19,39 @@ # 3. A session must never react to its own messages — filter on own pubkey. set -uo pipefail -NAME="${1:?usage: buzz-watch.sh [poll-seconds]}" -CH="${2:?usage: buzz-watch.sh [poll-seconds]}" +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}" -SESSION_DIR="${BUZZ_SESSION_DIR:-$HOME/.buzz/sessions}" -ENV_FILE="$SESSION_DIR/$NAME.env" -[ -f "$ENV_FILE" ] || { echo "no identity '$NAME' in $SESSION_DIR" >&2; exit 1; } +require_buzz -resolve_bin() { - local name="$1" var="$2" found - found="${!var:-}" - if [ -n "$found" ]; then printf '%s' "$found"; return 0; fi - if found=$(command -v "$name" 2>/dev/null); then printf '%s' "$found"; return 0; fi - local root - 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 -} -BUZZ=$(resolve_bin buzz BUZZ_BIN) || { echo "buzz not found on PATH (set BUZZ_BIN)" >&2; exit 1; } +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" -# shellcheck source=/dev/null -set -a; . "$ENV_FILE"; set +a +load_identity "$ENV_FILE" || die "could not load $ENV_FILE" export RUST_LOG="${RUST_LOG:-error}" # keep tracing off stdout +# 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" + SEEN=$(mktemp -t buzz-watch-seen) -trap 'rm -f "$SEEN"' EXIT +# TERM and INT too: Monitor stops a watcher by signalling it, and a marker left +# behind would make buzz-connect.sh --status claim a watcher that is gone. +trap 'rm -f "$SEEN" "$MARKER"; exit 0' EXIT INT TERM # --- prime: everything already in the channel counts as seen ----------------- "$BUZZ" messages get --channel "$CH" --limit 200 2>/dev/null \ From 7347c13930406affc1796672696ae1d889a8f2d8 Mon Sep 17 00:00:00 2001 From: Ash Brener Date: Mon, 3 Aug 2026 19:15:13 +0200 Subject: [PATCH 04/10] fix(skills): one ask for enrolment, and stop PATH shadowing the CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes from running the skill against a real relay. `~/.buzz/config` now outranks `PATH` when resolving `buzz` and `buzz-admin`. A configured path is a decision; `PATH` is ambient, and on a machine with Buzz Desktop installed it resolves to the app's bundled CLI, which lags the relay. The shadowing is invisible and the failure is wrong-but-plausible: the script reports a feature missing, which is true of the binary it picked and false of the one the user configured. Enrolment is now one ask with one answer. `buzz-connect.sh --invite` takes whatever "Invite to community -> Copy link" put on the clipboard — the whole URL or a bare code — saves it, and enrols; every later session on the machine self-enrols from it. `diagnose_relay` prints the exact sentence to say and nothing else. It no longer offers `buzz-admin add-member`: that writes to the relay's Postgres directly, so it is inert anywhere but the relay host, and a menu of routes is a worse answer than one instruction. The published profile is prefixed `Claude Code ()`. In a channel listing a bare session slug is indistinguishable from a human; the prefix says what kind of member it is and which terminal to go find. Signed-off-by: Ash Brener --- .claude/skills/buzz-multi-session/SKILL.md | 28 +++++++++++---- .../scripts/buzz-connect.sh | 35 +++++++++++++++++-- .../skills/buzz-multi-session/scripts/lib.sh | 28 +++++++++++---- 3 files changed, 76 insertions(+), 15 deletions(-) diff --git a/.claude/skills/buzz-multi-session/SKILL.md b/.claude/skills/buzz-multi-session/SKILL.md index 94194031d1..963677f040 100644 --- a/.claude/skills/buzz-multi-session/SKILL.md +++ b/.claude/skills/buzz-multi-session/SKILL.md @@ -28,9 +28,18 @@ see the `buzz-cli` skill; this skill only documents what that one does not. ## 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: @@ -137,14 +146,19 @@ 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**, `buzz-connect.sh` makes exactly one request of the human, -naming the command and the pubkey: +**Without a code**, ask for the invite link and nothing else: -``` -buzz-admin add-member --pubkey --role member -``` +> 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. -Relay the ask once and move on. Do not turn it into a setup procedure. +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 @@ -158,7 +172,7 @@ 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 invite-code route and the exact `buzz-admin add-member` line | +| 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 | `BLOCKED: relay membership is not channel membership` + the exact `buzz channels add-member` line for the channel owner | | Connected, watcher not armed | `watcher : NOT ARMED` + the exact `Monitor(...)` to run; `--status` exits 1 | diff --git a/.claude/skills/buzz-multi-session/scripts/buzz-connect.sh b/.claude/skills/buzz-multi-session/scripts/buzz-connect.sh index 86f8d6bf66..84aa6b1d25 100755 --- a/.claude/skills/buzz-multi-session/scripts/buzz-connect.sh +++ b/.claude/skills/buzz-multi-session/scripts/buzz-connect.sh @@ -34,13 +34,15 @@ HERE="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" STATUS_ONLY=0 SAY_HELLO=1 CHANNEL_ARG="" +INVITE_ARG="" while [ $# -gt 0 ]; do case "$1" in --channel) CHANNEL_ARG="${2:-}"; shift ;; + --invite) INVITE_ARG="${2:-}"; shift ;; --status) STATUS_ONLY=1; SAY_HELLO=0 ;; --quiet-hello) SAY_HELLO=0 ;; - -h|--help) sed -n '2,27p' "$0"; exit 0 ;; - *) die "usage: $0 [--channel ] [--status] [--quiet-hello]" ;; + -h|--help) sed -n '2,29p' "$0"; exit 0 ;; + *) die "usage: $0 [--channel ] [--invite ] [--status] [--quiet-hello]" ;; esac shift done @@ -48,6 +50,25 @@ done 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 + config_set BUZZ_INVITE_CODE "$code" + echo "invite : saved to $CONFIG_FILE — every future session enrols itself" +fi + # --- 1-3. identity ----------------------------------------------------------- IDENT=$("$HERE/buzz-session.sh" resolve) || exit 1 SESSION_NAME=$(printf '%s' "$IDENT" | cut -f1) @@ -58,6 +79,16 @@ 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" diff --git a/.claude/skills/buzz-multi-session/scripts/lib.sh b/.claude/skills/buzz-multi-session/scripts/lib.sh index ebdd786628..83c64a136f 100644 --- a/.claude/skills/buzz-multi-session/scripts/lib.sh +++ b/.claude/skills/buzz-multi-session/scripts/lib.sh @@ -85,6 +85,16 @@ 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 @@ -276,12 +286,18 @@ diagnose_relay() { # $1 exit code, $2 stderr, $3 pubkey, $4 relay pubkey : $3 relay : $4 - Two ways to fix it, in order of preference: - 1. Put an invite code where every session can read it: - printf 'BUZZ_INVITE_CODE=%s\\n' "" >> $CONFIG_FILE && chmod 600 $CONFIG_FILE - Then re-run buzz-connect.sh — each session claims it and enrols itself. - 2. Ask the relay operator to run, once, for the pubkey above: - buzz-admin add-member --pubkey $3 --role member + ASK THE USER FOR EXACTLY THIS, IN THESE WORDS, AND NOTHING ELSE: + + "In Buzz Desktop: Invite to community -> 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 From a7e35a0867467c06556c3677935dc0fa8eb4616b Mon Sep 17 00:00:00 2001 From: Ash Brener Date: Mon, 3 Aug 2026 19:15:36 +0200 Subject: [PATCH 05/10] feat(skills): admit peers with a local owner key, and one channel per topic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two manual steps removed from connecting a session. **Channel membership is granted automatically when the machine holds the owner's key.** Relay membership and channel membership are separate gates, and only a channel's owner can open the second one, so a human was running `buzz channels add-member` by hand for every new session — four times now. Every session on a machine mints its identity into `~/.buzz/sessions`, so the key that created the channel is almost always right there; asking a human to run the command is asking them to relay a decision they already made. The owner is found the only way the relay permits. A non-member cannot see a private channel at all — `channels members` returns `[]` with exit 0 and `channels get` returns `null` — so the blocked session cannot read the member list and look the owner up. Each key in `~/.buzz/sessions` is asked in turn whether the relay reports it as this channel's owner, and that key then runs the grant in a subshell so the caller's identity is never replaced. The safety model is three rules: only keys already in `~/.buzz/sessions`; only `channels add-member --role member` on the channel being joined, both literals in `join_channel`; and every use prints the identity, the owner pubkey, the file the key came from, and the command run under it. A privilege action that leaves no trace in the output is unacceptable. `BUZZ_AUTO_ADMIT=0` skips the search and falls back to the single ask, unchanged. Relay membership deliberately does not work this way, though a local owner/admin key could mint an invite. Channel membership is one room and one scoped grant; relay membership is the whole community, and the artefact is a bearer token that outlives the action and sits in a config file where anything that can read it can join. `--invite` already reduces that to one paste, and the human should stay the one who authorises it. **Dedicated channels now hold.** `BUZZ_COORD_CHANNEL` was a single slot, so joining a second named channel overwrote the first and the sessions still pointing at the old UUID went quiet with no error at all. The UUID cache is now one key per name — `BUZZ_CHANNEL_`, with the default keeping the existing key — and a found channel is cached, not only a created one. `buzz-connect.sh --channel ` is the documented way to open a room for a piece of work: it joins or creates, admits the session as above, and pins the room to that session's `.meta`, so a bare `buzz-msg.sh send` afterwards posts there rather than to the machine's default. The pin is per session, so one worktree can sit in `pp-refactor` while another stays in `agent-coordination`. Signed-off-by: Ash Brener --- .claude/skills/buzz-multi-session/SKILL.md | 101 +++++++++-- .../scripts/buzz-connect.sh | 47 +++-- .../buzz-multi-session/scripts/buzz-msg.sh | 11 +- .../skills/buzz-multi-session/scripts/lib.sh | 171 +++++++++++++++++- 4 files changed, 286 insertions(+), 44 deletions(-) diff --git a/.claude/skills/buzz-multi-session/SKILL.md b/.claude/skills/buzz-multi-session/SKILL.md index 963677f040..3ba8ffd63a 100644 --- a/.claude/skills/buzz-multi-session/SKILL.md +++ b/.claude/skills/buzz-multi-session/SKILL.md @@ -6,7 +6,7 @@ description: > 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. -version: 2 +version: 3 --- # Buzz Multi-Session Coordination @@ -47,14 +47,26 @@ unsure of the state. In one pass it: 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 coordination channel, +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. The only thing a human is ever asked for is authorising a new pubkey on a -closed relay, and only when no invite code is available. +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 --channel 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: @@ -133,8 +145,10 @@ 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= # written automatically by the creator +BUZZ_COORD_CHANNEL= # the default channel, written on creation BUZZ_COORD_CHANNEL_NAME=agent-coordination +BUZZ_CHANNEL_PP_REFACTOR= # one key per dedicated channel, likewise +BUZZ_AUTO_ADMIT=0 # opt out of admitting with a local owner key ``` Environment variables override the file. @@ -173,23 +187,70 @@ 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 | `BLOCKED: relay membership is not channel membership` + the exact `buzz channels add-member` line for the channel owner | +| 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. -## The channel - -`buzz-connect.sh` finds `agent-coordination` or creates it (`stream`, -`private`), then **records the UUID as `BUZZ_COORD_CHANNEL` in `~/.buzz/config`**. -This matters: a private channel is invisible to a non-member, so a second -session cannot find it by name and would otherwise create a duplicate with the -same name that nobody shares. Writing the UUID back is what makes the second -session join the first one's channel. - -Sessions on a *different* machine need that UUID copied across — the one piece -of state that cannot be derived. Pass it with `--channel `. +### 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 --channel ` 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 `--channel`. + +**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 — +`BUZZ_COORD_CHANNEL` for the default, `BUZZ_CHANNEL_` for a dedicated one. +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 `--channel `. ## The watcher @@ -286,3 +347,9 @@ dependency — for JSON handling. 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 connects with `--channel `, a + bare `buzz-connect.sh` or `buzz-msg.sh` keeps using that room. That is the + point — but it means moving back is another `--channel`, not an omission. +7. **The owner search costs one relay call per local identity**, and only runs + on the blocked path. If `~/.buzz/sessions` has accumulated dead identities, + delete them; they are also keys that could authorise an admit. diff --git a/.claude/skills/buzz-multi-session/scripts/buzz-connect.sh b/.claude/skills/buzz-multi-session/scripts/buzz-connect.sh index 84aa6b1d25..1762482833 100755 --- a/.claude/skills/buzz-multi-session/scripts/buzz-connect.sh +++ b/.claude/skills/buzz-multi-session/scripts/buzz-connect.sh @@ -16,6 +16,15 @@ # 7. announces HELLO # 8. prints the exact Monitor command to arm, or reports the live watcher # +# A dedicated room for one piece of work, joined or created in the same step: +# +# buzz-connect.sh --channel pp-refactor +# +# The name is remembered per session and its UUID is cached per name, so a +# machine can hold several dedicated channels at once. If the channel already +# exists and its owner's key is in ~/.buzz/sessions, this session is admitted +# to it automatically and told so (BUZZ_AUTO_ADMIT=0 turns that off). +# # 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. # @@ -23,8 +32,10 @@ # (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 channel UUID, if you already have one -# BUZZ_COORD_CHANNEL_NAME channel to find or create [agent-coordination] +# BUZZ_COORD_CHANNEL default channel's UUID, written on creation +# BUZZ_COORD_CHANNEL_NAME default channel name [agent-coordination] +# BUZZ_CHANNEL_ a dedicated channel's UUID, written on creation +# BUZZ_AUTO_ADMIT 0 disables admitting with a local owner key [1] set -uo pipefail HERE="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" @@ -41,7 +52,7 @@ while [ $# -gt 0 ]; do --invite) INVITE_ARG="${2:-}"; shift ;; --status) STATUS_ONLY=1; SAY_HELLO=0 ;; --quiet-hello) SAY_HELLO=0 ;; - -h|--help) sed -n '2,29p' "$0"; exit 0 ;; + -h|--help) sed -n '2,38p' "$0"; exit 0 ;; *) die "usage: $0 [--channel ] [--invite ] [--status] [--quiet-hello]" ;; esac shift @@ -154,7 +165,7 @@ if ! resolve_channel "$CHANNEL_ARG" 1; then fi if [ "$CHANNEL_CREATED" = 1 ]; then echo "channel : created '$CHANNEL_NAME' ($CHANNEL)" - echo " recorded as BUZZ_COORD_CHANNEL in $CONFIG_FILE, so other" + 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)" @@ -163,7 +174,13 @@ 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 @@ -184,21 +201,21 @@ sys.stdout.write("%s\t%s" % (member, owner)) ') MEMBER=$(printf '%s' "$CHECK" | cut -f1) OWNER=$(printf '%s' "$CHECK" | cut -f2) - if [ -z "$MEMBER" ]; then - # Being the owner of the channel is the one case we can fix ourselves. - if [ "$OWNER" = "$PUBKEY" ] || ! buzz_run channels add-member \ - --channel "$CHANNEL" --pubkey "$PUBKEY" --role member; then - diagnose_channel "$CHANNEL" "${CHANNEL_NAME:-$CHANNEL}" "$PUBKEY" "$OWNER" - exit 4 - fi - echo "channel : added this session as a member" - fi - else - diagnose_channel "$CHANNEL" "${CHANNEL_NAME:-$CHANNEL}" "$PUBKEY" "" + fi + if [ -z "$MEMBER" ] \ + && ! join_channel "$CHANNEL" "${CHANNEL_NAME:-$CHANNEL}" "$PUBKEY"; then + diagnose_channel "$CHANNEL" "${CHANNEL_NAME:-$CHANNEL}" "$PUBKEY" "$OWNER" exit 4 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:-}" + # --- 7. HELLO ---------------------------------------------------------------- if [ "$SAY_HELLO" = 1 ]; then # --abbrev-ref prints "HEAD" *and* fails on an unborn branch; check the value. diff --git a/.claude/skills/buzz-multi-session/scripts/buzz-msg.sh b/.claude/skills/buzz-multi-session/scripts/buzz-msg.sh index e3c1a55877..d468cfebc6 100755 --- a/.claude/skills/buzz-multi-session/scripts/buzz-msg.sh +++ b/.claude/skills/buzz-multi-session/scripts/buzz-msg.sh @@ -6,9 +6,14 @@ # buzz-msg.sh read [limit] (default 50, oldest first) # # Both load this session's identity themselves. Nothing is sourced by hand and -# no channel UUID has to be pasted: the channel comes from --channel, then -# BUZZ_COORD_CHANNEL, then BUZZ_COORD_CHANNEL_NAME in ~/.buzz/config, then -# 'agent-coordination'. Run buzz-connect.sh first — this does not create. +# no channel UUID has to be pasted. The channel is, in order: --channel (a UUID +# or a name), BUZZ_COORD_CHANNEL in the environment, the room this session last +# connected to with buzz-connect.sh, then the cached UUID for the channel name, +# then a lookup by name. Run buzz-connect.sh first — this does not create. +# +# The session-pinned room is what makes dedicated channels work: after +# `buzz-connect.sh --channel pp-refactor`, a bare `buzz-msg.sh send` posts to +# pp-refactor and not to the machine's default channel. set -uo pipefail HERE="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" diff --git a/.claude/skills/buzz-multi-session/scripts/lib.sh b/.claude/skills/buzz-multi-session/scripts/lib.sh index 83c64a136f..4d096969a4 100644 --- a/.claude/skills/buzz-multi-session/scripts/lib.sh +++ b/.claude/skills/buzz-multi-session/scripts/lib.sh @@ -222,23 +222,66 @@ is_uuid() { esac } +default_channel_name() { setting BUZZ_COORD_CHANNEL_NAME "agent-coordination"; } + +# channel_cache_key NAME — the ~/.buzz/config key that caches this channel's +# UUID. One slot per channel name, because a single BUZZ_COORD_CHANNEL cannot +# hold two rooms: opening a second dedicated channel overwrote the first, and +# the sessions still pointing at the old UUID went quiet with no error at all. +# The default name keeps the historical key, so existing configs keep working. +channel_cache_key() { + 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}" +} + # resolve_channel -# Sets CHANNEL, CHANNEL_NAME, CHANNEL_CREATED. Needs $BUZZ and a loaded identity. +# 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}" - CHANNEL=""; CHANNEL_CREATED=0 + local want="${1:-}" create="${2:-0}" cached pin + CHANNEL=""; CHANNEL_CREATED=0; CHANNEL_KEY="" if [ -n "$want" ] && is_uuid "$want"; then CHANNEL="$want"; CHANNEL_NAME="" return 0 fi - CHANNEL=$(setting BUZZ_COORD_CHANNEL "") - is_uuid "$CHANNEL" || CHANNEL="" - CHANNEL_NAME="${want:-$(setting BUZZ_COORD_CHANNEL_NAME "agent-coordination")}" - [ -n "$CHANNEL" ] && return 0 + + 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. + if [ -n "${SESSION_NAME:-}" ]; then + pin=$(meta_get "$SESSION_NAME" BUZZ_SESSION_CHANNEL || printf '') + 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 buzz_run channels list --limit 500 || return 2 CHANNEL=$(printf '%s' "$BUZZ_OUT" | WANT="$CHANNEL_NAME" python3 -c ' @@ -253,7 +296,7 @@ for row in rows if isinstance(rows, list) else []: sys.stdout.write(row.get("channel_id") or row.get("id") or "") break ') - [ -n "$CHANNEL" ] && return 0 + 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 \ @@ -271,7 +314,117 @@ except Exception: 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 BUZZ_COORD_CHANNEL "$CHANNEL" + 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. +# Only the two functions below may call it, and both pass a literal verb. +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 + AS_OUT=$( { 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 < Date: Mon, 3 Aug 2026 20:35:51 +0200 Subject: [PATCH 06/10] feat(skills): let a session say what it wants, and let it leave MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Everything was a flag on buzz-connect.sh, and nothing tore anything down. A session could only accumulate: arm a watcher, join rooms, become a permanent relay member, then vanish when its terminal closed, leaving all three behind. Five verbs — connect, join, status, leave, disconnect. Every existing flag still works and nothing was renamed: --status is status, --channel is join . They live on buzz-connect.sh rather than in a dispatcher because all five need the same first three steps, and those steps are this script. leave and disconnect do only the two unambiguous things: post DONE while still a channel member, because after `channels leave` the relay refuses the send, and print the exact TaskStop call — the watcher is a Claude Code Monitor, so a shell script cannot kill it. Then the room pin is cleared, so a bare `buzz-msg.sh send` stops posting into a room the session has left. The two destructive actions are opt-in, because each is right in one case and wrong in the other. --leave-channel is right for finished work and wrong for a session that reconnects tomorrow: on a private channel it is not self-reversible (`channels join` is refused, some remaining member has to re-add the pubkey), and a session that opened its own room owns it and cannot leave at all, which the script now names instead of passing on "cannot remove the last owner". --retire is right for a throwaway worktree and wrong for anything resumable, and it is never implicit — leave refuses the flag, and it only ever targets the running session's own pubkey. --retire says plainly what archiving is not. NIP-IA kind:9035 adds one row and republishes the kind:13535 snapshot; it does not stop the key reading, writing or connecting, does not hide anything already published, and does not touch relay or channel membership. `agents unarchive` is a clean inverse of the state — the archive is that row — but not of the record: both requests are stored, publicly readable events. status now reports rather than acts. It will not create a channel, and it will not re-admit a session that has just left one, which would have made leave look like it had silently failed. status --all lists every identity on the machine, whether the relay still counts it as a member, and whether anything is listening for it, because relay_members has no TTL and identities accumulate silently. It prunes nothing: an identity with no watcher is usually a session between runs. It does surface the three states worth acting on — an identity no session ever adopted, a watcher still polling a room its session left, and an archived identity that can still write. Every teardown ends by printing what remains, including that relay membership does not go anywhere. There is no path out of it from here: the relay implements a self-service leave (NIP-43 kind:28936) but no client builds that event, the admin remove refuses self-removal, and buzz-admin remove-member writes to the relay's Postgres directly. Signed-off-by: Ash Brener --- .claude/skills/buzz-multi-session/SKILL.md | 221 +++++++++++-- .../scripts/buzz-connect.sh | 157 +++++++-- .../skills/buzz-multi-session/scripts/lib.sh | 306 +++++++++++++++++- 3 files changed, 644 insertions(+), 40 deletions(-) diff --git a/.claude/skills/buzz-multi-session/SKILL.md b/.claude/skills/buzz-multi-session/SKILL.md index 3ba8ffd63a..d453c47cc7 100644 --- a/.claude/skills/buzz-multi-session/SKILL.md +++ b/.claude/skills/buzz-multi-session/SKILL.md @@ -6,7 +6,8 @@ description: > 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. -version: 3 + Also covers leaving a room and disconnecting a finished session. +version: 4 --- # Buzz Multi-Session Coordination @@ -25,6 +26,27 @@ 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 | What it does | +|------|--------------| +| `connect` | the default. Identity, enrolment, profile, channel, `HELLO`, watcher | +| `join ` | a room for one piece of work — connect, but into that channel | +| `status [--all]` | am I connected, is the watcher alive, and with `--all`, every identity on this machine | +| `leave` | stop participating in the current channel | +| `disconnect` | stop participating entirely | + +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, and it would cost the skill its +one true sentence: there is a single entry point. + ## Connect — one command, run by you, not by the user ```bash @@ -61,7 +83,7 @@ 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 --channel pp-refactor +scripts/buzz-connect.sh join pp-refactor ``` That joins `pp-refactor` if it exists and creates it if it does not, admits this @@ -74,11 +96,16 @@ After connecting, post and catch up with: 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? +scripts/buzz-connect.sh status # am I connected? is the watcher alive? ``` -`--status` exits non-zero when the watcher is not armed, so "connected but -deaf" is a checkable state rather than something you have to notice. +`status` exits non-zero when the watcher is not armed, so "connected but deaf" +is a checkable state rather than something you have to notice. It exits 4 when +this session is not a channel member and 2 when it is in no room at all. + +**`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` @@ -188,7 +215,7 @@ The three states, and what you will see: |-------|-----------------| | 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 | +| 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. @@ -226,12 +253,13 @@ paste, and the human should stay the one who authorises it. ## Dedicated channels: a room per piece of work -`buzz-connect.sh --channel ` joins that channel or creates it (`stream`, +`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 `--channel`. +`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 @@ -250,7 +278,7 @@ 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 `--channel `. +state that cannot be derived. Pass it with `join `. ## The watcher @@ -275,11 +303,159 @@ Four things the watcher does that a naive `messages get --since` loop does not replies, reacts to the reply, and you have built a loop that costs money. 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. + identical, and `status` could not tell you which one you are in. It keeps only chat kinds (`9`, `1`); reactions and presence are noise here. -Stop a watcher with `TaskStop` — a persistent monitor otherwise outlives the -task and keeps polling a dead channel. + +**Keep the task id the `Monitor(...)` call returns.** It is the only handle on +the watcher: `leave` and `disconnect` print the `TaskStop` that needs it, and a +persistent monitor nobody can stop outlives the work and keeps polling a channel +where nothing will ever happen again. + +## 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 polling 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 polling`** — a live watcher for an identity that is no + longer in a room. That is a `Monitor` whose session moved on; it costs a relay + call every 5 seconds 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. ## The coordination protocol @@ -305,8 +481,9 @@ ambiguous name **stops the send before publishing**. Rules that make it work: -1. `HELLO` on arrival (automatic), `DONE` on exit. A silent session is - indistinguishable from a dead one. +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. @@ -321,7 +498,7 @@ Rules that make it work: | Script | Role | |--------|------| -| `buzz-connect.sh` | **the entry point.** Everything above, idempotently. | +| `buzz-connect.sh` | **the entry point.** `connect` / `join` / `status` / `leave` / `disconnect`, idempotently. | | `buzz-msg.sh` | `send` / `read` on the coordination channel | | `buzz-watch.sh` | the Monitor poller; `-` as the name resolves this session | | `buzz-session.sh` | identity lifecycle — called by the others | @@ -347,9 +524,13 @@ dependency — for JSON handling. 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 connects with `--channel `, a - bare `buzz-connect.sh` or `buzz-msg.sh` keeps using that room. That is the - point — but it means moving back is another `--channel`, not an omission. +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. If `~/.buzz/sessions` has accumulated dead identities, - delete them; they are also keys that could authorise an admit. + 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-connect.sh b/.claude/skills/buzz-multi-session/scripts/buzz-connect.sh index 1762482833..e39dc25373 100755 --- a/.claude/skills/buzz-multi-session/scripts/buzz-connect.sh +++ b/.claude/skills/buzz-multi-session/scripts/buzz-connect.sh @@ -1,10 +1,26 @@ #!/usr/bin/env bash -# buzz-connect.sh — connect this Claude Code session to the coordination channel. +# buzz-connect.sh — this session's whole relationship with the coordination +# channel: joining it, checking it, and ending it. # -# buzz-connect.sh [--channel ] [--status] [--quiet-hello] +# 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 # -# This is the skill's only entry point. Running it does everything a session -# needs, in one step and idempotently: +# 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 @@ -16,14 +32,26 @@ # 7. announces HELLO # 8. prints the exact Monitor command to arm, or reports the live watcher # -# A dedicated room for one piece of work, joined or created in the same step: +# 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: # -# buzz-connect.sh --channel pp-refactor +# --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. # -# The name is remembered per session and its UUID is cached per name, so a -# machine can hold several dedicated channels at once. If the channel already -# exists and its owner's key is in ~/.buzz/sessions, this session is admitted -# to it automatically and told so (BUZZ_AUTO_ADMIT=0 turns that off). +# 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. @@ -42,22 +70,69 @@ 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="${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) STATUS_ONLY=1; SAY_HELLO=0 ;; + --status) [ "$VERB" = connect ] && VERB=status ;; --quiet-hello) SAY_HELLO=0 ;; - -h|--help) sed -n '2,38p' "$0"; exit 0 ;; - *) die "usage: $0 [--channel ] [--invite ] [--status] [--quiet-hello]" ;; + --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 @@ -103,6 +178,24 @@ 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 ----------------------------------------------------- # A single cheap authenticated read is the membership probe. relay_probe() { buzz_run channels list --limit 1; } @@ -159,7 +252,19 @@ else fi # --- 6. channel -------------------------------------------------------------- -if ! resolve_channel "$CHANNEL_ARG" 1; then +# 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 @@ -202,10 +307,18 @@ sys.stdout.write("%s\t%s" % (member, owner)) MEMBER=$(printf '%s' "$CHECK" | cut -f1) OWNER=$(printf '%s' "$CHECK" | cut -f2) fi - if [ -z "$MEMBER" ] \ - && ! join_channel "$CHANNEL" "${CHANNEL_NAME:-$CHANNEL}" "$PUBKEY"; then - diagnose_channel "$CHANNEL" "${CHANNEL_NAME:-$CHANNEL}" "$PUBKEY" "$OWNER" - exit 4 + 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 @@ -235,6 +348,7 @@ fi WATCH_CMD="$HERE/buzz-watch.sh - $CHANNEL 5" if PID=$(watcher_pid "$SESSION_NAME"); then echo "watcher : running (pid $PID)" + [ "$SHOW_ALL" = 1 ] && roster_report exit 0 fi @@ -242,6 +356,7 @@ if [ "$STATUS_ONLY" = 1 ]; then # Exit non-zero so "connected but deaf" is a checkable state, not prose. echo "watcher : NOT ARMED — peers' messages cannot wake this session." echo " Arm it with: Monitor(command: \"$WATCH_CMD\", persistent: true)" + [ "$SHOW_ALL" = 1 ] && roster_report exit 1 fi @@ -256,6 +371,10 @@ Monitor( persistent: true ) +Keep the task id that call returns — 'buzz-connect.sh leave' and 'disconnect' +print the TaskStop that needs it, and a watcher nobody can stop outlives the +session and keeps polling. + Post and catch up with (they load this session's identity themselves): $HERE/buzz-msg.sh send "STATUS ..." $HERE/buzz-msg.sh read 50 diff --git a/.claude/skills/buzz-multi-session/scripts/lib.sh b/.claude/skills/buzz-multi-session/scripts/lib.sh index 4d096969a4..d3b3275295 100644 --- a/.claude/skills/buzz-multi-session/scripts/lib.sh +++ b/.claude/skills/buzz-multi-session/scripts/lib.sh @@ -159,6 +159,22 @@ 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() { @@ -333,7 +349,11 @@ except Exception: # _as_identity — one CLI call under another local # key, in a subshell so the caller's identity is never replaced in this process. -# Only the two functions below may call it, and both pass a literal verb. +# 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() { @@ -503,3 +523,287 @@ watcher_pid() { # prints the pid of a live watcher for this session, else fails 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() { # prints "live " | "stale " | "none" | "unbound" + local name="$1" sid m pid ch + 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 polling 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 will poll until the Claude Code session ends. + if [ -z "$room" ]; then + room="none pinned; still polling ${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 + + # 3. 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." + + # 4. 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 + + # 5. Opt-in: retire the identity. + if [ "$do_retire" = 1 ]; then + retire_identity || return 5 + fi + + # 6. What is left, said plainly. Every teardown leaves residue and pretending + # otherwise is how six identities accumulated in the first place. + cat < Date: Mon, 3 Aug 2026 21:13:19 +0200 Subject: [PATCH 07/10] feat(skills): a skill per verb, an identity for hosted agents, and a cache that knows its relay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things, all downstream of the verbs landing. **A skill per verb.** Verbs on one script are correct and undiscoverable: the slash menu lists skill names and there is no completion into a skill's arguments, so a user who sees buzz-multi-session cannot learn that `leave` exists. A session that cannot be told to disconnect never disconnects, which is how watchers and identities accumulate. buzz-connect, buzz-join, buzz-status, buzz-leave, buzz-disconnect and buzz-agent-provision are thin siblings whose frontmatter description is the whole discoverability surface. They carry no logic and no duplicated prose — one document, one scripts directory, still. They are directories with their own SKILL.md rather than symlinks because a skill's identity is its name and description, and six symlinks to one file would be six skills with the same name. **buzz-agent-provision.sh.** buzz-acp runs goose, codex, claude-agent-acp and hermes, and assumes its identity is already a relay member and already a channel member — it never claims an invite, publishes a profile or joins anything, and given none of them boots to "agent will sit idle". That is the same keypair, enrolment, profile and membership buzz-connect.sh already does, so it reuses ensure_relay_membership, publish_profile, resolve_channel and join_channel rather than reimplementing them; ensure_relay_membership and publish_profile were lifted out of buzz-connect.sh for the purpose. It prints the env block for a Dockerfile or a fly secret, and never the private key — only its path and two ways to load it that keep it out of a terminal, a log, a history and ps. The identity is deliberately not bound to the session that created it. buzz-session.sh recorded CLAUDE_CODE_SESSION_ID even for an explicit name, which meant a /rename in the terminal that provisioned an agent renamed the daemon's key out from under it. Fixed at the source; the caller also drops the variable. Ownership is the part that cannot be automated, so it is stated rather than papered over. An unowned agent is not a formality: --respond-to defaults to owner-only, so it connects and ignores everyone. --auth-tag takes a real NIP-OA attestation, which only the owner's secret key can mint; --owner records a pubkey and says plainly that it is not the same thing. And the finding that matters: a key that enrols itself can never have an owner recorded. The relay materialises users.agent_owner_pubkey only on the ViaOwner path, so a direct member's membership check short-circuits before the attestation is read, on both the HTTP submit and the WS AUTH. --auth-tag therefore does not claim an invite. BUZZ_AUTH_TAG is also unset inside _as_identity, or auto-admit under an owner key would present another identity's attestation and hard-fail. **Relay-scoped caches.** A channel UUID is structurally valid on any relay, so pointing BUZZ_RELAY_URL somewhere new made every session resolve the old relay's UUID and post into a channel that does not exist, silently. Cache keys now carry the relay — scoped rather than invalidated, because verifying on every resolve costs a round trip on the path buzz-msg.sh takes for every send, and invalidating would make switching back create a duplicate instead of finding the original room. Unscoped keys are still read and adopted on first use: silently when channels get proves the channel is here, announced when it cannot, because a private channel this identity is not in looks the same as one somewhere else. The room pin records its relay and is dropped with a message when that changes, and a failed invite claim says when the code may belong to a relay you left. Worse than the cache: the relay recorded in an identity file outranked ~/.buzz/config, so editing the relay there did nothing at all for any existing identity and every session kept talking to the relay it was born on. The mint record no longer outranks configuration — environment, then config, then the record — and a mismatch says that the keypair carries over but membership does not. The record itself is left alone, so switching back needs no repair. Signed-off-by: Ash Brener --- .claude/skills/buzz-agent-provision/SKILL.md | 48 ++ .claude/skills/buzz-connect/SKILL.md | 38 ++ .claude/skills/buzz-disconnect/SKILL.md | 46 ++ .claude/skills/buzz-join/SKILL.md | 37 ++ .claude/skills/buzz-leave/SKILL.md | 43 ++ .claude/skills/buzz-multi-session/SKILL.md | 216 ++++++++- .../scripts/buzz-agent-provision.sh | 184 +++++++ .../scripts/buzz-connect.sh | 82 ++-- .../scripts/buzz-session.sh | 7 +- .../skills/buzz-multi-session/scripts/lib.sh | 459 +++++++++++++++++- .claude/skills/buzz-status/SKILL.md | 43 ++ 11 files changed, 1119 insertions(+), 84 deletions(-) create mode 100644 .claude/skills/buzz-agent-provision/SKILL.md create mode 100644 .claude/skills/buzz-connect/SKILL.md create mode 100644 .claude/skills/buzz-disconnect/SKILL.md create mode 100644 .claude/skills/buzz-join/SKILL.md create mode 100644 .claude/skills/buzz-leave/SKILL.md create mode 100755 .claude/skills/buzz-multi-session/scripts/buzz-agent-provision.sh create mode 100644 .claude/skills/buzz-status/SKILL.md 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..bf4eb982b7 --- /dev/null +++ b/.claude/skills/buzz-connect/SKILL.md @@ -0,0 +1,38 @@ +--- +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, the watcher's four non-obvious rules — 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..c3a8fb76a4 --- /dev/null +++ b/.claude/skills/buzz-disconnect/SKILL.md @@ -0,0 +1,46 @@ +--- +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 two unambiguous things and nothing else: posts `DONE` +while still a channel member, and prints the exact **`TaskStop`** for the +watcher — a Claude Code `Monitor` that a shell script cannot kill, and that +otherwise keeps polling 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 index d453c47cc7..8d7b3a62ec 100644 --- a/.claude/skills/buzz-multi-session/SKILL.md +++ b/.claude/skills/buzz-multi-session/SKILL.md @@ -30,13 +30,14 @@ see the `buzz-cli` skill; this skill only documents what that one does not. One script, five verbs, all run by you and never by the user: -| Verb | What it does | -|------|--------------| -| `connect` | the default. Identity, enrolment, profile, channel, `HELLO`, watcher | -| `join ` | a room for one piece of work — connect, but into that channel | -| `status [--all]` | am I connected, is the watcher alive, and with `--all`, every identity on this machine | -| `leave` | stop participating in the current channel | -| `disconnect` | stop participating entirely | +| 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 watcher alive, 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. @@ -44,8 +45,31 @@ Every flag still works and nothing was renamed: `--status` **is** `status`, and 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, and it would cost the skill its -one true sentence: there is a single entry point. +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 @@ -172,14 +196,61 @@ 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= # the default channel, written on creation BUZZ_COORD_CHANNEL_NAME=agent-coordination -BUZZ_CHANNEL_PP_REFACTOR= # one key per dedicated channel, likewise 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 @@ -269,13 +340,14 @@ sets of `CLAIM`s that never have to be read by sessions they do not concern. 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 — -`BUZZ_COORD_CHANNEL` for the default, `BUZZ_CHANNEL_` for a dedicated one. -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. +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 `. @@ -457,6 +529,114 @@ runs, not a dead one, and the script cannot tell the difference. Retiring is 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 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 index e39dc25373..a70bb469ea 100755 --- a/.claude/skills/buzz-multi-session/scripts/buzz-connect.sh +++ b/.claude/skills/buzz-multi-session/scripts/buzz-connect.sh @@ -60,10 +60,19 @@ # (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 default channel's UUID, written on creation # BUZZ_COORD_CHANNEL_NAME default channel name [agent-coordination] -# BUZZ_CHANNEL_ a dedicated channel's UUID, written on creation # 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)" @@ -151,8 +160,13 @@ if [ -n "$INVITE_ARG" ]; then 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 — every future session enrols itself" + echo "invite : saved to $CONFIG_FILE for $(setting BUZZ_RELAY_URL '') —" + echo " every future session on this relay enrols itself" fi # --- 1-3. identity ----------------------------------------------------------- @@ -197,59 +211,10 @@ if [ "$VERB" = leave ] || [ "$VERB" = disconnect ]; then fi # --- 4. relay membership ----------------------------------------------------- -# A single cheap authenticated read is the membership probe. -relay_probe() { buzz_run channels list --limit 1; } - -# Capture the status directly: after `if ! cmd`, $? is the negation, not cmd's. -relay_probe; RC=$? -if [ "$RC" != 0 ]; then - claimed=0 - case "$BUZZ_ERR" in - *relay_membership_required*) - code=$(setting BUZZ_INVITE_CODE "") - 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" - claimed=1 - else - note "invite claim failed: ${BUZZ_ERR:-(no detail)}" - 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"; exit 3; } - else - diagnose_relay "$RC" "$BUZZ_ERR" "$PUBKEY" "$RELAY" - exit "$RC" - fi -fi -echo "relay : member" +ensure_relay_membership "$PUBKEY" "$RELAY" || exit $? # --- 5. profile -------------------------------------------------------------- -# Idempotent, and it refreshes after a /rename because the published name is -# recorded in the identity file and compared on every run. -PUBLISHED=$(meta_get "$SESSION_NAME" BUZZ_PROFILE_NAME || printf '') -if [ "$PUBLISHED" = "$SESSION_DISPLAY" ]; then - echo "profile : '$SESSION_DISPLAY' (already published)" -elif buzz_run users set-profile --name "$SESSION_DISPLAY"; then - meta_set "$SESSION_NAME" BUZZ_PROFILE_NAME "$SESSION_DISPLAY" - if [ -n "$PUBLISHED" ]; then - echo "profile : renamed '$PUBLISHED' -> '$SESSION_DISPLAY'" - else - echo "profile : published as '$SESSION_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 +publish_profile "$SESSION_NAME" "$SESSION_DISPLAY" # --- 6. channel -------------------------------------------------------------- # status reports; it does not act. It must not create a channel and — the case @@ -275,6 +240,12 @@ if [ "$CHANNEL_CREATED" = 1 ]; then 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 @@ -328,6 +299,9 @@ fi # 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 diff --git a/.claude/skills/buzz-multi-session/scripts/buzz-session.sh b/.claude/skills/buzz-multi-session/scripts/buzz-session.sh index e0312c1394..d8d3c5b01d 100755 --- a/.claude/skills/buzz-multi-session/scripts/buzz-session.sh +++ b/.claude/skills/buzz-multi-session/scripts/buzz-session.sh @@ -137,7 +137,12 @@ ensure_identity() { 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. - [ -n "${CLAUDE_CODE_SESSION_ID:-}" ] \ + # + # 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" ] \ diff --git a/.claude/skills/buzz-multi-session/scripts/lib.sh b/.claude/skills/buzz-multi-session/scripts/lib.sh index d3b3275295..7fb08b2e3c 100644 --- a/.claude/skills/buzz-multi-session/scripts/lib.sh +++ b/.claude/skills/buzz-multi-session/scripts/lib.sh @@ -197,8 +197,11 @@ identity_for_session() { # 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:-}" bad + 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." @@ -208,9 +211,29 @@ load_identity() { # shellcheck disable=SC1090 # runtime path, one identity file per session . "$f" set +a - # An explicit BUZZ_RELAY_URL in the caller's environment outranks the value - # recorded when the key was minted. - [ -n "$env_relay" ] && export BUZZ_RELAY_URL="$env_relay" + 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 } @@ -230,6 +253,111 @@ buzz_run() { 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 @@ -240,18 +368,69 @@ is_uuid() { 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, because a single BUZZ_COORD_CHANNEL cannot -# hold two rooms: opening a second dedicated channel overwrote the first, and -# the sessions still pointing at the old UUID went quiet with no error at all. -# The default name keeps the historical key, so existing configs keep working. +# 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. @@ -262,7 +441,7 @@ CHANNEL_CREATED=0 # shellcheck disable=SC2034 CHANNEL_KEY="" resolve_channel() { - local want="${1:-}" create="${2:-0}" cached pin + 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="" @@ -281,8 +460,25 @@ resolve_channel() { # 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 '') @@ -299,6 +495,31 @@ resolve_channel() { 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 @@ -361,7 +582,12 @@ _as_identity() { local f err rc f=$(identity_file "$ident") err=$(mktemp -t buzz-as) || return 127 - AS_OUT=$( { load_identity "$f" 2>/dev/null || exit 127; "$BUZZ" "$@"; } 2>"$err" ) + # 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" @@ -447,6 +673,210 @@ EOF return 1 } +# --- provisioning a non-Claude-Code agent ------------------------------------- +# buzz-acp assumes its identity is already a relay member and already a channel +# member. It never claims an invite, never publishes a profile and never joins +# anything: `BUZZ_ACP_CHANNELS` narrows channels it has already discovered from +# kind:39002 membership events, so a UUID it is not a member of is dropped +# silently and the agent boots to "no channel subscriptions resolved — agent will +# sit idle". Provisioning is exactly the gap between a keypair and that. + +# acp_command_identity CMD — reproduce buzz-acp's normalisation so the guidance +# printed here matches what the harness will actually do: basename, lowercase, +# drop .exe/.cmd/.bat, and space/underscore to hyphen. +acp_command_identity() { + printf '%s' "$1" \ + | LC_ALL=C tr '\134' '/' \ + | sed -e 's#/*$##' -e 's#.*/##' \ + | LC_ALL=C tr '[:upper:]' '[:lower:]' \ + | sed -E -e 's/\.(exe|cmd|bat)$//' \ + | LC_ALL=C tr ' _' '--' +} + +# acp_has_default_args IDENT — true when buzz-acp knows this harness's arguments. +# The list is config.rs's, and it matters because clap's default for +# --agent-args is the literal "acp": a harness buzz-acp does not recognise is +# launched as ` 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 < " | "stale " | "none" | "unbound" +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; } @@ -622,6 +1056,8 @@ roster_report() { WATCHER live = a Monitor is polling for it now. none = nothing is listening, but the identity is still a relay member and still holds a key. + daemon = a provisioned agent identity (buzz-agent-provision.sh); it + has no watcher by design, because its harness is its own event loop. unbound = no .meta, so no Claude Code session ever adopted it: it was minted by hand or is left over from a session that never ran. RELAY member = the relay still accepts writes from that key. Membership has @@ -779,6 +1215,7 @@ EOF # retire_identity — NIP-IA kind:9035 for this session's own pubkey. Self-service # because the relay's self consent path is actor == target; this never touches # another identity, and there is no flag here that could make it. +# shellcheck disable=SC2153 # $PUBKEY is the caller's global, not a typo for $pubkey retire_identity() { 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 polling` (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. + +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. From 677f77b656041027e1e2de3255a235e02d044d74 Mon Sep 17 00:00:00 2001 From: Ash Brener Date: Mon, 3 Aug 2026 23:48:14 +0200 Subject: [PATCH 08/10] feat(skills): push messages instead of polling, and keep receiving when the watcher dies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The watcher slept 5 seconds between REST reads, so a peer's message took up to 5s to wake a session. It also *was* the fetch: when a Monitor-hosted watcher died — three did, with exit 144, where the same command under nohup stayed healthy — the messages were never fetched at all and were simply gone. Two changes, and the second matters more. buzz messages subscribe A new CLI verb: hold a NIP-42-authenticated WebSocket open and print one JSON object per line as the relay pushes it. buzz-ws-client already had connect/auth/send_raw/next_event but no REQ helper and no caller that streams, so BuzzClient gains subscribe_events() beside publish_ephemeral_event and keeps the keys private. It never returns Ok: every exit is a reason the stream stopped, because a reader that cannot tell a quiet channel from a dead socket is worse than a poller. --idle-timeout clears the relay's 30s heartbeat, so silence past it is a dead socket rather than a quiet room. --reconnect-after ends a healthy stream on a schedule so a supervisor can backfill over HTTP — a subscription the relay has quietly stopped matching is silent and still heartbeating, and nothing else would ever notice. Receiving split from waking buzz-stream.sh is a per-identity, per-channel daemon outside Monitor. It owns the relay connection, the dedupe and the filter, and appends notifications to ~/.buzz/stream/..log. buzz-watch.sh — all Monitor runs — is now just tail -F on that log from a stored line offset. Its interface is unchanged, so a Monitor command recorded before this still works. A Monitor death now costs the wake, not the messages: they keep landing in the log and re-arming replays them, in order, once. Verified by SIGKILLing a watcher, posting three messages, and re-arming: all three delivered. Guardrails, because silent deafness beats slow delivery only in the sense that neither is acceptable: the receiver heartbeats every 15s so a wedged one is distinguishable from a working one; status reports live/stale/dead/none, restarts a dead receiver, counts queued messages and exits 1 unarmed, 6 with no receiver; every send and read warns first and continues. Receiver stderr is kept in .err across restarts — Monitor reaps its own task output, which is why three deaths produced no diagnosis. Measured against a local relay, send to notification: push 35-90ms, poll 326-4541ms. A CLI with no subscribe verb falls back to polling with no error — latency is a nicety, hearing your peers is not. Three defects found by testing the failure rather than the happy path: - an orphaned tail kept advancing the offset after its watcher was SIGKILLed, so re-arming skipped messages that were never delivered. The delivery loop now runs in the watcher's own shell and the offset advances only after the line is out. - an orphaned heartbeat kept stamping liveness for a dead pid, so disconnect reported "not running" and left a receiver behind. The pidfile is now authoritative for identity, the heartbeat only for health. - a SIGKILLed receiver orphaned its stream job, leaving a second WebSocket appending duplicates. Its process group is recorded and reaped by whoever starts next. On exit 144: bash ignores SIGURG by default, verified on Darwin 25, so a bare SIGURG cannot produce it. No mechanism is claimed. Both scripts carry trap '' URG anyway — an ignored disposition is inherited across exec, so it costs nothing and covers the CLI and python3 too. Signed-off-by: Ash Brener --- .claude/skills/buzz-connect/SKILL.md | 3 +- .claude/skills/buzz-disconnect/SKILL.md | 9 +- .claude/skills/buzz-multi-session/SKILL.md | 186 ++++++-- .../scripts/buzz-connect.sh | 63 ++- .../buzz-multi-session/scripts/buzz-msg.sh | 6 + .../buzz-multi-session/scripts/buzz-stream.sh | 396 ++++++++++++++++++ .../buzz-multi-session/scripts/buzz-watch.sh | 203 +++++---- .../skills/buzz-multi-session/scripts/lib.sh | 216 +++++++++- .claude/skills/buzz-status/SKILL.md | 8 +- crates/buzz-cli/README.md | 1 + crates/buzz-cli/src/client.rs | 112 ++++- crates/buzz-cli/src/commands/messages.rs | 134 +++++- crates/buzz-cli/src/lib.rs | 25 +- 13 files changed, 1207 insertions(+), 155 deletions(-) create mode 100755 .claude/skills/buzz-multi-session/scripts/buzz-stream.sh diff --git a/.claude/skills/buzz-connect/SKILL.md b/.claude/skills/buzz-connect/SKILL.md index bf4eb982b7..021d473fa9 100644 --- a/.claude/skills/buzz-connect/SKILL.md +++ b/.claude/skills/buzz-connect/SKILL.md @@ -33,6 +33,7 @@ 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, the watcher's four non-obvious rules — is +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 index c3a8fb76a4..11ae5cc372 100644 --- a/.claude/skills/buzz-disconnect/SKILL.md +++ b/.claude/skills/buzz-disconnect/SKILL.md @@ -19,10 +19,11 @@ version: 1 ~/.claude/skills/buzz-multi-session/scripts/buzz-connect.sh disconnect ``` -By default it does the two unambiguous things and nothing else: posts `DONE` -while still a channel member, and prints the exact **`TaskStop`** for the -watcher — a Claude Code `Monitor` that a shell script cannot kill, and that -otherwise keeps polling a channel where nothing will happen again. Then it clears +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: diff --git a/.claude/skills/buzz-multi-session/SKILL.md b/.claude/skills/buzz-multi-session/SKILL.md index 8d7b3a62ec..f0e85ab26e 100644 --- a/.claude/skills/buzz-multi-session/SKILL.md +++ b/.claude/skills/buzz-multi-session/SKILL.md @@ -34,7 +34,7 @@ One script, five verbs, all run by you and never by the user: |------|-------|--------------| | `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 watcher alive, and with `--all`, every identity on this machine | +| `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`) | @@ -123,9 +123,11 @@ scripts/buzz-msg.sh read 50 # what happened before you armed the watch scripts/buzz-connect.sh status # am I connected? is the watcher alive? ``` -`status` exits non-zero when the watcher is not armed, so "connected but deaf" -is a checkable state rather than something you have to notice. It exits 4 when -this session is not a channel member and 2 when it is in no room at all. +`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 @@ -352,37 +354,163 @@ 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 `. -## The watcher +## 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/**`. -**Poll interval: 5 seconds.** 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. +Behind that there are two processes, and the split between them is the most +important property of this design: -Four things the watcher does that a naive `messages get --since` loop does not -— preserve them if you rewrite it: +``` +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. + +One hypothesis is already ruled out, so do not spend time on it: 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. + +### 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. -2. **Prime the seen-set from existing history at startup**, or arming the - watcher dumps the entire backlog as notifications in one burst. + 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. + 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.** It is the only handle on -the watcher: `leave` and `disconnect` print the `TaskStop` that needs it, and a -persistent monitor nobody can stop outlives the work and keeps polling a channel -where nothing will ever happen again. +**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 @@ -510,7 +638,7 @@ request), and says whether anything is listening for it: 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 polling 6c61c7b4 + spec-kit-arch-governance-init ce24afa247e2674c member live pid 49820 none pinned; still watching 6c61c7b4 ``` Three states are worth acting on: @@ -518,9 +646,9 @@ 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 polling`** — a live watcher for an identity that is no - longer in a room. That is a `Monitor` whose session moved on; it costs a relay - call every 5 seconds and wakes nobody. `TaskStop` it. +- **`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 @@ -680,7 +808,8 @@ Rules that make it work: |--------|------| | `buzz-connect.sh` | **the entry point.** `connect` / `join` / `status` / `leave` / `disconnect`, idempotently. | | `buzz-msg.sh` | `send` / `read` on the coordination channel | -| `buzz-watch.sh` | the Monitor poller; `-` as the name resolves this session | +| `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 | @@ -690,6 +819,11 @@ Prerequisites: `buzz` on `PATH` or a release build in the checkout 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 @@ -698,7 +832,9 @@ dependency — for JSON handling. 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. + 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. diff --git a/.claude/skills/buzz-multi-session/scripts/buzz-connect.sh b/.claude/skills/buzz-multi-session/scripts/buzz-connect.sh index a70bb469ea..7fa89f68b1 100755 --- a/.claude/skills/buzz-multi-session/scripts/buzz-connect.sh +++ b/.claude/skills/buzz-multi-session/scripts/buzz-connect.sh @@ -318,19 +318,59 @@ if [ "$SAY_HELLO" = 1 ]; then fi fi -# --- 8. watcher -------------------------------------------------------------- +# --- 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 — peers' messages cannot wake this session." + 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 @@ -344,12 +384,27 @@ Monitor( description: "buzz coordination: ${CHANNEL_NAME:-$CHANNEL}", persistent: true ) +EOF + +[ "$QUEUED" = 0 ] || cat <" diff --git a/.claude/skills/buzz-multi-session/scripts/buzz-stream.sh b/.claude/skills/buzz-multi-session/scripts/buzz-stream.sh new file mode 100755 index 0000000000..f2a21b029d --- /dev/null +++ b/.claude/skills/buzz-multi-session/scripts/buzz-stream.sh @@ -0,0 +1,396 @@ +#!/usr/bin/env bash +# buzz-stream.sh [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 index aeb8b2ef44..cdabb758a8 100755 --- a/.claude/skills/buzz-multi-session/scripts/buzz-watch.sh +++ b/.claude/skills/buzz-multi-session/scripts/buzz-watch.sh @@ -1,24 +1,42 @@ #!/usr/bin/env bash # buzz-watch.sh [session-name|-] [poll-seconds] # -# Emits one line per NEW message from a peer in the channel. 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. +# 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. # -# Pass "-" as the session name (what buzz-connect.sh prints) and the watcher -# resolves this session's identity itself, so the command stays correct after a -# /rename. It loads the identity file too; nothing is sourced by hand. +# 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. `buzz messages get --since ` is INCLUSIVE. A timestamp watermark -# alone re-emits the newest message on every single poll. We dedupe on -# event id instead and only use --since to bound the query. -# 2. The seen-set is primed from existing history on startup, so arming the -# watcher does not replay the backlog as a burst of notifications. -# 3. A session must never react to its own messages — filter on own pubkey. +# 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" @@ -27,83 +45,116 @@ NAME="${1:?usage: buzz-watch.sh [session-name|-] [poll-seconds]}" CH="${2:?usage: buzz-watch.sh [session-name|-] [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 \ +[ -f "$(identity_file "$NAME")" ] || 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 off stdout +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 +# 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" -SEEN=$(mktemp -t buzz-watch-seen) -# TERM and INT too: Monitor stops a watcher by signalling it, and a marker left -# behind would make buzz-connect.sh --status claim a watcher that is gone. -trap 'rm -f "$SEEN" "$MARKER"; exit 0' EXIT INT TERM +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 -# --- prime: everything already in the channel counts as seen ----------------- -"$BUZZ" messages get --channel "$CH" --limit 200 2>/dev/null \ - | 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 +# 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 -FILTER=$(cat <<'PY' -import json, os, sys -me = os.environ["ME"] -path = os.environ["SEEN"] -with open(path) as fh: - seen = set(fh.read().split()) -try: - msgs = json.load(sys.stdin) -except Exception: - sys.exit(0) -fresh = [] -for m in sorted(msgs, key=lambda x: x.get("created_at", 0)): - eid = m.get("id") - if not eid or eid in seen: - continue - seen.add(eid) - fresh.append(eid) - if m.get("pubkey") == me: # never react to our own messages - continue - if m.get("kind") not in (9, 1): # chat kinds only - continue - who = m.get("pubkey", "")[:8] - body = " ".join(m.get("content", "").split())[:400] - print("[buzz] %s: %s" % (who, body), flush=True) -if fresh: - with open(path, "a") as fh: - fh.write("\n".join(fresh) + "\n") -PY -) +# --- 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 -# Bound each query to a short trailing window; correctness comes from the -# id dedupe above, not from this watermark. -WINDOW="${BUZZ_WATCH_WINDOW:-300}" +# 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 -while true; do - SINCE=$(( $(date +%s) - WINDOW )) - OUT=$("$BUZZ" messages get --channel "$CH" --since "$SINCE" --limit 100 2>/dev/null) || OUT="" - if [ -n "$OUT" ]; then - printf '%s' "$OUT" | ME="$BUZZ_PUBKEY" SEEN="$SEEN" python3 -c "$FILTER" - fi - sleep "$SLEEP" +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 index 7fb08b2e3c..34a03482b0 100644 --- a/.claude/skills/buzz-multi-session/scripts/lib.sh +++ b/.claude/skills/buzz-multi-session/scripts/lib.sh @@ -937,6 +937,127 @@ diagnose_channel() { # $1 channel uuid, $2 channel name, $3 pubkey, $4 owner-or- EOF } +# --- the receiver, and why it is not the watcher ------------------------------ +# Two jobs that used to be one process, deliberately split: +# +# RECEIVING buzz-stream.sh. Holds the relay connection, filters, and appends +# one notification line to a log. Runs OUTSIDE Monitor, in its own +# process group, so nothing that happens to a Monitor task can stop +# messages being fetched. +# WAKING buzz-watch.sh, which is all Monitor runs: `tail -f` on that log, +# resuming from a stored line offset. +# +# The split exists because Monitor-hosted watchers have been observed dying +# (exit 144) while the identical command under nohup stayed healthy on the same +# channel. Nobody has a mechanism. What the split buys is that a mechanism is no +# longer needed: a dead Monitor 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 gone. +# +# Files are per identity AND per channel. One log per channel would be wrong: +# the log holds post-filter notifications, and the filter that matters most is +# "drop my own pubkey" — three worktree sessions sharing the default channel +# would each be woken by their own messages. +STREAM_DIR="${BUZZ_STREAM_DIR:-$HOME/.buzz/stream}" + +stream_base() { printf '%s/%s.%s' "$STREAM_DIR" "$1" "$(printf '%s' "$2" | cut -c1-8)"; } +stream_log() { printf '%s.log' "$(stream_base "$1" "$2")"; } # notifications +stream_err() { printf '%s.err' "$(stream_base "$1" "$2")"; } # the post-mortem +stream_hb() { printf '%s.hb' "$(stream_base "$1" "$2")"; } # pid + heartbeat +stream_pidf() { printf '%s.pid' "$(stream_base "$1" "$2")"; } +stream_pos() { printf '%s.pos' "$(stream_base "$1" "$2")"; } # lines delivered + +# A heartbeat older than this means dead or wedged. The receiver ticks every +# BUZZ_STREAM_TICK seconds, so this has to clear several ticks. +stream_tick() { setting BUZZ_STREAM_TICK 15; } +stream_stale() { setting BUZZ_STREAM_STALE 60; } + +# receiver_state NAME CHANNEL — "live " | "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. @@ -944,6 +1065,45 @@ 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") @@ -1036,11 +1196,12 @@ roster_report() { live=$((live + 1)) pid=${state#live }; ch=${pid#* }; pid=${pid%% *} state="live pid $pid" - # A watcher still polling 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 will poll until the Claude Code session ends. + # 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 polling ${ch:0:8}" + room="none pinned; still watching ${ch:0:8}" orphan=$((orphan + 1)) fi ;; unbound) @@ -1054,7 +1215,7 @@ roster_report() { $total identities, $live with a live watcher. - WATCHER live = a Monitor is polling for it now. none = nothing is listening, + WATCHER live = a Monitor is listening for it now. none = nothing is listening, but the identity is still a relay member and still holds a key. daemon = a provisioned agent identity (buzz-agent-provision.sh); it has no watcher by design, because its harness is its own event loop. @@ -1075,9 +1236,9 @@ roster_report() { EOF [ "$orphan" = 0 ] || cat <( + &self, + filter: serde_json::Value, + idle_timeout_secs: u64, + mut on_event: F, + ) -> Result + where + F: FnMut(&nostr::Event) -> Result<(), CliError>, + { + use buzz_ws_client::{NostrWsConnection, RelayMessage, WsClientError}; + + let ws_url = to_ws_url(&self.relay_url); + let mut conn = + NostrWsConnection::connect_authenticated(&ws_url, &self.keys, self.auth_tag.as_ref()) + .await + .map_err(|e| CliError::Other(format!("{ws_url}: {e}")))?; + + let sub_id = format!("buzz-cli-{}", uuid::Uuid::new_v4()); + conn.send_raw(&serde_json::json!(["REQ", sub_id, filter])) + .await + .map_err(|e| CliError::Other(format!("REQ failed: {e}")))?; + + let idle = std::time::Duration::from_secs(idle_timeout_secs); + loop { + match conn.next_event(idle).await { + Ok(RelayMessage::Event { + subscription_id, + event, + }) => { + if subscription_id == sub_id { + on_event(&event)?; + } + } + // EOSE means stored events are done and live delivery starts; + // COUNT and OK cannot arrive on this connection but are not worth + // tearing a working subscription down for. + Ok(RelayMessage::Eose { .. }) + | Ok(RelayMessage::Count { .. }) + | Ok(RelayMessage::Ok(_)) => {} + Ok(RelayMessage::Notice { message }) => { + eprintln!("relay notice: {message}"); + } + // A re-issued AUTH challenge means the relay stopped treating this + // connection as authenticated; the subscription is no longer + // delivering anything, so reconnecting is the only repair. + Ok(RelayMessage::Auth { .. }) => { + return Err(CliError::Other( + "relay re-issued an AUTH challenge — session no longer authenticated" + .into(), + )); + } + Ok(RelayMessage::Closed { message, .. }) => { + return Err(CliError::Other(format!( + "relay closed the subscription: {message}" + ))); + } + Err(WsClientError::Timeout) => { + return Err(CliError::Other(format!( + "no relay traffic for {idle_timeout_secs}s, not even a heartbeat — \ + treating the socket as dead" + ))); + } + Err(e) => return Err(CliError::Other(e.to_string())), + } + } + } + /// Upload a file to the relay's Blossom endpoint. /// Returns a BlobDescriptor on success. pub async fn upload_file(&self, file_path: &str) -> Result { @@ -1302,22 +1383,27 @@ fn to_ws_url(http_url: &str) -> String { .replace("http://", "ws://") } +/// Normalize one raw event JSON object into the shape every read path emits: +/// `{id, pubkey, kind, content, created_at, tags}`. +/// +/// Shared by the HTTP read path (`normalize_events`) and the WebSocket stream +/// (`messages subscribe`) so a consumer can dedupe across both without knowing +/// which one delivered a given event. +pub fn normalize_event(e: &serde_json::Value) -> serde_json::Value { + serde_json::json!({ + "id": e.get("id").and_then(|v| v.as_str()).unwrap_or(""), + "pubkey": e.get("pubkey").and_then(|v| v.as_str()).unwrap_or(""), + "kind": e.get("kind").and_then(|v| v.as_u64()).unwrap_or(0), + "content": e.get("content").and_then(|v| v.as_str()).unwrap_or(""), + "created_at": e.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0), + "tags": e.get("tags").cloned().unwrap_or(serde_json::json!([])), + }) +} + /// Normalize raw event JSON array into consistent shape. /// Each event becomes: {id, pubkey, kind, content, created_at, tags} pub fn normalize_events(events: &[serde_json::Value]) -> String { - let normalized: Vec = events - .iter() - .map(|e| { - serde_json::json!({ - "id": e.get("id").and_then(|v| v.as_str()).unwrap_or(""), - "pubkey": e.get("pubkey").and_then(|v| v.as_str()).unwrap_or(""), - "kind": e.get("kind").and_then(|v| v.as_u64()).unwrap_or(0), - "content": e.get("content").and_then(|v| v.as_str()).unwrap_or(""), - "created_at": e.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0), - "tags": e.get("tags").cloned().unwrap_or(serde_json::json!([])), - }) - }) - .collect(); + let normalized: Vec = events.iter().map(normalize_event).collect(); serde_json::to_string(&normalized).unwrap_or_default() } diff --git a/crates/buzz-cli/src/commands/messages.rs b/crates/buzz-cli/src/commands/messages.rs index 40a9ae80b5..00059bbe8b 100644 --- a/crates/buzz-cli/src/commands/messages.rs +++ b/crates/buzz-cli/src/commands/messages.rs @@ -1,8 +1,10 @@ +use std::io::Write; + use buzz_sdk::{DeleteMessageOptions, DiffMeta, ThreadRef, VoteDirection}; use nostr::PublicKey; use uuid::Uuid; -use crate::client::{normalize_events, normalize_write_response, BuzzClient}; +use crate::client::{normalize_event, normalize_events, normalize_write_response, BuzzClient}; use crate::error::CliError; use crate::validate::{ infer_language, parse_event_id, parse_uuid, read_or_stdin, truncate_diff, @@ -350,6 +352,24 @@ fn format_events(normalized: &str, format: &crate::OutputFormat) -> String { } } +/// The event kinds a channel read returns when the caller does not name any: +/// chat plus the channel-scoped system events a reader expects to see. +const CHANNEL_READ_KINDS: [u64; 5] = [9, 40002, 40008, 45001, 45003]; + +/// Parse `--kinds 9,1984` into a filter list, falling back to the default read +/// set. Shared by the HTTP read and the WebSocket stream so `messages subscribe` +/// delivers exactly what `messages get` would have returned. +fn channel_read_kinds(kinds: Option<&str>) -> Vec { + let parsed: Vec = kinds + .map(|k| k.split(',').filter_map(|s| s.trim().parse().ok()).collect()) + .unwrap_or_default(); + if parsed.is_empty() { + CHANNEL_READ_KINDS.to_vec() + } else { + parsed + } +} + pub async fn cmd_get_messages( client: &BuzzClient, channel_id: &str, @@ -363,19 +383,11 @@ pub async fn cmd_get_messages( let limit = limit.unwrap_or(50).min(200); let mut filter = serde_json::json!({ - "kinds": [9, 40002, 40008, 45001, 45003], + "kinds": channel_read_kinds(kinds), "#h": [channel_id], "limit": limit }); - // If specific kinds requested, override - if let Some(k) = kinds { - let kind_list: Vec = k.split(',').filter_map(|s| s.trim().parse().ok()).collect(); - if !kind_list.is_empty() { - filter["kinds"] = serde_json::json!(kind_list); - } - } - if let Some(b) = before { filter["until"] = serde_json::json!(b); } @@ -391,6 +403,91 @@ pub async fn cmd_get_messages( Ok(()) } +/// Relay heartbeat period (`buzz_relay::connection::heartbeat_loop`). The idle +/// deadline has to clear several of these or a healthy quiet channel looks dead. +const RELAY_HEARTBEAT_SECS: u64 = 30; + +/// Stream a channel over a held-open authenticated WebSocket, one normalized +/// event per line on stdout, until the connection stops delivering. +/// +/// Deliberately not `--format`-aware. The output is a transport for a program +/// that reads one line at a time and acts on it, not a rendering for a person, +/// and a pretty table cannot be consumed a line at a time. +pub async fn cmd_subscribe_messages( + client: &BuzzClient, + channel_id: &str, + kinds: Option<&str>, + since: Option, + idle_timeout: u64, + reconnect_after: u64, +) -> Result<(), CliError> { + validate_uuid(channel_id)?; + if idle_timeout <= RELAY_HEARTBEAT_SECS { + return Err(CliError::Usage(format!( + "--idle-timeout {idle_timeout} is not above the relay's {RELAY_HEARTBEAT_SECS}s \ + heartbeat, so a healthy connection would be torn down as dead; use 90 or more" + ))); + } + + // Default to "from now". History is what `messages get` is for, and a + // subscriber that replays the backlog on every reconnect turns a flapping + // network into a flood. + let since = since.unwrap_or_else(|| { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) + }); + + let filter = serde_json::json!({ + "kinds": channel_read_kinds(kinds), + "#h": [channel_id], + "since": since, + }); + + let mut out = std::io::stdout().lock(); + let mut reader_gone = false; + + let stream = client.subscribe_events(filter, idle_timeout, |event| { + let raw = serde_json::to_value(event) + .map_err(|e| CliError::Other(format!("cannot serialize event: {e}")))?; + let line = normalize_event(&raw).to_string(); + // Flush every line. The consumer is a reader blocked on this pipe, so + // a line sitting in a buffer is an undelivered message — the exact + // failure this command exists to remove. + if let Err(e) = writeln!(out, "{line}").and_then(|()| out.flush()) { + if e.kind() == std::io::ErrorKind::BrokenPipe { + reader_gone = true; + } + return Err(CliError::Other(format!("stdout: {e}"))); + } + Ok(()) + }); + + // A healthy subscription is indistinguishable from a subscription the relay + // has quietly stopped matching against: both are silent, and both heartbeat. + // Ending a good connection on a schedule is what gives the supervisor its + // chance to re-read over HTTP and find out which one this was. + let stopped = if reconnect_after == 0 { + stream.await + } else { + match tokio::time::timeout(std::time::Duration::from_secs(reconnect_after), stream).await { + Ok(reason) => reason, + Err(_) => Err(CliError::Other(format!( + "scheduled re-subscribe after {reconnect_after}s" + ))), + } + }; + + match stopped { + Ok(never) => match never {}, + // The reader closed the pipe. That is the reader's decision, not a + // failure of the stream, and must not be reported as one. + Err(_) if reader_gone => Ok(()), + Err(reason) => Err(reason), + } +} + pub async fn cmd_get_thread( client: &BuzzClient, channel_id: &str, @@ -962,6 +1059,23 @@ pub async fn dispatch( ) .await } + MessagesCmd::Subscribe { + channel, + kinds, + since, + idle_timeout, + reconnect_after, + } => { + cmd_subscribe_messages( + client, + &channel, + kinds.as_deref(), + since, + idle_timeout, + reconnect_after, + ) + .await + } MessagesCmd::Thread { channel, event, diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index f745e7b280..c56fe5b015 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -460,6 +460,28 @@ pub enum MessagesCmd { #[arg(long)] kinds: Option, }, + /// Stream new messages from a channel over a live WebSocket, one JSON object + /// per line, until the connection drops + #[command( + after_help = "Holds an authenticated NIP-42 connection open and prints each matching\nevent as it arrives — no polling, no interval.\n\nOutput is newline-delimited JSON on stdout, one event per line, in the same\nshape 'messages get' returns, flushed immediately so a reader blocked on a\nline wakes the moment the relay pushes one. Diagnostics go to stderr.\n\nIt never exits 0. Every exit is a reason the stream stopped, so a supervisor\ncan reconnect and backfill the gap with 'messages get --since' instead of\nsitting on a dead socket believing the channel is quiet.\n\nExamples:\n buzz messages subscribe --channel \n buzz messages subscribe --channel --kinds 9 --since 1783497600" + )] + Subscribe { + /// Channel UUID + #[arg(long)] + channel: String, + /// Comma-separated event kinds to stream [default: the same set 'messages get' reads] + #[arg(long)] + kinds: Option, + /// Unix timestamp — only stream events at or after this time [default: now] + #[arg(long)] + since: Option, + /// Give up when the relay sends nothing at all, heartbeats included, for this many seconds + #[arg(long, default_value_t = 90)] + idle_timeout: u64, + /// Stop after this many seconds even while healthy, so a supervisor can backfill over HTTP and re-subscribe [0 disables] + #[arg(long, default_value_t = 300)] + reconnect_after: u64, + }, /// Get a message thread (replies to a root message) Thread { /// Channel UUID @@ -2161,6 +2183,7 @@ mod tests { "search", "send", "send-diff", + "subscribe", "thread", "vote" ] @@ -2295,7 +2318,7 @@ mod tests { ("feed", 1), ("issues", 4), ("media", 1), - ("messages", 8), + ("messages", 9), ("pack", 2), ("patches", 4), ("pr", 5), From f968d39f0f386b2cddcda4439848b81369757122 Mon Sep 17 00:00:00 2001 From: Ash Brener Date: Mon, 3 Aug 2026 23:55:35 +0200 Subject: [PATCH 09/10] docs(skills): say what a watcher's exit code means MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 143 is 128+15: someone stopped it, which is normal. 144 is 128+16 and means it died on its own, which is a bug worth reporting with the receiver's .err file. The distinction was earned rather than assumed — a live Monitor watcher was deliberately SIGTERMed and reported 143, which is what rules out "the harness reaped it" and "someone stopped it" as explanations for the 144s seen in the field. Two lines here save the next person that hour. Either way the response is identical, which is the point of splitting reception from waking: run status, re-arm, nothing was lost. Signed-off-by: Ash Brener --- .claude/skills/buzz-multi-session/SKILL.md | 25 +++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/.claude/skills/buzz-multi-session/SKILL.md b/.claude/skills/buzz-multi-session/SKILL.md index f0e85ab26e..a478b3ecd0 100644 --- a/.claude/skills/buzz-multi-session/SKILL.md +++ b/.claude/skills/buzz-multi-session/SKILL.md @@ -381,11 +381,26 @@ 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. -One hypothesis is already ruled out, so do not spend time on it: 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. +**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 From 2a48b3405091ba81869a62d42803e94307b48ae8 Mon Sep 17 00:00:00 2001 From: Ash Brener Date: Tue, 4 Aug 2026 08:57:58 +0200 Subject: [PATCH 10/10] refactor(skills): move `messages subscribe` out to its own PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR is a skill; it should not also be shipping a new buzz-cli verb. The subscribe work now lives on feat/cli-messages-subscribe, where it can be reviewed as the CLI change it is, and where it stops colliding with the invites work in #4479 — both were adding methods to the same regions of client.rs and lib.rs, so whichever merged first would have broken the other. Nothing here regresses. buzz-stream.sh already probes for the verb (`messages subscribe --help`) and falls back to the HTTP sweep when it is absent, which is the polling loop this skill has always used. With the CLI change merged the same skill gets push delivery for free; without it, it behaves exactly as it did before. Signed-off-by: Ash Brener --- crates/buzz-cli/README.md | 1 - crates/buzz-cli/src/client.rs | 112 +++---------------- crates/buzz-cli/src/commands/messages.rs | 134 ++--------------------- crates/buzz-cli/src/lib.rs | 25 +---- 4 files changed, 24 insertions(+), 248 deletions(-) diff --git a/crates/buzz-cli/README.md b/crates/buzz-cli/README.md index a28f0ccbd9..a2dcdce6d2 100644 --- a/crates/buzz-cli/README.md +++ b/crates/buzz-cli/README.md @@ -107,7 +107,6 @@ stored rules in `validation_error` so an owner can remove and repair them. | | `edit` | Edit a message you sent | | | `delete` | Delete a message | | | `get` | List messages in a channel | -| | `subscribe` | Stream new messages live over WebSocket, one JSON object per line | | | `thread` | Get a message thread | | | `search` | Full-text search, filterable by author | | | `vote` | Vote on a forum post | diff --git a/crates/buzz-cli/src/client.rs b/crates/buzz-cli/src/client.rs index 7c86149d46..d0dd2677a9 100644 --- a/crates/buzz-cli/src/client.rs +++ b/crates/buzz-cli/src/client.rs @@ -1095,87 +1095,6 @@ impl BuzzClient { .to_string()) } - /// Hold an authenticated WebSocket subscription open and hand every matching - /// event to `on_event` as the relay pushes it. - /// - /// This never returns `Ok`: a subscription that is working is a subscription - /// that has not finished. Every return is a reason the stream stopped, which - /// is the only thing a caller can act on — a reader that cannot tell "quiet - /// channel" from "dead socket" is worse than a poller, because it believes - /// it is listening. - /// - /// `idle_timeout_secs` is what makes that distinction possible. The relay - /// heartbeats every 30 s (`buzz_relay::connection::heartbeat_loop`) and - /// `NostrWsConnection` answers each Ping without surfacing it, so the read - /// deadline is only reached when nothing at all arrived — a half-dead socket, - /// not a quiet room. Keep the value a comfortable multiple of 30. - pub async fn subscribe_events( - &self, - filter: serde_json::Value, - idle_timeout_secs: u64, - mut on_event: F, - ) -> Result - where - F: FnMut(&nostr::Event) -> Result<(), CliError>, - { - use buzz_ws_client::{NostrWsConnection, RelayMessage, WsClientError}; - - let ws_url = to_ws_url(&self.relay_url); - let mut conn = - NostrWsConnection::connect_authenticated(&ws_url, &self.keys, self.auth_tag.as_ref()) - .await - .map_err(|e| CliError::Other(format!("{ws_url}: {e}")))?; - - let sub_id = format!("buzz-cli-{}", uuid::Uuid::new_v4()); - conn.send_raw(&serde_json::json!(["REQ", sub_id, filter])) - .await - .map_err(|e| CliError::Other(format!("REQ failed: {e}")))?; - - let idle = std::time::Duration::from_secs(idle_timeout_secs); - loop { - match conn.next_event(idle).await { - Ok(RelayMessage::Event { - subscription_id, - event, - }) => { - if subscription_id == sub_id { - on_event(&event)?; - } - } - // EOSE means stored events are done and live delivery starts; - // COUNT and OK cannot arrive on this connection but are not worth - // tearing a working subscription down for. - Ok(RelayMessage::Eose { .. }) - | Ok(RelayMessage::Count { .. }) - | Ok(RelayMessage::Ok(_)) => {} - Ok(RelayMessage::Notice { message }) => { - eprintln!("relay notice: {message}"); - } - // A re-issued AUTH challenge means the relay stopped treating this - // connection as authenticated; the subscription is no longer - // delivering anything, so reconnecting is the only repair. - Ok(RelayMessage::Auth { .. }) => { - return Err(CliError::Other( - "relay re-issued an AUTH challenge — session no longer authenticated" - .into(), - )); - } - Ok(RelayMessage::Closed { message, .. }) => { - return Err(CliError::Other(format!( - "relay closed the subscription: {message}" - ))); - } - Err(WsClientError::Timeout) => { - return Err(CliError::Other(format!( - "no relay traffic for {idle_timeout_secs}s, not even a heartbeat — \ - treating the socket as dead" - ))); - } - Err(e) => return Err(CliError::Other(e.to_string())), - } - } - } - /// Upload a file to the relay's Blossom endpoint. /// Returns a BlobDescriptor on success. pub async fn upload_file(&self, file_path: &str) -> Result { @@ -1383,27 +1302,22 @@ fn to_ws_url(http_url: &str) -> String { .replace("http://", "ws://") } -/// Normalize one raw event JSON object into the shape every read path emits: -/// `{id, pubkey, kind, content, created_at, tags}`. -/// -/// Shared by the HTTP read path (`normalize_events`) and the WebSocket stream -/// (`messages subscribe`) so a consumer can dedupe across both without knowing -/// which one delivered a given event. -pub fn normalize_event(e: &serde_json::Value) -> serde_json::Value { - serde_json::json!({ - "id": e.get("id").and_then(|v| v.as_str()).unwrap_or(""), - "pubkey": e.get("pubkey").and_then(|v| v.as_str()).unwrap_or(""), - "kind": e.get("kind").and_then(|v| v.as_u64()).unwrap_or(0), - "content": e.get("content").and_then(|v| v.as_str()).unwrap_or(""), - "created_at": e.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0), - "tags": e.get("tags").cloned().unwrap_or(serde_json::json!([])), - }) -} - /// Normalize raw event JSON array into consistent shape. /// Each event becomes: {id, pubkey, kind, content, created_at, tags} pub fn normalize_events(events: &[serde_json::Value]) -> String { - let normalized: Vec = events.iter().map(normalize_event).collect(); + let normalized: Vec = events + .iter() + .map(|e| { + serde_json::json!({ + "id": e.get("id").and_then(|v| v.as_str()).unwrap_or(""), + "pubkey": e.get("pubkey").and_then(|v| v.as_str()).unwrap_or(""), + "kind": e.get("kind").and_then(|v| v.as_u64()).unwrap_or(0), + "content": e.get("content").and_then(|v| v.as_str()).unwrap_or(""), + "created_at": e.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0), + "tags": e.get("tags").cloned().unwrap_or(serde_json::json!([])), + }) + }) + .collect(); serde_json::to_string(&normalized).unwrap_or_default() } diff --git a/crates/buzz-cli/src/commands/messages.rs b/crates/buzz-cli/src/commands/messages.rs index 00059bbe8b..40a9ae80b5 100644 --- a/crates/buzz-cli/src/commands/messages.rs +++ b/crates/buzz-cli/src/commands/messages.rs @@ -1,10 +1,8 @@ -use std::io::Write; - use buzz_sdk::{DeleteMessageOptions, DiffMeta, ThreadRef, VoteDirection}; use nostr::PublicKey; use uuid::Uuid; -use crate::client::{normalize_event, normalize_events, normalize_write_response, BuzzClient}; +use crate::client::{normalize_events, normalize_write_response, BuzzClient}; use crate::error::CliError; use crate::validate::{ infer_language, parse_event_id, parse_uuid, read_or_stdin, truncate_diff, @@ -352,24 +350,6 @@ fn format_events(normalized: &str, format: &crate::OutputFormat) -> String { } } -/// The event kinds a channel read returns when the caller does not name any: -/// chat plus the channel-scoped system events a reader expects to see. -const CHANNEL_READ_KINDS: [u64; 5] = [9, 40002, 40008, 45001, 45003]; - -/// Parse `--kinds 9,1984` into a filter list, falling back to the default read -/// set. Shared by the HTTP read and the WebSocket stream so `messages subscribe` -/// delivers exactly what `messages get` would have returned. -fn channel_read_kinds(kinds: Option<&str>) -> Vec { - let parsed: Vec = kinds - .map(|k| k.split(',').filter_map(|s| s.trim().parse().ok()).collect()) - .unwrap_or_default(); - if parsed.is_empty() { - CHANNEL_READ_KINDS.to_vec() - } else { - parsed - } -} - pub async fn cmd_get_messages( client: &BuzzClient, channel_id: &str, @@ -383,11 +363,19 @@ pub async fn cmd_get_messages( let limit = limit.unwrap_or(50).min(200); let mut filter = serde_json::json!({ - "kinds": channel_read_kinds(kinds), + "kinds": [9, 40002, 40008, 45001, 45003], "#h": [channel_id], "limit": limit }); + // If specific kinds requested, override + if let Some(k) = kinds { + let kind_list: Vec = k.split(',').filter_map(|s| s.trim().parse().ok()).collect(); + if !kind_list.is_empty() { + filter["kinds"] = serde_json::json!(kind_list); + } + } + if let Some(b) = before { filter["until"] = serde_json::json!(b); } @@ -403,91 +391,6 @@ pub async fn cmd_get_messages( Ok(()) } -/// Relay heartbeat period (`buzz_relay::connection::heartbeat_loop`). The idle -/// deadline has to clear several of these or a healthy quiet channel looks dead. -const RELAY_HEARTBEAT_SECS: u64 = 30; - -/// Stream a channel over a held-open authenticated WebSocket, one normalized -/// event per line on stdout, until the connection stops delivering. -/// -/// Deliberately not `--format`-aware. The output is a transport for a program -/// that reads one line at a time and acts on it, not a rendering for a person, -/// and a pretty table cannot be consumed a line at a time. -pub async fn cmd_subscribe_messages( - client: &BuzzClient, - channel_id: &str, - kinds: Option<&str>, - since: Option, - idle_timeout: u64, - reconnect_after: u64, -) -> Result<(), CliError> { - validate_uuid(channel_id)?; - if idle_timeout <= RELAY_HEARTBEAT_SECS { - return Err(CliError::Usage(format!( - "--idle-timeout {idle_timeout} is not above the relay's {RELAY_HEARTBEAT_SECS}s \ - heartbeat, so a healthy connection would be torn down as dead; use 90 or more" - ))); - } - - // Default to "from now". History is what `messages get` is for, and a - // subscriber that replays the backlog on every reconnect turns a flapping - // network into a flood. - let since = since.unwrap_or_else(|| { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs() as i64) - .unwrap_or(0) - }); - - let filter = serde_json::json!({ - "kinds": channel_read_kinds(kinds), - "#h": [channel_id], - "since": since, - }); - - let mut out = std::io::stdout().lock(); - let mut reader_gone = false; - - let stream = client.subscribe_events(filter, idle_timeout, |event| { - let raw = serde_json::to_value(event) - .map_err(|e| CliError::Other(format!("cannot serialize event: {e}")))?; - let line = normalize_event(&raw).to_string(); - // Flush every line. The consumer is a reader blocked on this pipe, so - // a line sitting in a buffer is an undelivered message — the exact - // failure this command exists to remove. - if let Err(e) = writeln!(out, "{line}").and_then(|()| out.flush()) { - if e.kind() == std::io::ErrorKind::BrokenPipe { - reader_gone = true; - } - return Err(CliError::Other(format!("stdout: {e}"))); - } - Ok(()) - }); - - // A healthy subscription is indistinguishable from a subscription the relay - // has quietly stopped matching against: both are silent, and both heartbeat. - // Ending a good connection on a schedule is what gives the supervisor its - // chance to re-read over HTTP and find out which one this was. - let stopped = if reconnect_after == 0 { - stream.await - } else { - match tokio::time::timeout(std::time::Duration::from_secs(reconnect_after), stream).await { - Ok(reason) => reason, - Err(_) => Err(CliError::Other(format!( - "scheduled re-subscribe after {reconnect_after}s" - ))), - } - }; - - match stopped { - Ok(never) => match never {}, - // The reader closed the pipe. That is the reader's decision, not a - // failure of the stream, and must not be reported as one. - Err(_) if reader_gone => Ok(()), - Err(reason) => Err(reason), - } -} - pub async fn cmd_get_thread( client: &BuzzClient, channel_id: &str, @@ -1059,23 +962,6 @@ pub async fn dispatch( ) .await } - MessagesCmd::Subscribe { - channel, - kinds, - since, - idle_timeout, - reconnect_after, - } => { - cmd_subscribe_messages( - client, - &channel, - kinds.as_deref(), - since, - idle_timeout, - reconnect_after, - ) - .await - } MessagesCmd::Thread { channel, event, diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index c56fe5b015..f745e7b280 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -460,28 +460,6 @@ pub enum MessagesCmd { #[arg(long)] kinds: Option, }, - /// Stream new messages from a channel over a live WebSocket, one JSON object - /// per line, until the connection drops - #[command( - after_help = "Holds an authenticated NIP-42 connection open and prints each matching\nevent as it arrives — no polling, no interval.\n\nOutput is newline-delimited JSON on stdout, one event per line, in the same\nshape 'messages get' returns, flushed immediately so a reader blocked on a\nline wakes the moment the relay pushes one. Diagnostics go to stderr.\n\nIt never exits 0. Every exit is a reason the stream stopped, so a supervisor\ncan reconnect and backfill the gap with 'messages get --since' instead of\nsitting on a dead socket believing the channel is quiet.\n\nExamples:\n buzz messages subscribe --channel \n buzz messages subscribe --channel --kinds 9 --since 1783497600" - )] - Subscribe { - /// Channel UUID - #[arg(long)] - channel: String, - /// Comma-separated event kinds to stream [default: the same set 'messages get' reads] - #[arg(long)] - kinds: Option, - /// Unix timestamp — only stream events at or after this time [default: now] - #[arg(long)] - since: Option, - /// Give up when the relay sends nothing at all, heartbeats included, for this many seconds - #[arg(long, default_value_t = 90)] - idle_timeout: u64, - /// Stop after this many seconds even while healthy, so a supervisor can backfill over HTTP and re-subscribe [0 disables] - #[arg(long, default_value_t = 300)] - reconnect_after: u64, - }, /// Get a message thread (replies to a root message) Thread { /// Channel UUID @@ -2183,7 +2161,6 @@ mod tests { "search", "send", "send-diff", - "subscribe", "thread", "vote" ] @@ -2318,7 +2295,7 @@ mod tests { ("feed", 1), ("issues", 4), ("media", 1), - ("messages", 9), + ("messages", 8), ("pack", 2), ("patches", 4), ("pr", 5),