Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
69 commits
Select commit Hold shift + click to select a range
acdabfa
Group consecutive desktop messages
klopez4212 Jul 1, 2026
ed59887
Add Chats mode
klopez4212 Jul 2, 2026
f8400b3
Add request deadlines to the desktop relay HTTP bridge
klopez4212 Jul 3, 2026
d4b0cfd
Teach buzz-acp to title chats and replay unanswered chat backlog
klopez4212 Jul 3, 2026
a217a04
Polish chats mode: live feedback, auto-titles, and turn readability
klopez4212 Jul 3, 2026
7c7302e
Anchor multi-message turns to the user's latest message
klopez4212 Jul 4, 2026
203e653
Hide turn-setup lifecycle markers in chats
klopez4212 Jul 4, 2026
e5c941c
Restore the last-viewed chat when returning to the Chats tab
klopez4212 Jul 4, 2026
0360171
Render rich GitHub PR cards in messages
klopez4212 Jul 4, 2026
44926d1
Track live turns per turn id; distinct agent-authored PR cards
klopez4212 Jul 4, 2026
30b8fb2
Chat sidebar and stream polish
klopez4212 Jul 4, 2026
d016092
Keep sidebar shimmer titles readable and truncating
klopez4212 Jul 4, 2026
8a5a5fd
Steady the Working shimmer; instant archive slot
klopez4212 Jul 4, 2026
d447793
Phase-shift the shimmer keyframes instead of a negative delay
klopez4212 Jul 4, 2026
ea4cc55
Fade chat content under the header like the sidebar's pinned edge
klopez4212 Jul 4, 2026
533af6a
Chat stream polish, PR work panel, and branch e2e fixes
klopez4212 Jul 4, 2026
751113d
Stop Vite from watching test artifacts
klopez4212 Jul 4, 2026
fcf5995
Toggle the chat work panel from the header
klopez4212 Jul 4, 2026
da405b1
Drop the leading icon from the chat header
klopez4212 Jul 4, 2026
e60bb47
Style the work drawer as a secondary-surface container
klopez4212 Jul 4, 2026
6cb50e3
Give the work panel's PR card its own surface
klopez4212 Jul 4, 2026
08a49fe
Drop the Work label from the chat work panel
klopez4212 Jul 4, 2026
0f83304
Soften the work panel container to a muted wash
klopez4212 Jul 4, 2026
e936664
Reserve the rich PR card for the chat work drawer
klopez4212 Jul 4, 2026
670e318
Widen the work drawer and match generic card styling
klopez4212 Jul 4, 2026
a9b8648
Always offer the work drawer; lighten the Share button
klopez4212 Jul 4, 2026
de961d5
Defer opening the rename dialog past the context menu's close
klopez4212 Jul 4, 2026
76536b6
CI monitor and automation toggles in the chat work panel
klopez4212 Jul 4, 2026
488e9ca
Expandable CI monitor with per-check runs and open-comment count
klopez4212 Jul 4, 2026
1eaef15
Show the agent's live branch in the work panel, animated on change
klopez4212 Jul 4, 2026
10ca526
Make the accent shimmer contrasty and robust to mid-turn attach
klopez4212 Jul 4, 2026
47f2a92
Composer stop button, message-derived branch, muted markers, shimmer …
klopez4212 Jul 4, 2026
3f7b74c
Ellipsize the shimmer overlay and center the stop icon
klopez4212 Jul 4, 2026
0419c69
Place unanchored turn activity by time instead of pinning it last
klopez4212 Jul 4, 2026
9ff6635
Soften chat marker rows to muted-foreground/70
klopez4212 Jul 4, 2026
7c402a9
Skip 👀/💬 seen and working reactions in chat channels
klopez4212 Jul 4, 2026
658d7b2
Work panel: accept PRs from any agent, pop the GitHub stack in
klopez4212 Jul 4, 2026
1212e9d
Track the chat's PR link from any participant, not just agents
klopez4212 Jul 4, 2026
2782aff
Render @mentions as chips in chat messages
klopez4212 Jul 4, 2026
362ecb5
New-chat preset cards and activity from every agent in a chat
klopez4212 Jul 4, 2026
6dd0b0b
Preset card polish and PR discovery from the branch
klopez4212 Jul 4, 2026
f326779
Tint link chips inside own message bubbles
klopez4212 Jul 4, 2026
0754952
Offer Side conversation on thread messages
klopez4212 Jul 4, 2026
5d118e8
Weight chat conversation text at medium
klopez4212 Jul 5, 2026
2b5e4fc
Hide automation prompts from the chat timeline
klopez4212 Jul 5, 2026
64dcb7a
Hold agent activation in its loading state until the agent responds
klopez4212 Jul 5, 2026
6def2ce
Match transcript message weight to conversation text
klopez4212 Jul 5, 2026
8a6ca7a
Name chats the way branches are named
klopez4212 Jul 5, 2026
688bbf7
Reject placeholder tokens when parsing branches
klopez4212 Jul 5, 2026
c55c81e
Keep the PR monitor live: gh token fallback, honest errors, poll gating
klopez4212 Jul 5, 2026
d9acb8c
Never render reaction events as chat bubbles
klopez4212 Jul 5, 2026
9f5c22c
Animate the work panel only for new information
klopez4212 Jul 5, 2026
68e364d
Re-nudge stalled automation and add manual Run now overrides
klopez4212 Jul 5, 2026
9afce99
Make mention chips legible inside own message bubbles
klopez4212 Jul 5, 2026
00a8950
Efficiency pass over the chat features
klopez4212 Jul 5, 2026
d91d6ff
Always surface a stopped default agent; acknowledge Run now
klopez4212 Jul 5, 2026
5d76f95
Merge origin/main into kennylopez-chatmode
klopez4212 Jul 5, 2026
eb09bcc
Post-merge reconciliation with latest main
klopez4212 Jul 5, 2026
92bb116
Recognize compatibility chats on relays without channel_type: chat
klopez4212 Jul 5, 2026
3fe55d1
Make Run now trustworthy and the sidebar shimmer unmissable
klopez4212 Jul 5, 2026
bc6233e
Pin each chat's PR so panels stop converging on one PR
klopez4212 Jul 5, 2026
4309005
Clip the sidebar shimmer wash to the chat name
klopez4212 Jul 5, 2026
6cbf879
Never hide a real agent reply behind narration heuristics
klopez4212 Jul 5, 2026
8e898e8
Route automation marker tags through the client tag channel
klopez4212 Jul 5, 2026
bf3bdb7
Let a chat unlink a mispinned PR
klopez4212 Jul 5, 2026
64e826b
Persist the work drawer state; shimmer washes toward background
klopez4212 Jul 5, 2026
6bdf2b5
Shimmer: drop the wash rectangle, band sits at the background color
klopez4212 Jul 5, 2026
8da3fc0
Work drawer state is purely remembered
klopez4212 Jul 5, 2026
16f231f
Pin a PR to the work panel manually
klopez4212 Jul 5, 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
35 changes: 35 additions & 0 deletions crates/buzz-acp/src/acp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ use crate::observer::{ObserverContext, ObserverHandle};
/// Lines exceeding this limit are rejected to prevent OOM from rogue agents.
const MAX_LINE_SIZE: usize = 10_000_000; // 10 MB

