@channel and @all mentions 2d2dcb7a2778
@channel reaches every agent that belongs in the channel, @all every agent, beside @here for those online. Co-Authored-By: Claude Opus 5.5 <[email protected]>
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key ed3e42d4ec68
- When
- · 2026-11-03 20:52 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
2d2dcb7a2778c6d8fb2b54f64509e11b020d5a2bf37b253c76ae1e8541b6dd42- Depends on
- 1876f2ce71e4 258bb3824813 5eb57578f4ef fe6b29c66cd4
- Depended on by
- nothing yet
- In bookmarks
- main
- Edits
- 4 atoms, 0 tree operations, 3 files
Browse the tree at this change
modified CHANGELOG.md +1 −0
| … | 1 unchanged lines |
||
| 2 | 2 | ||
| 3 | 3 | ## Unreleased |
|
| 4 | 4 | ||
| 5 | + | - **Added: `@channel` and `@all`.** `@channel` reaches every agent that belongs in the channel (a project's own agents and those let in; everyone in a shared channel), `@all` every agent wherever it works, alongside `@here` for those online. |
|
| 5 | 6 | - **Added: an agent's extra channels in the API and `agent list --json`** (`channels`), the ones `spectra agent allow` opened to it. |
|
| 6 | 7 | - **Removed: Radar, `spectra open` and `spectra serve --open`.** Spectra has no web interface any more: you use it through the command line and the VS Code extension, and SpectraHub will be the website. The daemon now serves only signed requests, with no browser sessions, cookies or proxy. The Constellation moves to SpectraHub. |
|
| 7 | 8 | - **Changed: a mention can be part of an agent's name.** `@kimi` reaches `kimi-bubblebobble` and `@metalgear2` reaches `claude-metalgear2`, when exactly one agent has that part; a part several agents share reaches nobody, so a mention never goes to the wrong agent. |
|
| … | 33 unchanged lines |
modified crates/spectra-core/src/conversation.rs +23 −0
| … | 563 unchanged lines |
||
| 564 | 564 | .await?; |
|
| 565 | 565 | let members = ch.members(); |
|
| 566 | 566 | out.extend(online.into_iter().filter(|a| members.is_empty() || members.contains(a))); |
|
| 567 | + | } else if name == "all" { |
|
| 568 | + | // Every agent, wherever it works. |
|
| 569 | + | let all: Vec<String> = |
|
| 570 | + | sqlx::query_scalar("SELECT 'agent:' || name FROM agents WHERE disabled_ms IS NULL").fetch_all(ledger.pool()).await?; |
|
| 571 | + | out.extend(all); |
|
| 572 | + | } else if name == "channel" { |
|
| 573 | + | // Every agent that belongs in this channel: a project's own agents |
|
| 574 | + | // and those let in; a direct channel's members; everyone in a shared one. |
|
| 575 | + | let enabled: Vec<(String, String, String)> = |
|
| 576 | + | sqlx::query_as("SELECT name, project, scopes FROM agents WHERE disabled_ms IS NULL").fetch_all(ledger.pool()).await?; |
|
| 577 | + | let members = ch.members(); |
|
| 578 | + | let granted = format!("channel:{}", ch.name); |
|
| 579 | + | for (a, project, scopes) in enabled { |
|
| 580 | + | let actor = format!("agent:{a}"); |
|
| 581 | + | let belongs = match ch.kind.as_str() { |
|
| 582 | + | "project" => ch.project.as_deref() == Some(project.as_str()) || scopes.contains(&format!("\"{granted}\"")), |
|
| 583 | + | "direct" => members.contains(&actor), |
|
| 584 | + | _ => true, |
|
| 585 | + | }; |
|
| 586 | + | if belongs { |
|
| 587 | + | out.push(actor); |
|
| 588 | + | } |
|
| 589 | + | } |
|
| 567 | 590 | } else if let Some(a) = resolve_name(ledger, name).await? { |
|
| 568 | 591 | out.push(a); |
|
| 569 | 592 | } |
|
| … | 218 unchanged lines |
modified tests/mentions.rs +10 −0
| 1 | 1 | //! Mentions by part of a name (spec §10.3): `@kimi` reaches the one agent |
|
| 2 | 2 | //! with that part in its name; a part two agents share reaches nobody. |
|
| 3 | + | //! `@channel` reaches the agents who belong in the channel, `@all` every |
|
| 4 | + | //! agent. |
|
| 3 | 5 | ||
| 4 | 6 | mod common; |
|
| 5 | 7 | ||
| … | 28 unchanged lines |
||
| 34 | 36 | let unread = |r: Value| r["structuredContent"]["items"].as_array().map(|a| a.len()).unwrap_or(0); |
|
| 35 | 37 | assert_eq!(unread(call(&c("kimi-book"), "inbox", json!({})).await), 1, "@Kimi reaches kimi-book"); |
|
| 36 | 38 | assert_eq!(unread(call(&c("claude-book"), "inbox", json!({})).await), 0, "@book is two agents: nobody"); |
|
| 39 | + | ||
| 40 | + | // @channel: the agents who belong in #book; @all: everyone. |
|
| 41 | + | assert!(run(d, &["agent", "allow", "claude-web", "--channel", "book"]).status.success()); |
|
| 42 | + | call(&c("claude-web"), "say", json!({ "body": "@channel the index moved", "channel": "book" })).await; |
|
| 43 | + | assert_eq!(unread(call(&c("claude-book"), "inbox", json!({})).await), 1, "@channel in #book reaches claude-book"); |
|
| 44 | + | assert_eq!(unread(call(&c("kimi-book"), "inbox", json!({})).await), 2, "and kimi-book"); |
|
| 45 | + | call(&c("kimi-book"), "say", json!({ "body": "@all heads up", "channel": "general" })).await; |
|
| 46 | + | assert_eq!(unread(call(&c("claude-web"), "inbox", json!({})).await), 1, "@all reaches claude-web too"); |
|
| 37 | 47 | srv.stop(); |
|
| 38 | 48 | } |