Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ members = [
"crates/buzz-workflow",
"crates/buzz-media",
"crates/buzz-cli",
"crates/buzz-supervisor",
"crates/buzz-pairing-cli",
"crates/buzz-sdk",
"crates/buzz-persona",
Expand Down
4 changes: 2 additions & 2 deletions crates/buzz-cli/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
pub mod agent_management;
mod client;
pub mod client;
mod commands;
mod error;
pub mod error;
mod links;
mod validate;

Expand Down
51 changes: 51 additions & 0 deletions crates/buzz-supervisor/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
[package]
name = "buzz-supervisor"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "Headless relay-side team supervisor — the server analog of Desktop Managed Agents"

[[bin]]
name = "buzz-supervisor"
path = "src/main.rs"

[dependencies]
# Internal — reuses buzz-cli's already-battle-tested relay HTTP client
# (NIP-98 signing, query/submit_event) instead of reimplementing it.
buzz-cli = { path = "../buzz-cli" }
buzz-core = { workspace = true }
buzz-sdk = { workspace = true }

# Nostr
nostr = { workspace = true }

# Async runtime
tokio = { workspace = true }

# Serialization / config
serde = { workspace = true }
serde_json = { workspace = true }
toml = "1.0"

# IDs
uuid = { workspace = true }

# Extracting `workdir:<path>` out of free-text channel descriptions
regex = "1"

# Logging
tracing = { workspace = true }
tracing-subscriber = { workspace = true }

# Error handling
anyhow = { workspace = true }

# CLI
clap = { version = "4", features = ["derive", "env"] }

# Safe wrapper around POSIX `kill(2)` for stop_process — keeps this crate
# unsafe-code-free (matches buzz-acp's kill_process_group rationale).
[target.'cfg(unix)'.dependencies]
nix = { version = "0.31", default-features = false, features = ["signal"] }
37 changes: 37 additions & 0 deletions crates/buzz-supervisor/roles.example.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Example team roster for buzz-supervisor.
#
# Every provisioned channel gets one fresh keypair + one spawned `buzz-acp`
# process per [[roles]] entry below, all rooted at that channel's `workdir`.
#
# NOTE: top-level keys (shared_members, extra_allowlist) must come BEFORE
# any [[roles]] block — TOML attaches trailing top-level keys to the last
# array-of-tables entry, not the document root. (RoleConfig rejects unknown
# fields specifically so getting this backwards is a loud parse error
# instead of the keys silently vanishing.)

# Added as a plain channel member (no process spawned) to every provisioned
# channel — e.g. an externally-bridged Reviewer identity.
shared_members = ["ce2fe47f9df685d5070407ab64e903b0fe76123b42b37b4cadee2552b5cb2b02"]

# Always included in every role's respond_to_allowlist, in addition to the
# team's own generated pubkeys — typically the human owner(s).
extra_allowlist = ["c19fc270d15c6bb3e6ca7910dd7c9e7aa760019fe1e729196450a173f157f227"]

[[roles]]
name = "load-balancer"
display_name = "Load Balancer"
agent_command = "claude-agent-acp"
system_prompt_file = "/home/alice/.buzz_agents/load-balancer-prompt.txt"
# At most one role per team should set this — see relay.md's "one listener
# per channel" note. The rest default to `false` (mentions-only).
subscribe_all = true

[[roles]]
name = "coder"
display_name = "Coder"
agent_command = "claude-agent-acp"

[[roles]]
name = "writer"
display_name = "Writer"
agent_command = "claude-agent-acp"
68 changes: 68 additions & 0 deletions crates/buzz-supervisor/src/config.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
//! Team roster configuration — which roles get provisioned per channel and
//! how each one is launched. Loaded from a TOML file at startup.

use serde::Deserialize;
use std::fs;
use std::path::Path;

fn default_agent_command() -> String {
"claude-agent-acp".to_string()
}

#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RoleConfig {
/// Stable identifier, used for state-file paths (e.g. "coder").
pub name: String,
/// Human-readable name published in the agent's `kind:0`/`kind:10100`
/// profiles (e.g. "Coder").
pub display_name: String,
/// `BUZZ_ACP_AGENT_COMMAND` for this role's `buzz-acp` process.
#[serde(default = "default_agent_command")]
pub agent_command: String,
/// Optional file whose contents become `BUZZ_ACP_SYSTEM_PROMPT`.
pub system_prompt_file: Option<String>,
/// If true, this role is launched with `--subscribe all` instead of the
/// default `mentions` — see relay.md's "one listener per channel" note.
/// At most one role per team should set this.
#[serde(default)]
pub subscribe_all: bool,
}

#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SupervisorConfig {
/// Roles that get a fresh keypair + a spawned `buzz-acp` process per
/// provisioned channel.
pub roles: Vec<RoleConfig>,
/// Pubkeys added as plain (no process) channel members to every
/// provisioned channel — e.g. an externally-bridged Reviewer identity.
#[serde(default)]
pub shared_members: Vec<String>,
/// Pubkeys always included in every role's `respond_to_allowlist`,
/// beyond the team's own generated pubkeys — typically the human
/// owner(s) who should be able to trigger any role directly.
#[serde(default)]
pub extra_allowlist: Vec<String>,
}