/// Byte cap for [`AcpClient::begin_message_capture`] accumulation.
const MESSAGE_CAPTURE_MAX_LEN: usize = 4_096;

/// An MCP server configuration passed to `session/new`.
///
/// Corresponds to the `McpServerStdio` variant in the ACP schema.
Expand Down Expand Up @@ -167,6 +170,11 @@ pub struct AcpClient {
/// outside of a goose-native turn — the read loop's steer arm is
/// disabled in that case.
steer_rx: Option<tokio::sync::mpsc::Receiver<crate::pool::SteerRequest>>,
/// Accumulates `agent_message_chunk` text while a capture is active.
/// Used by side prompts (e.g. chat-title generation) that need the
/// assistant's reply text, which is otherwise only streamed to logs.
/// `None` = capture inactive.
message_capture: Option<String>,
}

impl AcpClient {
Expand Down Expand Up @@ -258,6 +266,7 @@ impl AcpClient {
observer_context: ObserverContext::default(),
active_run_id: None,
steer_rx: None,
message_capture: None,
})
}

Expand Down Expand Up @@ -294,6 +303,20 @@ impl AcpClient {
}
}

/// Start capturing streamed assistant text (`agent_message_chunk`).
///
/// Used by side prompts (e.g. chat-title generation) whose reply the
/// harness needs as a string. Call before `session_prompt_*`, then
/// [`take_message_capture`](Self::take_message_capture) after it returns.
pub fn begin_message_capture(&mut self) {
self.message_capture = Some(String::new());
}

/// Stop capturing and return the accumulated assistant text.
pub fn take_message_capture(&mut self) -> Option<String> {
self.message_capture.take()
}

