Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
cc0b0cc
feat(projects): introduce multi-repository project model
thomaspblock Jul 24, 2026
efd2b36
feat(projects): enable multi-repository project navigation
thomaspblock Jul 24, 2026
eef840f
feat(projects): create projects with initial repositories
thomaspblock Jul 24, 2026
fc30af2
Merge origin/main into feat/multi-repository-projects
thomaspblock Jul 24, 2026
dfec053
feat(projects): add multi-repository management
thomaspblock Jul 25, 2026
4ca6657
Merge remote-tracking branch 'origin/main' into feat/multi-repository…
thomaspblock Jul 25, 2026
07db523
Merge remote-tracking branch 'origin/main' into feat/multi-repository…
thomaspblock Jul 27, 2026
30b5b71
fix(desktop): preserve Inbox actions for multi-repository projects
thomaspblock Jul 27, 2026
1ff331d
Merge origin/main into feat/multi-repository-projects
thomaspblock Jul 30, 2026
0a6864a
feat(desktop): show PR author identity rollover
thomaspblock Jul 31, 2026
2a63e4f
feat(desktop): clarify repository hosting and availability
thomaspblock Jul 29, 2026
1be45fb
fix(desktop): preserve multi-repository project context
thomaspblock Jul 31, 2026
39d9e3b
Merge remote-tracking branch 'origin/main' into feat/multi-repository…
thomaspblock Jul 31, 2026
3c786a6
fix(desktop): remove stale Vite type suppression
thomaspblock Jul 31, 2026
31394d0
fix(mobile): align iOS scheme app name
thomaspblock Jul 31, 2026
d52f247
feat(desktop): match issue author identity rollover
thomaspblock Jul 31, 2026
beb5097
Merge remote-tracking branch 'origin/main' into feat/multi-repository…
thomaspblock Jul 31, 2026
51c05a8
Merge origin/main into feat/multi-repository-projects
thomaspblock Aug 2, 2026
b3705ed
feat(desktop): add issue comment timeline
thomaspblock Aug 2, 2026
53b4fec
Merge origin/main into feat/multi-repository-projects
thomaspblock Aug 2, 2026
0099eaa
fix(projects): align desktop grouping with NIP-MP
thomaspblock Aug 3, 2026
026bf58
Merge remote-tracking branch 'origin/main' into feat/multi-repository…
thomaspblock Aug 3, 2026
d6ebe4e
feat(projects): attach existing repositories
thomaspblock Aug 3, 2026
63bdecd
fix(projects): preserve repository access and agent origins
thomaspblock Aug 4, 2026
774c253
refactor(desktop): keep agent display env within size guard
thomaspblock Aug 4, 2026
38c0406
Merge remote-tracking branch 'origin/main' into feat/multi-repository…
thomaspblock Aug 4, 2026
fe0011c
test(desktop): align deploy fixture with agent display name
thomaspblock Aug 4, 2026
60a7eff
fix(projects): align smoke coverage with current UI
thomaspblock Aug 4, 2026
d703803
Merge remote-tracking branch 'origin/main' into feat/multi-repository…
thomaspblock Aug 4, 2026
89d0207
fix(projects): address code review findings for multi-repo support
Aug 4, 2026
6e6f86d
fix(projects): address Thufir pass-2 review findings for multi-repo s…
Aug 4, 2026
d3705b9
fix(projects): close four pre-Thufir-pass-3 gaps in multi-repo review…
Aug 4, 2026
99894d1
fix(projects): fix 30617 entity-link smoke test navigation
Aug 4, 2026
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
120 changes: 105 additions & 15 deletions crates/buzz-acp/src/pool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ use uuid::Uuid;

