feat(buzz-acp): update base prompt; add buzz context and skills to Pi agents (#7335)

## Summary

- launch Pi through a private Buzz wrapper so managed Pi agents receive
Buzz's base prompt plus the configured thread/channel session model via
`--system-prompt`
- load the Buzz workspace skill directory with Pi's repeatable `--skill`
flag, making `~/.buzz/.agents/skills/buzz-cli` available alongside Pi's
normal global skill discovery
- report Pi and `pi-acp` installation states separately so setup
guidance points to the missing component

`pi-acp` does not currently consume the ACP `session/new` system prompt,
but its normal new-session and restore paths do honor
`PI_ACP_PI_COMMAND` as the executable used to launch Pi. Buzz reserves
that variable and sets it to a private generated launcher. The launcher
invokes `pi` from Buzz's effective `PATH`, adds `--system-prompt <file>`
and `--skill <workspace>/.agents/skills`, and forwards `pi-acp`'s RPC
and session arguments unchanged. This keeps the integration entirely in
`block/buzz`, without changes to Pi or `pi-acp`.

### How PI_ACP_PI_COMMAND works in `pi-acp`

`PI_ACP_PI_COMMAND` selects one executable; it does not accept
arguments. Therefore:

```bash
# Does not work
PI_ACP_PI_COMMAND="pi --skill ~/.buzz/.agents/skills"
```

Buzz does not expose this variable as user configuration. It creates a
private launcher and sets `PI_ACP_PI_COMMAND` only on the `pi-acp`
child. Conceptually, that launcher executes:

```sh
#!/bin/sh
exec pi \
  --system-prompt "$PRIVATE_SYSTEM_PROMPT" \
  --skill "$HOME/.buzz/.agents/skills" \
  "$@"
```

### Related issue

Follow-up to #7208. No duplicate issue or open PR found.

### Testing

- `just ci`
- `cargo test -p buzz-acp pi_launcher`
- `cargo test --manifest-path desktop/src-tauri/Cargo.toml
managed_agents::env_vars::tests`
- `cargo test --manifest-path desktop/src-tauri/Cargo.toml
managed_agents::discovery::presets::tests`
- pre-push branch checks

No screenshots: the UI change is state-dependent setup guidance only.

---
**Update Sep 4, 13:35:** Made `PI_ACP_PI_COMMAND` entirely Buzz-owned.
- Managed agent configuration now rejects the variable as a user
override.
- Buzz always launches `pi` from its effective `PATH` and rejects
inherited values before creating its private launcher.
- Removed the custom Pi executable discovery path and its internal
environment alias.

---
**Update Sep 4, 14:03:** Reject inherited `PI_ACP_PI_COMMAND` values
instead of replacing them.
- Pi startup now fails with an actionable message telling the user to
unset the variable.
- Removed the Pi-specific exception from the generic ACP environment
injection path.

Generated with Codex

---------

Signed-off-by: Salman Mohammed <smohammed@squareup.com>
This commit is contained in:
Salman Mohammed
2026-09-04 17:17:00 -04:00
committed by GitHub
parent 4afef8649a
commit 4beffef697
7 changed files with 557 additions and 51 deletions

View File

@@ -1,4 +1,5 @@
You are operating inside the Buzz platform — a Nostr-based messaging platform for human-agent collaboration. The buzz-acp harness routes channel events to your session.
You are an agent operating inside Buzz — a Nostr-based messaging platform for human-agent collaboration.
Buzz is a desktop and mobile collaboration app organized around channels, conversations, and shared work.
## Buzz CLI

View File

@@ -5,6 +5,7 @@ mod config;
mod engram_fetch;
mod filter;
mod observer;
mod pi_launcher;
mod pool;
mod pool_lifecycle;
mod prompt_framing;
@@ -2510,6 +2511,49 @@ async fn tokio_main() -> Result<()> {
tracing::info!("buzz-acp starting: {}", config.summary());
let cwd = current_working_directory()?;
let base_prompt_content = config.base_prompt_content.take();
let base_prompt = if config.no_base_prompt {
None
} else {
// Build standing context once under the configured policy, before any
// agent process starts. Pi consumes this through its native
// `--system-prompt`; other ACP agents consume the same bytes through
// session/new or legacy first-turn framing.
Some(
config.session_policy.append_session_model(
base_prompt_content
.as_deref()
.unwrap_or(include_str!("base_prompt.md")),
),
)
};
// PI_ACP_PI_COMMAND is Buzz-owned. Strip stale/user-provided copies from
// every adapter before optionally installing Buzz's generated Pi launcher.
config
.persona_env_vars
.retain(|(key, _)| !key.eq_ignore_ascii_case(pi_launcher::PI_ACP_PI_COMMAND_ENV));
let managed_skills_dir = std::path::Path::new(&cwd).join(".agents/skills");
let inherited_pi_command_is_set =
std::env::var_os(pi_launcher::PI_ACP_PI_COMMAND_ENV).is_some();
let (pi_launch_override, base_prompt) = pi_launcher::PiLaunchOverride::prepare(
&config.agent_command,
base_prompt,
&managed_skills_dir,
inherited_pi_command_is_set,
)
.context("failed to prepare Pi launch overrides")?;
if let Some(prepared) = pi_launch_override.as_ref() {
config.persona_env_vars.push((
pi_launcher::PI_ACP_PI_COMMAND_ENV.to_string(),
prepared.launcher_path().to_string_lossy().into_owned(),
));
tracing::info!(
skills_dir = %managed_skills_dir.display(),
"configured Pi to consume Buzz standing context and managed skills through native CLI flags"
);
}
let observer = config
.relay_observer
.then(observer::ObserverHandle::in_process);
@@ -2758,8 +2802,6 @@ async fn tokio_main() -> Result<()> {
);
}
let base_prompt_content = config.base_prompt_content.take();
let cwd = current_working_directory()?;
let ctx = Arc::new(PromptContext {
mcp_servers: build_mcp_servers(&config),
initial_message: config.initial_message.clone(),
@@ -2770,20 +2812,7 @@ async fn tokio_main() -> Result<()> {
system_prompt: config.system_prompt.clone(),
session_title: config.session_title.clone(),
team_instructions: config.team_instructions.clone(),
base_prompt: if config.no_base_prompt {
None
} else {
// Build standing context once under the configured policy, before
// any session/new. Both modern ACP and legacy first-turn framing
// consume this same assembled base (including custom base files).
Some(
config.session_policy.append_session_model(
base_prompt_content
.as_deref()
.unwrap_or(include_str!("base_prompt.md")),
),
)
},
base_prompt,
heartbeat_prompt: config.heartbeat_prompt.clone(),
cwd,
rest_client: relay.rest_client(),
@@ -4116,6 +4145,10 @@ async fn tokio_main() -> Result<()> {
// for the background task to finish, rather than aborting immediately (#40).
relay.shutdown().await;
// Pi may restore subprocesses throughout the pool lifetime. Remove its
// private prompt and launcher only after every adapter has shut down.
drop(pi_launch_override);
tracing::info!("buzz-acp stopped");
Ok(())
}
@@ -5208,6 +5241,9 @@ mod agent_draft_prompt_tests {
#[test]
fn shared_base_prompt_teaches_portable_agent_drafts() {
let prompt = include_str!("base_prompt.md");
assert!(prompt.starts_with(
"You are an agent operating inside Buzz — a Nostr-based messaging platform for human-agent collaboration.\nBuzz is a desktop and mobile collaboration app organized around channels, conversations, and shared work."
));
assert!(prompt.contains("buzz agents draft-create"));
assert!(prompt.contains("ask for at most two things"));
assert!(prompt.contains("what it should do day-to-day"));

View File

@@ -0,0 +1,380 @@
//! Pi-specific native launcher setup.
//!
//! `pi-acp` does not currently consume ACP `session/new.systemPrompt`, but it
//! does let callers replace the `pi` executable through
//! `PI_ACP_PI_COMMAND`. For Pi sessions, Buzz points that variable at a
//! private launcher which adds `--system-prompt <file>` and the canonical Buzz
//! `--skill <directory>` before forwarding the adapter's RPC/session arguments
//! unchanged.
use std::fs::{self, OpenOptions};
use std::io::{self, Write};
use std::path::{Path, PathBuf};
#[cfg(unix)]
use std::ffi::OsStr;
use uuid::Uuid;
pub(crate) const PI_ACP_PI_COMMAND_ENV: &str = "PI_ACP_PI_COMMAND";
/// Files backing the Pi launcher for one `buzz-acp` process.
///
/// The guard must live as long as the ACP pool because `pi-acp` may start or
/// restore Pi subprocesses after its own initialization.
pub(crate) struct PiLaunchOverride {
directory: PathBuf,
launcher: PathBuf,
}
impl PiLaunchOverride {
/// Prepare a Pi launcher when the configured ACP adapter is `pi-acp`.
///
/// Returns the prompt that still needs ordinary ACP delivery. For Pi, the
/// base prompt moves into Pi's native system role and is therefore removed
/// from first-turn user framing. Other adapters receive it unchanged.
pub(crate) fn prepare(
agent_command: &str,
base_prompt: Option<String>,
managed_skills_dir: &Path,
inherited_pi_command_is_set: bool,
) -> io::Result<(Option<Self>, Option<String>)> {
if crate::config::normalize_agent_command_identity(agent_command) != "pi-acp" {
return Ok((None, base_prompt));
}
if inherited_pi_command_is_set {
return Err(io::Error::new(
io::ErrorKind::AlreadyExists,
"PI_ACP_PI_COMMAND is managed by Buzz; unset it before starting a managed Pi agent",
));
}
// Buzz owns PI_ACP_PI_COMMAND and always uses it to point pi-acp at
// this generated launcher. The launcher resolves the ordinary `pi`
// command from Buzz's effective PATH.
let prepared = Self::create("pi", base_prompt.as_deref(), managed_skills_dir)?;
Ok((Some(prepared), None))
}
pub(crate) fn launcher_path(&self) -> &Path {
&self.launcher
}
fn create(
pi_command: &str,
prompt: Option<&str>,
managed_skills_dir: &Path,
) -> io::Result<Self> {
let directory = std::env::temp_dir().join(format!(
"buzz-acp-pi-launcher-{}-{}",
std::process::id(),
Uuid::new_v4()
));
create_private_directory(&directory)?;
let prompt_path = directory.join("SYSTEM.md");
let launcher = directory.join(launcher_file_name());
// Construct the cleanup guard before either file write. Any later `?`
// drops it, so a partial setup cannot strand the private prompt file.
let prepared = Self {
directory,
launcher,
};
if let Some(prompt) = prompt {
write_private_file(&prompt_path, prompt.as_bytes(), false)?;
}
let script = launcher_script(
pi_command,
prompt.map(|_| prompt_path.as_path()),
managed_skills_dir,
)?;
write_private_file(&prepared.launcher, script.as_bytes(), true)?;
Ok(prepared)
}
}
impl Drop for PiLaunchOverride {
fn drop(&mut self) {
if let Err(error) = fs::remove_dir_all(&self.directory) {
if error.kind() != io::ErrorKind::NotFound {
tracing::warn!(
path = %self.directory.display(),
%error,
"failed to remove temporary Pi launcher"
);
}
}
}
}
#[cfg(unix)]
fn create_private_directory(path: &Path) -> io::Result<()> {
use std::os::unix::fs::DirBuilderExt;
let mut builder = fs::DirBuilder::new();
builder.mode(0o700).create(path)
}
#[cfg(not(unix))]
fn create_private_directory(path: &Path) -> io::Result<()> {
fs::create_dir(path)
}
fn write_private_file(path: &Path, content: &[u8], executable: bool) -> io::Result<()> {
let mut options = OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(if executable { 0o700 } else { 0o600 });
}
#[cfg(not(unix))]
let _ = executable;
let mut file = options.open(path)?;
file.write_all(content)?;
file.sync_all()
}
#[cfg(unix)]
fn launcher_file_name() -> &'static str {
"pi-with-buzz-context"
}
#[cfg(windows)]
fn launcher_file_name() -> &'static str {
"pi-with-buzz-context.cmd"
}
#[cfg(not(any(unix, windows)))]
fn launcher_file_name() -> &'static str {
"pi-with-buzz-context"
}
#[cfg(unix)]
fn launcher_script(
pi_command: &str,
prompt_path: Option<&Path>,
managed_skills_dir: &Path,
) -> io::Result<String> {
let system_prompt_arg = match prompt_path {
Some(prompt_path) => format!(" --system-prompt {}", shell_quote(prompt_path.as_os_str())?),
None => String::new(),
};
Ok(format!(
"#!/bin/sh\nexec {}{} --skill {} \"$@\"\n",
shell_quote(OsStr::new(pi_command))?,
system_prompt_arg,
shell_quote(managed_skills_dir.as_os_str())?,
))
}
#[cfg(unix)]
fn shell_quote(value: &OsStr) -> io::Result<String> {
let value = value.to_str().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidData,
"Pi launcher paths must be valid UTF-8",
)
})?;
Ok(format!("'{}'", value.replace('\'', "'\"'\"'")))
}
#[cfg(windows)]
fn launcher_script(
pi_command: &str,
prompt_path: Option<&Path>,
managed_skills_dir: &Path,
) -> io::Result<String> {
let system_prompt_arg = match prompt_path {
Some(prompt_path) => {
let prompt_path = prompt_path.to_str().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidData,
"Pi launcher paths must be valid UTF-8",
)
})?;
format!(" --system-prompt \"{}\"", batch_escape(prompt_path))
}
None => String::new(),
};
let managed_skills_dir = managed_skills_dir.to_str().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidData,
"Pi skill paths must be valid UTF-8",
)
})?;
Ok(format!(
"@echo off\r\n\"{}\"{} --skill \"{}\" %*\r\nexit /b %ERRORLEVEL%\r\n",
batch_escape(pi_command),
system_prompt_arg,
batch_escape(managed_skills_dir),
))
}
#[cfg(windows)]
fn batch_escape(value: &str) -> String {
value.replace('%', "%%").replace('"', "\"\"")
}
#[cfg(not(any(unix, windows)))]
fn launcher_script(
_pi_command: &str,
_prompt_path: Option<&Path>,
_managed_skills_dir: &Path,
) -> io::Result<String> {
Err(io::Error::new(
io::ErrorKind::Unsupported,
"Pi launch overrides are unsupported on this platform",
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn non_pi_adapter_keeps_base_prompt_for_acp_delivery() {
let base = Some("Buzz base".to_string());
let (prepared, remaining) =
PiLaunchOverride::prepare("goose", base.clone(), Path::new("/unused/skills"), true)
.expect("prepare");
assert!(prepared.is_none());
assert_eq!(remaining, base);
}
#[test]
fn pi_adapter_rejects_inherited_pi_command() {
let error = PiLaunchOverride::prepare(
"pi-acp",
Some("Buzz base".to_string()),
Path::new("/unused/skills"),
true,
)
.err()
.expect("inherited PI_ACP_PI_COMMAND must be rejected");
assert_eq!(error.kind(), io::ErrorKind::AlreadyExists);
assert!(error.to_string().contains("managed by Buzz"));
}
#[test]
fn disabled_base_prompt_still_creates_pi_skills_launcher() {
let (prepared, remaining) =
PiLaunchOverride::prepare("pi-acp", None, Path::new("/unused/skills"), false)
.expect("prepare");
let prepared = prepared.expect("Pi skills launcher");
assert!(remaining.is_none());
assert!(!prepared.directory.join("SYSTEM.md").exists());
#[cfg(unix)]
assert!(fs::read_to_string(prepared.launcher_path())
.expect("read launcher")
.contains("--skill '/unused/skills'"));
}
#[test]
fn pi_adapter_moves_buzz_base_out_of_ordinary_acp_delivery() {
let base = crate::scope::SessionPolicy::Thread
.append_session_model(include_str!("base_prompt.md"));
let (prepared, remaining) = PiLaunchOverride::prepare(
"/opt/bin/pi-acp",
Some(base.clone()),
Path::new("/buzz/.agents/skills"),
false,
)
.expect("prepare");
let prepared = prepared.expect("Pi launcher");
assert!(remaining.is_none());
assert_eq!(
fs::read_to_string(prepared.directory.join("SYSTEM.md")).expect("read prompt"),
base
);
assert!(base.contains("each thread gets its own"));
#[cfg(unix)]
assert!(fs::read_to_string(prepared.launcher_path())
.expect("read launcher")
.contains("exec 'pi'"));
}
#[cfg(unix)]
#[test]
fn pi_launcher_replaces_system_prompt_and_forwards_adapter_args() {
use std::os::unix::fs::PermissionsExt;
use std::process::Command;
let fixture_dir =
std::env::temp_dir().join(format!("buzz-acp-pi-system-prompt-test-{}", Uuid::new_v4()));
create_private_directory(&fixture_dir).expect("create fixture dir");
let capture_path = fixture_dir.join("args.txt");
let fake_pi = fixture_dir.join("fake-pi");
let managed_skills_dir = fixture_dir.join("managed skills");
let fake_script = format!(
"#!/bin/sh\nprintf '%s\\n' \"$@\" > {}\n",
shell_quote(capture_path.as_os_str()).expect("quote capture path")
);
write_private_file(&fake_pi, fake_script.as_bytes(), true).expect("write fake pi");
let prepared = PiLaunchOverride::create(
fake_pi.to_str().expect("UTF-8 fake Pi path"),
Some("Buzz base\n\n## Session Model\nThread scoped"),
&managed_skills_dir,
)
.expect("prepare Pi launcher");
let prompt_path = prepared.directory.join("SYSTEM.md");
let status = Command::new(prepared.launcher_path())
.args(["--mode", "rpc", "--session", "/tmp/session.jsonl"])
.status()
.expect("run launcher");
assert!(status.success());
assert_eq!(
fs::read_to_string(&capture_path).expect("read captured args"),
format!(
"--system-prompt\n{}\n--skill\n{}\n--mode\nrpc\n--session\n/tmp/session.jsonl\n",
prompt_path.display(),
managed_skills_dir.display(),
)
);
assert_eq!(
fs::read_to_string(&prompt_path).expect("read system prompt"),
"Buzz base\n\n## Session Model\nThread scoped"
);
assert_eq!(
fs::metadata(&prompt_path)
.expect("prompt metadata")
.permissions()
.mode()
& 0o777,
0o600
);
assert_eq!(
fs::metadata(prepared.launcher_path())
.expect("launcher metadata")
.permissions()
.mode()
& 0o777,
0o700
);
assert_eq!(
fs::metadata(&prepared.directory)
.expect("directory metadata")
.permissions()
.mode()
& 0o777,
0o700
);
drop(prepared);
assert!(!prompt_path.exists());
fs::remove_dir_all(fixture_dir).expect("remove fixture dir");
}
}

View File

@@ -1130,7 +1130,6 @@ pub fn discover_acp_runtimes_from(
// Track all ids seen so far (builtins) to prevent preset/custom collisions.
let mut seen_ids: std::collections::HashSet<String> =
entries.iter().map(|e| e.id.clone()).collect();
// Phase 2.5: insert static preset entries (PATH-probed, not editable/deletable).
for def in PRESET_HARNESSES {
if seen_ids.contains(def.id) {

View File

@@ -16,11 +16,10 @@ pub(super) struct PresetHarness {
install_instructions_url: &'static str,
install_hint: &'static str,
/// Vendor CLI the ACP command wraps, when the preset is an adapter.
///
/// Consulted only when the adapter is absent, so `AdapterMissing` replaces
/// `NotInstalled` when the CLI is present but the adapter is not. `None`
/// when the command is itself the vendor CLI.
underlying_cli: Option<&'static str>,
/// State-specific setup guidance for the wrapped vendor CLI.
underlying_cli_install_hint: Option<&'static str>,
underlying_cli_install_instructions_url: Option<&'static str>,
}
/// Build one preset catalog entry through an injectable command resolver.
@@ -28,28 +27,44 @@ pub(super) fn preset_catalog_entry(
def: &PresetHarness,
resolve: impl Fn(&str) -> Option<PathBuf>,
) -> AcpRuntimeCatalogEntry {
let (availability, command, binary_path) = match resolve(def.command) {
Some(path) => (
AcpAvailabilityStatus::Available,
Some(def.command.to_string()),
Some(path.display().to_string()),
),
None => {
let underlying_cli_found = def
.underlying_cli
.map(|cli| resolve(cli).is_some())
.unwrap_or(false);
if underlying_cli_found {
(AcpAvailabilityStatus::AdapterMissing, None, None)
} else {
(AcpAvailabilityStatus::NotInstalled, None, None)
}
}
};
let underlying_cli_path = def
.underlying_cli
.and_then(resolve)
.and_then(&resolve)
.map(|path| path.display().to_string());
let (availability, command, binary_path) = super::classify_runtime(
resolve(def.command).map(|path| (def.command, path)),
def.underlying_cli,
underlying_cli_path.is_some(),
);
let cli_install_hint = def.underlying_cli.map(|cli| {
def.underlying_cli_install_hint
.map(str::to_string)
.unwrap_or_else(|| {
format!(
"Install the {} CLI and make sure {} is on your PATH.",
def.label, cli
)
})
});
let install_hint = match availability {
AcpAvailabilityStatus::Available if def.underlying_cli.is_some() => String::new(),
AcpAvailabilityStatus::CliMissing => cli_install_hint.unwrap_or_default(),
AcpAvailabilityStatus::NotInstalled if def.underlying_cli.is_some() => {
format!(
"{} {}",
cli_install_hint.unwrap_or_default(),
def.install_hint
)
}
_ => def.install_hint.to_string(),
};
let install_instructions_url = match availability {
AcpAvailabilityStatus::CliMissing | AcpAvailabilityStatus::NotInstalled => def
.underlying_cli_install_instructions_url
.unwrap_or(def.install_instructions_url),
_ => def.install_instructions_url,
};
AcpRuntimeCatalogEntry {
id: def.id.to_string(),
@@ -71,12 +86,10 @@ pub(super) fn preset_catalog_entry(
max_tokens_env_var: None,
context_limit_env_var: None,
max_rounds_env_var: None,
install_hint: def.install_hint.to_string(),
install_instructions_url: def.install_instructions_url.to_string(),
install_hint,
install_instructions_url: install_instructions_url.to_string(),
can_auto_install: false,
// Presets carry one flat install hint, so builtin external-CLI copy
// would name the wrong missing component for adapter presets.
requires_external_cli: false,
requires_external_cli: def.underlying_cli.is_some(),
underlying_cli_path,
node_required: false,
auth_status: AuthStatus::NotApplicable,
@@ -97,8 +110,14 @@ pub(super) const PRESET_HARNESSES: &[PresetHarness] = &[
command: "pi-acp",
args: &[],
install_instructions_url: "https://github.com/svkozak/pi-acp",
install_hint: "Buzz talks to Pi through the pi-acp adapter. Install Pi with `npm install -g --ignore-scripts @earendil-works/pi-coding-agent`, then install the adapter with `npm install -g pi-acp`.",
install_hint: "Install the Pi ACP adapter with npm install -g pi-acp.",
underlying_cli: Some("pi"),
underlying_cli_install_hint: Some(
"Install Pi with npm install -g --ignore-scripts @earendil-works/pi-coding-agent.",
),
underlying_cli_install_instructions_url: Some(
"https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent",
),
},
PresetHarness {
id: "devin",
@@ -108,6 +127,8 @@ pub(super) const PRESET_HARNESSES: &[PresetHarness] = &[
install_instructions_url: "https://docs.devin.ai/cli",
install_hint: "Buzz talks to Devin through the official Devin CLI's ACP mode (devin acp).",
underlying_cli: None,
underlying_cli_install_hint: None,
underlying_cli_install_instructions_url: None,
},
PresetHarness {
id: "cursor",
@@ -117,6 +138,8 @@ pub(super) const PRESET_HARNESSES: &[PresetHarness] = &[
install_instructions_url: "https://cursor.com/downloads",
install_hint: "Buzz talks to Cursor through the cursor-agent CLI's ACP mode.",
underlying_cli: None,
underlying_cli_install_hint: None,
underlying_cli_install_instructions_url: None,
},
PresetHarness {
id: "omp",
@@ -126,6 +149,8 @@ pub(super) const PRESET_HARNESSES: &[PresetHarness] = &[
install_instructions_url: "https://omp.sh/",
install_hint: "Buzz talks to Oh My Pi through its CLI's ACP mode (omp acp).",
underlying_cli: None,
underlying_cli_install_hint: None,
underlying_cli_install_instructions_url: None,
},
PresetHarness {
id: "grok",
@@ -135,6 +160,8 @@ pub(super) const PRESET_HARNESSES: &[PresetHarness] = &[
install_instructions_url: "https://build.x.ai/docs",
install_hint: "Buzz talks to Grok Build through its CLI's agent stdio mode.",
underlying_cli: None,
underlying_cli_install_hint: None,
underlying_cli_install_instructions_url: None,
},
PresetHarness {
id: "opencode",
@@ -144,6 +171,8 @@ pub(super) const PRESET_HARNESSES: &[PresetHarness] = &[
install_instructions_url: "https://opencode.ai/docs",
install_hint: "Buzz talks to OpenCode through its CLI's ACP mode (opencode acp).",
underlying_cli: None,
underlying_cli_install_hint: None,
underlying_cli_install_instructions_url: None,
},
PresetHarness {
id: "kimi",
@@ -153,6 +182,8 @@ pub(super) const PRESET_HARNESSES: &[PresetHarness] = &[
install_instructions_url: "https://kimi.ai/download",
install_hint: "Buzz talks to Kimi Code through its CLI's ACP mode (kimi acp).",
underlying_cli: None,
underlying_cli_install_hint: None,
underlying_cli_install_instructions_url: None,
},
PresetHarness {
id: "amp",
@@ -162,6 +193,8 @@ pub(super) const PRESET_HARNESSES: &[PresetHarness] = &[
install_instructions_url: "https://github.com/tao12345666333/amp-acp",
install_hint: "Buzz talks to the Amp CLI through the amp-acp adapter. Follow the setup guide to install the adapter so the amp-acp command is on your PATH.",
underlying_cli: Some("amp"),
underlying_cli_install_hint: None,
underlying_cli_install_instructions_url: None,
},
PresetHarness {
id: "hermes",
@@ -171,6 +204,8 @@ pub(super) const PRESET_HARNESSES: &[PresetHarness] = &[
install_instructions_url: "https://hermes-agent.nousresearch.com",
install_hint: "Buzz talks to Hermes Agent through its hermes-acp command.",
underlying_cli: None,
underlying_cli_install_hint: None,
underlying_cli_install_instructions_url: None,
},
PresetHarness {
id: "openclaw",
@@ -187,6 +222,8 @@ pub(super) const PRESET_HARNESSES: &[PresetHarness] = &[
needs BUZZ_* credentials at execution time, set them on the \
Gateway's own environment separately.",
underlying_cli: None,
underlying_cli_install_hint: None,
underlying_cli_install_instructions_url: None,
},
];
@@ -303,6 +340,8 @@ mod tests {
install_instructions_url: "https://example.com/install",
install_hint: "Install the amp-acp npm adapter.",
underlying_cli: Some("amp"),
underlying_cli_install_hint: Some("Install the Amp Test CLI."),
underlying_cli_install_instructions_url: Some("https://example.com/amp"),
};
#[test]
@@ -377,6 +416,8 @@ mod tests {
assert_eq!(available.availability, AcpAvailabilityStatus::Available);
assert_eq!(available.command.as_deref(), Some("pi-acp"));
assert!(available.default_args.is_empty());
assert!(available.install_hint.is_empty());
assert!(available.requires_external_cli);
assert_eq!(
available.underlying_cli_path.as_deref(),
Some("/usr/local/bin/pi")
@@ -391,12 +432,38 @@ mod tests {
);
assert!(adapter_missing.command.is_none());
assert!(adapter_missing.default_args.is_empty());
assert_eq!(
adapter_missing.install_hint,
"Install the Pi ACP adapter with npm install -g pi-acp."
);
assert_eq!(
adapter_missing.install_instructions_url,
"https://github.com/svkozak/pi-acp"
);
let cli_missing = preset_catalog_entry(preset, |command| {
(command == "pi-acp").then(|| PathBuf::from("/usr/local/bin/pi-acp"))
});
assert_eq!(cli_missing.availability, AcpAvailabilityStatus::CliMissing);
assert_eq!(cli_missing.command.as_deref(), Some("pi-acp"));
assert_eq!(
cli_missing.install_hint,
"Install Pi with npm install -g --ignore-scripts @earendil-works/pi-coding-agent."
);
assert_eq!(
cli_missing.install_instructions_url,
"https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent"
);
let not_installed = preset_catalog_entry(preset, |_| None);
assert_eq!(
not_installed.availability,
AcpAvailabilityStatus::NotInstalled
);
assert_eq!(
not_installed.install_hint,
"Install Pi with npm install -g --ignore-scripts @earendil-works/pi-coding-agent. Install the Pi ACP adapter with npm install -g pi-acp."
);
}
#[test]
@@ -411,8 +478,12 @@ mod tests {
entry.underlying_cli_path.as_deref(),
Some("/usr/local/bin/amp")
);
assert!(!entry.requires_external_cli);
assert!(entry.requires_external_cli);
assert_eq!(entry.install_hint, "Install the amp-acp npm adapter.");
assert_eq!(
entry.install_instructions_url,
"https://example.com/install"
);
}
#[test]
@@ -420,7 +491,12 @@ mod tests {
let entry = preset_catalog_entry(&ADAPTER_PRESET, |_| None);
assert_eq!(entry.availability, AcpAvailabilityStatus::NotInstalled);
assert!(entry.underlying_cli_path.is_none());
assert!(!entry.requires_external_cli);
assert!(entry.requires_external_cli);
assert_eq!(
entry.install_hint,
"Install the Amp Test CLI. Install the amp-acp npm adapter."
);
assert_eq!(entry.install_instructions_url, "https://example.com/amp");
}
#[test]
@@ -437,17 +513,20 @@ mod tests {
entry.underlying_cli_path.as_deref(),
Some("/usr/local/bin/amp")
);
assert!(entry.install_hint.is_empty());
}
#[test]
fn adapter_presence_is_enough_for_availability() {
fn adapter_without_underlying_cli_reports_cli_missing() {
let entry = preset_catalog_entry(&ADAPTER_PRESET, |command| {
(command == "amp-acp").then(|| PathBuf::from("/usr/local/bin/amp-acp"))
});
assert_eq!(entry.availability, AcpAvailabilityStatus::Available);
assert_eq!(entry.availability, AcpAvailabilityStatus::CliMissing);
assert_eq!(entry.command.as_deref(), Some("amp-acp"));
assert_eq!(entry.binary_path.as_deref(), Some("/usr/local/bin/amp-acp"));
assert!(entry.underlying_cli_path.is_none());
assert_eq!(entry.install_hint, "Install the Amp Test CLI.");
assert_eq!(entry.install_instructions_url, "https://example.com/amp");
}
#[test]

View File

@@ -145,6 +145,14 @@ fn reserved_keys_include_agent_owner_for_legacy_records() {
assert!(merged.is_empty());
}
#[test]
fn reserved_keys_include_pi_acp_command() {
assert!(is_reserved_env_key("PI_ACP_PI_COMMAND"));
let agent = map(&[("PI_ACP_PI_COMMAND", "/tmp/custom-pi")]);
let merged = merged_user_env(&BTreeMap::new(), &agent);
assert!(merged.is_empty());
}
#[test]
fn reserved_keys_include_respond_to_gate() {
// Respond-to mode + allowlist control who the agent answers.

View File

@@ -41,6 +41,9 @@ pub(crate) const RESERVED_ENV_KEYS: &[&str] = &[
"BUZZ_ACP_AGENT_COMMAND",
"BUZZ_ACP_AGENT_ARGS",
"BUZZ_ACP_MCP_COMMAND",
// pi-acp's executable override is reserved for Buzz's generated launcher,
// which injects the managed system prompt and skills.
"PI_ACP_PI_COMMAND",
// Control-plane parallelism: the Desktop resolves the effective
// worker-pool size (applying any per-harness cap) and writes it into
// launch.policy_env. A user-supplied BUZZ_ACP_AGENTS would bypass the