impl SupervisorConfig {
pub fn load(path: &Path) -> anyhow::Result<Self> {
let raw = fs::read_to_string(path)
.map_err(|e| anyhow::anyhow!("reading roles file {}: {e}", path.display()))?;
let config: Self = toml::from_str(&raw)
.map_err(|e| anyhow::anyhow!("parsing roles file {}: {e}", path.display()))?;
if config.roles.is_empty() {
anyhow::bail!("roles file {} defines no roles", path.display());
}
let subscribe_all_count = config.roles.iter().filter(|r| r.subscribe_all).count();
if subscribe_all_count > 1 {
tracing::warn!(
count = subscribe_all_count,
"more than one role has subscribe_all=true — they will all react to every \
unaddressed message in the channel, which is usually not intended"
);
}
Ok(config)
}
}
130 changes: 130 additions & 0 deletions crates/buzz-supervisor/src/main.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
//! buzz-supervisor — the headless, relay-side analog of Buzz Desktop's
//! Managed Agents. Watches a relay for channels whose `description`
//! declares a `workdir:<path>`, and provisions/heals/tears down a team of
//! `buzz-acp` processes rooted at that directory for each one.
//!
//! This binary does nothing by default. It requires an explicit
//! `--relay-url` (no default — deliberately unlike `buzz-acp`, which
//! defaults to `ws://localhost:3000`) and at least one `--allowed-root`, so
//! a relay operator can never end up running it by accident against the
//! wrong deployment or with an unbounded filesystem scope. See relay.md for
//! the full design rationale.
#![deny(unsafe_code)]

mod config;
mod provision;
mod security;
mod state;

use std::path::PathBuf;
use std::time::Duration;

use clap::Parser;
use nostr::Keys;
use regex::Regex;

use buzz_cli::client::BuzzClient;

use config::SupervisorConfig;
use provision::Ctx;
use security::AllowedRoots;
use state::StateStore;

#[derive(Parser)]
#[command(
about = "Headless relay-side agent-team supervisor (server analog of Buzz Desktop Managed Agents)",
after_help = "Example:\n buzz-supervisor \\\n --relay-url http://lotto645.lge.com:3000 \\\n --private-key <owner-cli-key> \\\n --relay-admin-key <BUZZ_RELAY_PRIVATE_KEY> \\\n --allowed-root /home/alice/code \\\n --roles-file /home/alice/.buzz_agents/roles.toml \\\n --state-dir /home/alice/.buzz_agents/supervisor/state"
)]
struct Args {
/// Relay base URL. Required, no default — buzz-supervisor only runs
/// against a relay deployment its operator has deliberately named.
#[arg(long, env = "BUZZ_SUPERVISOR_RELAY_URL")]
relay_url: String,

/// Private key (hex or nsec) for the identity that owns every
/// provisioned channel membership/profile this instance creates.
#[arg(long, env = "BUZZ_SUPERVISOR_PRIVATE_KEY")]
private_key: String,

/// Relay admin signing key, forwarded to `buzz-admin add-member` for
/// relay-wide membership registration (see relay-admin's own docs for
/// why this can't be done over the plain client HTTP API).
#[arg(long, env = "BUZZ_SUPERVISOR_RELAY_ADMIN_KEY")]
relay_admin_key: String,

/// Path to the `buzz-admin` binary.
#[arg(long, env = "BUZZ_SUPERVISOR_ADMIN_BIN", default_value = "buzz-admin")]
admin_bin: PathBuf,

/// Path to the `buzz-acp` binary spawned per role.
#[arg(long, env = "BUZZ_SUPERVISOR_ACP_BIN", default_value = "buzz-acp")]
acp_bin: PathBuf,

/// Directory agents are allowed to work in (repeatable). Any `workdir`
/// resolving (after symlink/`..` resolution) outside every one of these
/// is rejected — this is the security boundary, not `channel_add_policy`
/// or anything relay-side.
#[arg(long = "allowed-root", required = true)]
allowed_roots: Vec<PathBuf>,

/// TOML file defining the team roster (roles → harness/prompt). See
/// `roles.example.toml` in this crate for the shape.
#[arg(long, env = "BUZZ_SUPERVISOR_ROLES_FILE")]
roles_file: PathBuf,

/// Directory for per-channel state (generated keys, pids, per-role logs).
#[arg(long, env = "BUZZ_SUPERVISOR_STATE_DIR")]
state_dir: PathBuf,

/// Poll interval in seconds.
#[arg(long, default_value_t = 20)]
poll_interval_secs: u64,
}

#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt::init();
let args = Args::parse();

let config = SupervisorConfig::load(&args.roles_file)?;
let allowed_roots = AllowedRoots::new(args.allowed_roots.clone())?;
let state = StateStore::new(args.state_dir.clone())?;

let keys = Keys::parse(&args.private_key)
.map_err(|e| anyhow::anyhow!("invalid --private-key: {e}"))?;
let client = BuzzClient::new(args.relay_url.clone(), keys, None, None)
.map_err(|e| anyhow::anyhow!("building relay client: {e}"))?;

// `workdir:` may appear anywhere in the description (e.g. embedded in a
// longer human-written sentence), and humans won't always type it with
// zero spacing around the colon — allow whitespace there, but capture
// the path itself up to the next whitespace.
let workdir_pattern = Regex::new(r"workdir\s*:\s*(\S+)")?;

tracing::info!(
relay_url = %args.relay_url,
allowed_roots = ?args.allowed_roots,
roles = ?config.roles.iter().map(|r| &r.name).collect::<Vec<_>>(),
poll_interval_secs = args.poll_interval_secs,
"buzz-supervisor starting"
);

let ctx = Ctx {
client,
config,
allowed_roots,
state,
acp_bin: args.acp_bin,
admin_bin: args.admin_bin,
relay_admin_key: args.relay_admin_key,
relay_url_for_admin: args.relay_url,
workdir_pattern,
};

loop {
if let Err(e) = provision::run_once(&ctx).await {
tracing::error!(error = %e, "poll iteration failed");
}
tokio::time::sleep(Duration::from_secs(args.poll_interval_secs)).await;
}
}
Loading