use crate::acp::{
extract_model_config_options, extract_model_state, model_in_catalog,
resolve_model_switch_method, AcpClient, AcpError, McpServer, ModelSwitchMethod, StopReason,
SystemPromptTransport,
resolve_model_switch_method, AcpClient, AcpError, EnvVar, McpServer, ModelSwitchMethod,
StopReason, SystemPromptTransport,
};
use crate::config::{compose_session_title, DedupMode, PermissionMode};
use crate::observer;
Expand Down Expand Up @@ -867,13 +867,13 @@ const UNKNOWN_CHANNEL_NAME: &str = "unknown";
async fn resolve_new_session_channel_context(
channel_info: &ChannelInfoResolver,
channel_id: Uuid,
) -> (bool, Option<String>) {
) -> (bool, Option<String>, Option<String>) {
let Some(info) = channel_info.resolve(channel_id).await else {
return (true, None);
return (true, None, None);
};
let is_dm = info.channel_type == "dm";
let title_channel = (!is_dm && info.name != UNKNOWN_CHANNEL_NAME).then_some(info.name);
(is_dm, title_channel)
(is_dm, title_channel, Some(info.channel_type))
}

/// Create a new ACP session via `session_new_full()`, populate model capabilities
Expand All @@ -888,6 +888,8 @@ async fn create_session_and_apply_model(
agent_core: Option<&str>,
agent_canvas: Option<&str>,
channel_name: Option<&str>,
channel_id: Option<Uuid>,
channel_type: Option<&str>,
) -> Result<String, AcpError> {
// Build base_prompt + system_prompt + agent core + canvas metadata into a
// single prompt. Standard protocol-v2 agents receive it in `session/new`;
Expand All @@ -911,12 +913,18 @@ async fn create_session_and_apply_model(
.session_title
.as_deref()
.map(|agent_name| compose_session_title(agent_name, channel_name));
let mcp_servers = mcp_servers_with_git_origin(
&ctx.mcp_servers,
channel_id,
channel_type,
ctx.session_title.as_deref(),
);

let resp = agent
.acp
.session_new_full(
&ctx.cwd,
ctx.mcp_servers.clone(),
mcp_servers,
session_new_system_prompt(
is_goose,
agent.protocol_version,
Expand Down Expand Up @@ -1019,6 +1027,34 @@ async fn create_session_and_apply_model(
Ok(resp.session_id)
}

fn mcp_servers_with_git_origin(
servers: &[McpServer],
channel_id: Option<Uuid>,
channel_type: Option<&str>,
agent_name: Option<&str>,
) -> Vec<McpServer> {
let mut servers = servers.to_vec();
let origin = match (channel_id, channel_type) {
(Some(channel_id), Some("stream")) => Some(EnvVar {
name: "BUZZ_GIT_ORIGIN_CHANNEL_ID".into(),
value: channel_id.to_string(),
}),
(Some(_), _) => agent_name
.filter(|name| !name.trim().is_empty())
.map(|name| EnvVar {
name: "BUZZ_GIT_ORIGIN_AGENT_NAME".into(),
value: name.trim().to_string(),
}),
(None, _) => None,
};
if let Some(origin) = origin {
for server in &mut servers {
server.env.push(origin.clone());
}
}
servers
}

/// Send the appropriate ACP model-switch request with a timeout.
///
/// On timeout or error, logs a warning and returns — the caller proceeds
Expand Down Expand Up @@ -1519,14 +1555,15 @@ pub async fn run_prompt_task(
// Channel name for the session title, from the same single resolve the
// canvas DM check uses — see `resolve_new_session_channel_context`.
let mut title_channel: Option<String> = None;
let mut origin_channel_type: Option<String> = None;
if let PromptSource::Channel(cid) = &source {
let is_new_channel_session = !agent.state.sessions.contains_key(cid);
let needs_canvas = is_new_channel_session && !agent.state.canvas_sections.contains_key(cid);
let needs_title = is_new_channel_session && ctx.session_title.is_some();
if needs_canvas || needs_title {
let (is_dm, resolved_channel) =
if is_new_channel_session {
let (is_dm, resolved_channel, resolved_channel_type) =
resolve_new_session_channel_context(&ctx.channel_info, *cid).await;
title_channel = resolved_channel;
origin_channel_type = resolved_channel_type;
// A confirmed DM never receives a canvas section; an undeterminable
// channel type fails closed as a DM for the same reason.
if needs_canvas && !is_dm {
Expand Down Expand Up @@ -1571,6 +1608,8 @@ pub async fn run_prompt_task(
agent_core.as_deref(),
agent_canvas.as_deref(),
title_channel.as_deref(),
Some(*cid),
origin_channel_type.as_deref(),
)
.await
{
Expand Down Expand Up @@ -1618,7 +1657,9 @@ pub async fn run_prompt_task(
if let Some(sid) = &agent.state.heartbeat_session {
(sid.clone(), false)
} else {
match create_session_and_apply_model(&mut agent, &ctx, None, None, None).await {
match create_session_and_apply_model(&mut agent, &ctx, None, None, None, None, None)
.await
{
Ok(sid) => {
tracing::info!(
target: "pool::session",
Expand Down Expand Up @@ -3989,6 +4030,50 @@ mod tests {
use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp};
use serde_json::json;

fn test_mcp_server() -> McpServer {
McpServer {
name: "dev".into(),
command: "buzz-dev-mcp".into(),
args: vec![],
env: vec![],
}
}

#[test]
fn public_session_forwards_channel_origin_to_mcp() {
let channel_id = Uuid::new_v4();
let servers = mcp_servers_with_git_origin(
&[test_mcp_server()],
Some(channel_id),
Some("stream"),
None,
);
assert!(servers[0].env.iter().any(|entry| {
entry.name == "BUZZ_GIT_ORIGIN_CHANNEL_ID" && entry.value == channel_id.to_string()
}));
assert!(!servers[0]
.env
.iter()
.any(|entry| entry.name == "BUZZ_GIT_ORIGIN_AGENT_NAME"));
}

#[test]
fn private_session_forwards_agent_name_without_channel_id() {
let servers = mcp_servers_with_git_origin(
&[test_mcp_server()],
Some(Uuid::new_v4()),
Some("dm"),
Some("Builder"),
);
assert!(servers[0].env.iter().any(|entry| {
entry.name == "BUZZ_GIT_ORIGIN_AGENT_NAME" && entry.value == "Builder"
}));
assert!(!servers[0]
.env
.iter()
.any(|entry| entry.name == "BUZZ_GIT_ORIGIN_CHANNEL_ID"));
}

// These pin the initial_message dispatch path (run_prompt_task, ~line 855):
// a legacy agent WITH a base_prompt must get [Base] prepended to the user
// message. This is the exact regression that shipped in the round-2 bug.
Expand Down Expand Up @@ -6833,12 +6918,14 @@ mod tests {
let response = channel_metadata_response(id, &[["name", "buzz-dev"], ["t", "stream"]]);
let (resolver, requests, server) = counting_resolver(response).await;

let (is_dm, title_channel) = resolve_new_session_channel_context(&resolver, id).await;
let (is_dm, title_channel, channel_type) =
resolve_new_session_channel_context(&resolver, id).await;
assert!(!is_dm, "a stream channel is not a DM");
assert_eq!(title_channel.as_deref(), Some("buzz-dev"));
assert_eq!(channel_type.as_deref(), Some("stream"));
assert_eq!(requests.load(Ordering::SeqCst), 1);

let (_, again) = resolve_new_session_channel_context(&resolver, id).await;
let (_, again, _) = resolve_new_session_channel_context(&resolver, id).await;
assert_eq!(again.as_deref(), Some("buzz-dev"));
assert_eq!(
requests.load(Ordering::SeqCst),
Expand All @@ -6856,8 +6943,10 @@ mod tests {
let response = channel_metadata_response(id, &[["name", "DM"], ["t", "dm"]]);
let (resolver, _requests, server) = counting_resolver(response).await;

let (is_dm, title_channel) = resolve_new_session_channel_context(&resolver, id).await;
let (is_dm, title_channel, channel_type) =
resolve_new_session_channel_context(&resolver, id).await;
assert!(is_dm);
assert_eq!(channel_type.as_deref(), Some("dm"));
assert_eq!(
title_channel, None,
"a DM name must never reach the session title"
Expand All @@ -6874,7 +6963,7 @@ mod tests {
let response = channel_metadata_response(id, &[["t", "stream"]]);
let (resolver, _requests, server) = counting_resolver(response).await;

let (is_dm, title_channel) = resolve_new_session_channel_context(&resolver, id).await;
let (is_dm, title_channel, _) = resolve_new_session_channel_context(&resolver, id).await;
assert!(!is_dm, "a nameless stream channel is still not a DM");
assert_eq!(
title_channel, None,
Expand All @@ -6894,10 +6983,11 @@ mod tests {

let (resolver, requests, server) = counting_resolver(json!([])).await;

let (is_dm, title_channel) =
let (is_dm, title_channel, channel_type) =
resolve_new_session_channel_context(&resolver, Uuid::new_v4()).await;
assert!(is_dm, "an undeterminable channel type must fail closed");
assert_eq!(title_channel, None, "unresolved channels get a bare title");
assert_eq!(channel_type, None);
assert_eq!(
requests.load(Ordering::SeqCst),
2,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"owner_pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"policy_env": {
"BUZZ_ACP_AGENTS": "10",
"BUZZ_ACP_DISPLAY_NAME": "worker",
"BUZZ_ACP_LAZY_POOL": "true",
"BUZZ_ACP_MODEL": "gpt-5",
"BUZZ_ACP_RELAY_OBSERVER": "true",
Expand Down
8 changes: 6 additions & 2 deletions crates/buzz-cli/src/commands/issues.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use crate::client::BuzzClient;
use crate::commands::with_git_provenance;
use crate::error::CliError;
use crate::validate::{read_or_stdin, sdk_err, validate_hex64, validate_repo_id};
use buzz_sdk::{GitIssueMeta, GitRepoCoord, GitStatusMeta};
Expand Down Expand Up @@ -26,7 +27,9 @@ pub async fn cmd_create_issue(
id: repo_id.to_string(),
};

let builder = buzz_sdk::build_git_issue(&repo, subject, &body, &meta).map_err(sdk_err)?;
let builder = with_git_provenance(
buzz_sdk::build_git_issue(&repo, subject, &body, &meta).map_err(sdk_err)?,
)?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
Expand Down Expand Up @@ -137,7 +140,8 @@ pub async fn cmd_issue_status(
applied_as_commits: vec![],
};

let builder = buzz_sdk::build_git_status(status, &body, &meta).map_err(sdk_err)?;
let builder =
with_git_provenance(buzz_sdk::build_git_status(status, &body, &meta).map_err(sdk_err)?)?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
Expand Down
93 changes: 93 additions & 0 deletions crates/buzz-cli/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,55 @@ pub mod users;
pub mod workflows;

use crate::{client::normalize_write_response, error::CliError};
use nostr::{EventBuilder, Tag};

const GIT_ORIGIN_CHANNEL_ENV: &str = "BUZZ_GIT_ORIGIN_CHANNEL_ID";
const GIT_ORIGIN_AGENT_ENV: &str = "BUZZ_GIT_ORIGIN_AGENT_NAME";

/// Add trusted, session-scoped provenance supplied by the ACP harness.
///
/// Public channels use the standard NIP-29 `h` tag. Private conversations
/// intentionally omit their channel coordinate and retain only the agent's
/// display name.
pub(crate) fn with_git_provenance(builder: EventBuilder) -> Result<EventBuilder, CliError> {
apply_git_provenance(
builder,
std::env::var(GIT_ORIGIN_CHANNEL_ENV).ok().as_deref(),
std::env::var(GIT_ORIGIN_AGENT_ENV).ok().as_deref(),
)
}

fn apply_git_provenance(
builder: EventBuilder,
channel_id: Option<&str>,
agent_name: Option<&str>,
) -> Result<EventBuilder, CliError> {
if let Some(channel_id) = channel_id {
let channel_id = channel_id.trim();
uuid::Uuid::parse_str(channel_id)
.map_err(|_| CliError::Other("invalid git origin channel ID".into()))?;
let origin_tag = Tag::parse(["h", channel_id])
.map_err(|error| CliError::Other(format!("invalid git origin tag: {error}")))?;
return Ok(builder.tag(origin_tag));
}

if let Some(agent_name) = agent_name {
let agent_name = agent_name.trim();
if agent_name.is_empty()
|| agent_name.len() > 256
|| agent_name.chars().any(char::is_control)
{
return Err(CliError::Other(
"invalid private-conversation agent name".into(),
));
}
let origin_tag = Tag::parse(["buzz-origin-agent", agent_name])
.map_err(|error| CliError::Other(format!("invalid git origin tag: {error}")))?;
return Ok(builder.tag(origin_tag));
}

Ok(builder)
}

/// Parse a relay write-response JSON blob, mapping a duplicate (dominated)
/// write to [`CliError::Conflict`] with the caller-supplied message.
Expand All @@ -46,3 +95,47 @@ pub fn parse_write_response(raw: &str, conflict_msg: &str) -> Result<String, Cli
}
Ok(normalize_write_response(raw))
}

#[cfg(test)]
mod tests {
use super::*;
use nostr::{Keys, Kind};

fn event_with_origin(channel_id: Option<&str>, agent_name: Option<&str>) -> nostr::Event {
apply_git_provenance(
EventBuilder::new(Kind::Custom(1621), "issue"),
channel_id,
agent_name,
)
.expect("apply provenance")
.sign_with_keys(&Keys::generate())
.expect("sign event")
}

#[test]
fn public_channel_origin_uses_h_tag_and_suppresses_agent_name() {
let channel_id = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50";
let event = event_with_origin(Some(channel_id), Some("Builder"));
assert!(event
.tags
.iter()
.any(|tag| tag.as_slice() == ["h", channel_id]));
assert!(!event
.tags
.iter()
.any(|tag| tag.as_slice().first().map(String::as_str) == Some("buzz-origin-agent")));
}

#[test]
fn private_origin_exposes_only_agent_name() {
let event = event_with_origin(None, Some("Builder"));
assert!(event
.tags
.iter()
.any(|tag| tag.as_slice() == ["buzz-origin-agent", "Builder"]));
assert!(!event
.tags
.iter()
.any(|tag| tag.as_slice().first().map(String::as_str) == Some("h")));
}
}
7 changes: 5 additions & 2 deletions crates/buzz-cli/src/commands/patches.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use crate::client::BuzzClient;
use crate::commands::with_git_provenance;
use crate::error::CliError;
use crate::validate::{
read_file_or_stdin, read_or_stdin, sdk_err, validate_hex64, validate_repo_id,
Expand Down Expand Up @@ -47,7 +48,8 @@ pub async fn cmd_send_patch(
id: repo_id.to_string(),
};

let builder = buzz_sdk::build_git_patch(&repo, &content, &meta).map_err(sdk_err)?;
let builder =
with_git_provenance(buzz_sdk::build_git_patch(&repo, &content, &meta).map_err(sdk_err)?)?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
Expand Down Expand Up @@ -180,7 +182,8 @@ pub async fn cmd_patch_status(
applied_as_commits: applied_as_commit.to_vec(),
};

let builder = buzz_sdk::build_git_status(status, &body, &meta).map_err(sdk_err)?;
let builder =
with_git_provenance(buzz_sdk::build_git_status(status, &body, &meta).map_err(sdk_err)?)?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
Expand Down
Loading
Loading