OXYGENHUB

@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
}