Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
a6c5f1d
feat(projects): add agent and CLI project-home support
thomaspblock Aug 23, 2026
7a9af2a
fix(projects): authenticate channel home routing
Aug 23, 2026
7bbed3f
fix(projects): validate reused repo binding
Aug 23, 2026
ac2c45e
feat(projects): make project creation channel-first (#6591)
thomaspblock Aug 23, 2026
6c0abfd
feat(channels): support project-home channel surfaces (#6594)
thomaspblock Aug 23, 2026
7d6c4ab
feat(projects): make the home channel the project surface (#6595)
thomaspblock Aug 23, 2026
07d2ce6
feat(projects): unify navigation and reviewed channel requests
thomaspblock Aug 23, 2026
f0192ea
polish(projects): streamline overview navigation and state (#6600)
thomaspblock Aug 24, 2026
d5c6df5
feat(projects): finish multi-repository workspace flows
thomaspblock Aug 23, 2026
593de88
fix(projects): overlay workspace sheets above threads
thomaspblock Aug 24, 2026
7892632
fix(projects): prioritize sheets in every thread layout
thomaspblock Aug 24, 2026
42b1825
fix(projects): remove drawer container focus outline
thomaspblock Aug 24, 2026
6961e4c
fix(projects): show project glyphs in channel rows
thomaspblock Aug 24, 2026
b5c927e
fix(projects): stabilize workspace sheet transitions
thomaspblock Aug 24, 2026
c9e8056
fix(projects): restore overview drawer controls
thomaspblock Aug 24, 2026
19fe55e
fix(channels): satisfy pane size ratchet
Aug 24, 2026
69a30f2
fix(projects): surface partial repository failures
Aug 24, 2026
1f31f09
fix(projects): route commit links to source repo
Aug 24, 2026
67d3720
test(projects): align merged workspace expectations
Aug 24, 2026
95572be
feat(projects): move add action into overview
matt2e Aug 24, 2026
4754ef7
fix(projects): remove duplicate create action
matt2e Aug 24, 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
10 changes: 10 additions & 0 deletions crates/buzz-acp/src/base_prompt.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ The `buzz` CLI is your primary interface. Auth env vars: `BUZZ_RELAY_URL`, `BUZZ
| `buzz feed` | `get` |
| `buzz social` | `publish`, `notes` |
| `buzz repos` | `create`, `get`, `list` |
| `buzz projects` | `create`, `get`, `list`, `add-repo`, `add-channel` |
| `buzz issues` | `create`, `get`, `list`, `status`, `assign` |
| `buzz pr` | `open`, `update`, `get`, `list`, `status` |
| `buzz upload` | `file` |
Expand All @@ -31,6 +32,15 @@ Run `buzz --help` or `buzz <group> --help` for full usage. For multiline message

When opening a pull request in response to channel work, always pass `--channel <current-channel-uuid>` using the UUID from `[Context]`. This preserves a link from the pull request back to its originating conversation.

## Projects

A project is a named grouping (`kind:30621`) with a home channel. Creating a second project with the same name produces a duplicate card in Buzz Desktop — never do that for work that already has a project.

- If you are in a project's home channel, or a project with that name/slug already exists, do **not** run `buzz projects create`. `[Context]` includes a Project block when this channel is a project home — tasks, repositories, and files you create belong to that project.
- To add a codebase: `buzz repos create --id <id> --name "…" --channel <current-channel-uuid>`. `mkdir` in `REPOS/` is not a Buzz repository.
- To add tasks: `buzz issues create --channel <current-channel-uuid> --subject "…" --content "…"`. That uses this project's repository and creates one bound to the channel if none exists. `--repo-owner` / `--repo-id` remain valid once a repository exists. Session todos and markdown plans do not appear on the project.
- To add another channel to this project: `buzz projects add-channel --home-channel <current-channel-uuid> --name "…" [--template "…"]`. This opens an owner-reviewed request in Buzz Desktop and uses the project-aware channel primitive after approval. Do **not** use `buzz channels create` for a channel that should belong to the current project, and do not claim the channel exists until the owner approves it.

`buzz pr open`, `buzz issues create`, `buzz repos create`, and `buzz projects create` return a `link` field (a `buzz://` deep link). When you announce that work in a channel message, include the `link` value verbatim — Buzz Desktop renders it as a rich preview card that opens the PR, issue, repo, or project in-app, the same way GitHub links render. Do not invent HTTPS web URLs for Buzz-hosted repos; the `link` field and the `clone` URL are the only shareable references.

To assign an issue to someone, run `buzz issues assign --issue <event-id> --repo-owner <hex> --repo-id <id> --assignee <hex> --label <name>` after creating it. Remove an assignment with the matching `buzz issues unassign` arguments. Writing assignee names in the issue body or adding recipients with `issues create --to` is notification/presentation only — Buzz Desktop's Assignees rail and the "Assigned to me" filter read the signed assignment operations. Only operations signed by the issue author or repo owner are trusted for other people; anyone may assign or unassign themselves.
Expand Down
13 changes: 11 additions & 2 deletions crates/buzz-acp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ mod filter;
mod observer;
mod pool;
mod pool_lifecycle;
mod prompt_project;
mod queue;
mod relay;
mod setup_mode;
Expand Down Expand Up @@ -4475,6 +4476,14 @@ mod agent_draft_prompt_tests {
assert!(prompt.contains("update the team's shared guidance"));
}

#[test]
fn shared_base_prompt_teaches_not_to_duplicate_projects() {
let prompt = include_str!("base_prompt.md");
assert!(prompt.contains("do **not** run `buzz projects create`"));
assert!(prompt.contains("buzz issues create --channel"));
assert!(prompt.contains("is not a Buzz repository"));
}

#[test]
fn shared_base_prompt_teaches_single_command_mentions_and_preflight() {
let prompt = include_str!("base_prompt.md");
Expand Down Expand Up @@ -5666,8 +5675,8 @@ mod author_gate_tests {
assert!(is_dm_channel(id, &resolver).await);
assert_eq!(
requests.load(Ordering::SeqCst),
1,
"second resolution uses cache"
2,
"channel metadata and project context each resolve once, then cache"
);
server.abort();
}
Expand Down
95 changes: 84 additions & 11 deletions crates/buzz-acp/src/pool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ use crate::acp::{
};
use crate::config::{compose_session_title, DedupMode, PermissionMode};
use crate::observer;
use crate::prompt_project::{pick_authoritative_project_home, PromptProjectInfo};
use crate::queue::{
CancelReason, ContextMessage, ConversationContext, FlushBatch, PromptChannelInfo,
PromptProfile, PromptProfileLookup, ThreadTags,
Expand Down Expand Up @@ -543,6 +544,9 @@ pub enum PromptOutcome {
#[derive(Debug, Clone)]
pub struct ChannelInfoResolver {
cache: std::sync::Arc<std::sync::RwLock<std::collections::HashMap<Uuid, PromptChannelInfo>>>,
projects: std::sync::Arc<
std::sync::RwLock<std::collections::HashMap<Uuid, Option<PromptProjectInfo>>>,
>,
rest_client: RestClient,
}

Expand All @@ -560,31 +564,51 @@ impl ChannelInfoResolver {
name: info.name,
channel_type: info.channel_type,
description: info.description,
project: None,
},
))
})
.collect();
Self {
cache: std::sync::Arc::new(std::sync::RwLock::new(cache)),
projects: std::sync::Arc::new(std::sync::RwLock::new(std::collections::HashMap::new())),
rest_client,
}
}

pub async fn resolve(&self, channel_id: Uuid) -> Option<PromptChannelInfo> {
if let Some(info) = self
let mut info = if let Some(info) = self
.cache
.read()
.ok()
.and_then(|cache| cache.get(&channel_id).cloned())
{
return Some(info);
}
info
} else {
let info = fetch_channel_info(channel_id, &self.rest_client).await?;
if let Ok(mut cache) = self.cache.write() {
cache.insert(channel_id, info.clone());
}
info
};
info.project = self.lookup_project(channel_id).await;
Some(info)
}

let info = fetch_channel_info(channel_id, &self.rest_client).await?;
if let Ok(mut cache) = self.cache.write() {
cache.insert(channel_id, info.clone());
async fn lookup_project(&self, channel_id: Uuid) -> Option<PromptProjectInfo> {
if let Some(cached) = self
.projects
.read()
.ok()
.and_then(|cache| cache.get(&channel_id).cloned())
{
return cached;
}
Some(info)
let fetched = fetch_project_home_for_channel(channel_id, &self.rest_client).await;
if let Ok(mut cache) = self.projects.write() {
cache.insert(channel_id, fetched.clone());
}
fetched
}
}

Expand Down Expand Up @@ -2937,6 +2961,7 @@ pub(crate) async fn fetch_channel_info(
name: name.unwrap_or(UNKNOWN_CHANNEL_NAME).to_string(),
channel_type,
description,
project: None,
})
}
Ok(Err(e)) => {
Expand All @@ -2958,6 +2983,53 @@ pub(crate) async fn fetch_channel_info(
.await
}

/// Resolve the listed NIP-MP project whose home channel is `channel_id`.
///
/// Empty results are not retried: most channels are not project homes.
pub(crate) async fn fetch_project_home_for_channel(
channel_id: Uuid,
rest: &RestClient,
) -> Option<PromptProjectInfo> {
let filters = [
serde_json::json!({
"kinds": [buzz_core::kind::KIND_PROJECT],
"limit": 1000,
}),
serde_json::json!({
"kinds": [buzz_core::kind::KIND_GIT_REPO_ANNOUNCEMENT],
"#buzz-channel": [channel_id.to_string()],
"limit": 1000,
}),
];

let json = fetch_with_retry(|| async {
match timeout(CONTEXT_FETCH_TIMEOUT, rest.query_raw(&filters)).await {
Ok(Ok(json)) => Some(json),
Ok(Err(e)) => {
tracing::debug!(
channel_id = %channel_id,
"project home fetch failed: {e} — will retry"
);
None
}
Err(_) => {
tracing::debug!(
channel_id = %channel_id,
"project home fetch timed out — will retry"
);
None
}
}
})
.await?;
let events = json.as_array()?;
let (projects, repos): (Vec<_>, Vec<_>) = events.iter().cloned().partition(|event| {
event.get("kind").and_then(serde_json::Value::as_u64)
== Some(buzz_core::kind::KIND_PROJECT as u64)
});
pick_authoritative_project_home(&projects, &repos, &channel_id.to_string())
}

/// Fetch owner-signed huddle instructions for a new channel session.
///
/// The event is promoted into the system role, so accepting any channel member's
Expand Down Expand Up @@ -8373,7 +8445,8 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'"
}

/// A normal channel yields a non-DM (canvas allowed) and its name for the
/// title suffix — and the second consumer reads it from cache, not the wire.
/// title suffix. Channel metadata and project context resolve once each;
/// the second consumer reads both from cache.
#[tokio::test]
async fn test_new_session_channel_context_qualifies_a_normal_channel() {
use std::sync::atomic::Ordering;
Expand All @@ -8387,14 +8460,14 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'"
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);
assert_eq!(requests.load(Ordering::SeqCst), 2);

let (_, again, _) = resolve_new_session_channel_context(&resolver, id).await;
assert_eq!(again.as_deref(), Some("buzz-dev"));
assert_eq!(
requests.load(Ordering::SeqCst),
1,
"a resolved channel is cached — no second lookup"
2,
"resolved channel metadata and project context are cached"
);
server.abort();
}
Expand Down
Loading
Loading