/// Send the `initialize` request and return the agent's response result value.
///
/// Must be called exactly once, before any other ACP method.
Expand Down Expand Up @@ -1218,6 +1241,18 @@ impl AcpClient {
"agent_message_chunk" => {
if let Some(text) = update["content"]["text"].as_str() {
tracing::info!(target: "acp::stream", "{text}");
if let Some(capture) = self.message_capture.as_mut() {
// Side prompts expect short replies; the cap bounds a
// runaway stream, not legitimate output.
if capture.len() < MESSAGE_CAPTURE_MAX_LEN {
let budget = MESSAGE_CAPTURE_MAX_LEN - capture.len();
let mut end = budget.min(text.len());
while end > 0 && !text.is_char_boundary(end) {
end -= 1;
}
capture.push_str(&text[..end]);
}
}
}
false
}
Expand Down
3 changes: 2 additions & 1 deletion crates/buzz-acp/src/base_prompt.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ All replies and delegations — including task assignments to other agents — g
### General

- Respond promptly to @mentions. Be direct — no preamble. Name what you did, what you found, or what you need.
- **Every turn that processes a user message MUST end with `buzz messages send`.** Your reasoning and tool calls are invisible to users — if you didn't send a message, they saw nothing. A turn that ends without a sent message is a silent failure.
- **Every turn that processes a user message MUST end with `buzz messages send --channel <UUID> --content "..."`.** Message text is not positional — always pass it with `--content`, or pipe it with `--content -` for shell-sensitive text. Your reasoning and tool calls are invisible to users — if you didn't send a message, they saw nothing. A turn that ends without a sent message is a silent failure.
- Do not narrate that you are about to send a reply, or that you sent one. Put the actual human-facing answer in the final `buzz messages send` content only.
- For work that requires follow-up tools, create an open todo **before** sending the pickup acknowledgment. Keep it open until the deliverable is verified and you have sent a completion or blocker message; never end a turn with open todo state unless you have posted that completion or blocker message.
- Use GitHub-flavored Markdown. Fenced code blocks with language tags for syntax highlighting.
- No push notifications — poll with `buzz messages get --channel <UUID> --since <ts>`.
Expand Down
34 changes: 34 additions & 0 deletions crates/buzz-acp/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,16 @@ pub struct ChannelFilter {
pub require_mention: bool,
}

/// Chat channels are single-agent addressed even when multiple agents are
/// members: the desktop client writes the default agent as an implicit `p` tag
/// and explicit @mentions add more `p` tags. Force `#p` filtering so broad
/// agent configs do not fan every chat turn out to every attached agent.
pub fn force_mention_filter_for_chat(filter: &mut ChannelFilter, channel_type: Option<&str>) {
if channel_type == Some("chat") {
filter.require_mention = true;
}
}

#[derive(Debug)]
pub struct Config {
pub keys: Keys,
Expand Down Expand Up @@ -1414,6 +1424,30 @@ mod tests {
assert!(!f.require_mention);
}

#[test]
fn test_chat_channels_force_mention_filter() {
let mut filter = ChannelFilter {
kinds: None,
require_mention: false,
};

force_mention_filter_for_chat(&mut filter, Some("chat"));

assert!(filter.require_mention);
}

#[test]
fn test_non_chat_channels_keep_resolved_filter() {
let mut filter = ChannelFilter {
kinds: None,
require_mention: false,
};

force_mention_filter_for_chat(&mut filter, Some("stream"));

assert!(!filter.require_mention);
}

#[test]
fn normalizes_goose_args_to_acp() {
assert_eq!(normalize_agent_args("goose", Vec::new()), vec!["acp"]);
Expand Down
201 changes: 196 additions & 5 deletions crates/buzz-acp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,129 @@ async fn publish_presence(
Ok(())
}

fn channel_is_chat(channel_info: &HashMap<Uuid, relay::ChannelInfo>, channel_id: Uuid) -> bool {
channel_info
.get(&channel_id)
.is_some_and(|info| info.channel_type == "chat")
}

/// Oldest chat backlog the startup replay will deliver. Bounds necro-replies
/// to abandoned chats while comfortably covering the activation flow (owner
/// messages a stopped agent, then starts it from the desktop).
const CHAT_BACKLOG_MAX_AGE_SECS: u64 = 6 * 60 * 60;

/// Replay floor for a chat channel's startup subscription.
///
/// A chat's owner often messages while the agent is stopped — the desktop's
/// "Activate agent" card starts the agent on demand — and a `since: now`
/// subscription would silently skip that pending message forever. Replay from
/// just after the agent's own last reply in the channel (it has answered
/// everything before that), capped to a recent window. Best-effort: on query
/// failure, fall back to plain live-only subscription.
async fn chat_backlog_replay_since(
rest: &relay::RestClient,
channel_id: Uuid,
agent_pubkey_hex: &str,
startup_watermark: u64,
) -> Option<u64> {
let floor = startup_watermark.saturating_sub(CHAT_BACKLOG_MAX_AGE_SECS);
let author = nostr::PublicKey::from_hex(agent_pubkey_hex).ok()?;
let h_tag = nostr::SingleLetterTag::lowercase(nostr::Alphabet::H);
let filter = nostr::Filter::new()
.kind(nostr::Kind::Custom(9))
.author(author)
.custom_tags(h_tag, [channel_id.to_string()])
.limit(1);

match rest.query(&[filter]).await {
Ok(value) => {
let last_reply_ts = value.as_array().and_then(|events| {
events
.iter()
.filter_map(|event| event.get("created_at").and_then(|v| v.as_u64()))
.max()
});
Some(match last_reply_ts {
Some(ts) => ts.saturating_add(1).max(floor),
None => floor,
})
}
Err(error) => {
tracing::debug!("chat backlog query failed for {channel_id}: {error}");
None
}
}
}

/// Reclassify compatibility chats.
///
/// Relays that predate `channel_type: chat` store chats as plain private
/// channels — the chat-ness lives only in a kind 30623 (or legacy 30078)
/// chat-metadata event. Without this, every chat behavior keyed on
/// `channel_type == "chat"` (activation backlog replay, mention gating,
/// reaction suppression, title emission) silently degrades against staging.
async fn mark_compat_chats(
rest: &relay::RestClient,
channel_info: &mut HashMap<Uuid, relay::ChannelInfo>,
) {
const KIND_CHAT_METADATA: u16 = 30623;
const LEGACY_CHAT_METADATA_KIND: u16 = 30078;
const LEGACY_D_PREFIX: &str = "buzz:chat:";

if channel_info
.values()
.all(|info| info.channel_type == "chat")
{
return;
}
let filters = [
nostr::Filter::new()
.kind(nostr::Kind::Custom(KIND_CHAT_METADATA))
.limit(500),
nostr::Filter::new()
.kind(nostr::Kind::Custom(LEGACY_CHAT_METADATA_KIND))
.limit(1000),
];
let value = match rest.query(&filters).await {
Ok(value) => value,
Err(error) => {
tracing::debug!("compat chat metadata query failed: {error}");
return;
}
};
let mut reclassified = 0usize;
for event in value.as_array().into_iter().flatten() {
let kind = event.get("kind").and_then(|v| v.as_u64()).unwrap_or(0);
let tag_value = |name: &str| -> Option<String> {
event.get("tags")?.as_array()?.iter().find_map(|tag| {
let tag = tag.as_array()?;
(tag.first()?.as_str()? == name)
.then(|| tag.get(1)?.as_str().map(str::to_string))
.flatten()
})
};
let channel_id = if kind == u64::from(LEGACY_CHAT_METADATA_KIND) {
tag_value("chat_h").or_else(|| {
tag_value("d").and_then(|d| d.strip_prefix(LEGACY_D_PREFIX).map(str::to_string))
})
} else {
tag_value("d")
};
let Some(channel_id) = channel_id.and_then(|id| id.parse::<Uuid>().ok()) else {
continue;
};
if let Some(info) = channel_info.get_mut(&channel_id) {
if info.channel_type != "chat" {
info.channel_type = "chat".to_string();
reclassified += 1;
}
}
}
if reclassified > 0 {
tracing::info!("reclassified {reclassified} compatibility chat channel(s)");
}
}

/// Resolve the agent's owner pubkey at startup.
///
/// Priority:
Expand Down Expand Up @@ -1311,10 +1434,12 @@ async fn tokio_main() -> Result<()> {
}
}

let channel_info_map = relay
let mut channel_info_map = relay
.discover_channels()
.await
.map_err(|e| anyhow::anyhow!("channel discovery error: {e}"))?;
mark_compat_chats(&relay.rest_client(), &mut channel_info_map).await;
let channel_info_map = channel_info_map;

tracing::info!("discovered {} channel(s)", channel_info_map.len());
let channel_ids: Vec<Uuid> = channel_info_map.keys().copied().collect();
Expand Down Expand Up @@ -1356,13 +1481,36 @@ async fn tokio_main() -> Result<()> {
}
};

let channel_filters = config::resolve_channel_filters(&config, &channel_ids, &rules);
let mut channel_filters = config::resolve_channel_filters(&config, &channel_ids, &rules);
for (channel_id, filter) in channel_filters.iter_mut() {
config::force_mention_filter_for_chat(
filter,
channel_info_map
.get(channel_id)
.map(|info| info.channel_type.as_str()),
);
}
let mut event_channel_info = channel_info_map.clone();
if channel_filters.is_empty() {
tracing::warn!("no channel subscriptions resolved — agent will sit idle");
}
let backlog_rest = relay.rest_client();
for (channel_id, filter) in &channel_filters {
if let Err(e) = relay.subscribe_channel(*channel_id, filter.clone()).await {
// Chats replay their unanswered backlog; other channel types stay
// live-only from the startup watermark.
let replay_since = if channel_is_chat(&channel_info_map, *channel_id) {
chat_backlog_replay_since(&backlog_rest, *channel_id, &pubkey_hex, startup_watermark)
.await
} else {
None
};
if let Err(e) = relay
.subscribe_channel_from(*channel_id, filter.clone(), replay_since)
.await
{
tracing::warn!("failed to subscribe to channel {channel_id}: {e}");
} else if let Some(since) = replay_since {
tracing::info!("subscribed to channel {channel_id} (chat backlog since {since})");
} else {
tracing::info!("subscribed to channel {channel_id}");
}
Expand Down Expand Up @@ -1402,6 +1550,7 @@ async fn tokio_main() -> Result<()> {
.as_deref()
.and_then(|hex| nostr::PublicKey::from_hex(hex).ok()),
memory_enabled: config.memory_enabled,
titled_channels: std::sync::Mutex::new(std::collections::HashSet::new()),
});

if !config.memory_enabled {
Expand Down Expand Up @@ -1730,7 +1879,29 @@ async fn tokio_main() -> Result<()> {
// stripped for a legitimately re-added channel.
removed_channels.remove(&ch);

if let Some(filter) = config::resolve_dynamic_channel_filter(&config, ch, &rules) {
if let Some(mut filter) = config::resolve_dynamic_channel_filter(&config, ch, &rules) {
match relay.discover_channels().await {
Ok(discovered) => {
event_channel_info.extend(discovered);
mark_compat_chats(
&ctx.rest_client,
&mut event_channel_info,
)
.await;
}
Err(e) => {
tracing::debug!(
channel_id = %ch,
"membership notification: channel metadata refresh failed: {e}"
);
}
}
config::force_mention_filter_for_chat(
&mut filter,
event_channel_info
.get(&ch)
.map(|info| info.channel_type.as_str()),
);
tracing::info!(channel_id = %ch, "membership notification: subscribing to new channel");
if let Err(e) = relay.subscribe_channel_from(ch, filter, Some(ts)).await {
tracing::warn!("failed to subscribe to new channel {ch}: {e}");
Expand All @@ -1752,6 +1923,7 @@ async fn tokio_main() -> Result<()> {
// Track removed channels so checked-out agents get
// their sessions stripped when they return to the pool.
removed_channels.insert(ch);
event_channel_info.remove(&ch);
typing_channels.remove(&ch);
// Best-effort: clean up 👀 on drained events.
// Note: the relay revokes membership before
Expand Down Expand Up @@ -1920,6 +2092,17 @@ async fn tokio_main() -> Result<()> {
}
}

if channel_is_chat(&event_channel_info, buzz_event.channel_id)
&& !event_mentions_agent(&buzz_event.event, &pubkey_hex)
{
tracing::debug!(
channel_id = %buzz_event.channel_id,
kind = buzz_event.event.kind.as_u16(),
"chat event did not mention this agent — dropping"
);
continue;
}

let matched = filter::match_event(&buzz_event.event, buzz_event.channel_id, &rules, &pubkey_hex).await;
let prompt_tag = match matched {
Some(m) => m.prompt_tag,
Expand Down Expand Up @@ -1955,7 +2138,15 @@ async fn tokio_main() -> Result<()> {
// Fire-and-forget: on rare fast-failure paths the
// guard's cleanup may race with this add, leaving a
// cosmetic stale 👀. Acceptable — see ReactionGuard docs.
if accepted {
// Chats skip it: their UI has live working
// indicators, and reaction emoji on the user's
// message reads as noise there.
if accepted
&& !channel_is_chat(
&event_channel_info,
buzz_event.channel_id,
)
{
let rc = ctx.rest_client.clone();
let eid = event_id_hex.clone();
tokio::spawn(async move {
Expand Down
Loading
Loading