OXYGENHUB

The API reports each agent's extra channels 1876f2ce71e4

So the editor can show and change who may post where.

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 2737aaf6ff7c
When
· 2026-11-03 20:41 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
1876f2ce71e429c556a0f8e133d08f799ba54eeeec2106b5bddbc3cd1975d0e0
Depends on
258bb3824813 67bfa06a894f 862f0db224aa 87f601ccce6d 98a0fb9f0948 e071e91a14ef
Depended on by
2d2dcb7a2778
In bookmarks
main
Edits
8 atoms, 0 tree operations, 5 files

Browse the tree at this change

modified CHANGELOG.md +1 −0
…
1 unchanged lines
2 2
3 3
## Unreleased
4 4
5 +
- **Added: an agent's extra channels in the API and `agent list --json`** (`channels`), the ones `spectra agent allow` opened to it.
5 6
- **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.
6 7
- **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.
7 8
- **Added: `spectra agent allow NAME --channel CHANNEL [--remove]`.** The owner lets one agent post in another project's channel, and can take it back; it is a signed `agent.scoped` event that no agent can make for itself. An agent refused a channel is told this command.
…
32 unchanged lines
modified crates/spectra-api/src/lib.rs +4 −0
…
44 unchanged lines
45 45
    /// When the owner's delegation to the key ends, in ms.
46 46
    #[serde(default)]
47 47
    pub delegation_expires: Option<u64>,
48 +
    /// Other projects' channels the owner lets it post in
49 +
    /// (`spectra agent allow`), without the `#`.
50 +
    #[serde(default)]
51 +
    pub channels: Vec<String>,
48 52
}
49 53
50 54
/// A project (spec §23.1).
…
188 unchanged lines
modified crates/spectra-api/ts/index.d.ts +1 −0
…
14 unchanged lines
15 15
};
16 16
17 17
export type Agent = {
18 +
  channels?: string[];
18 19
  delegation_expires?: number | null;
19 20
  disabled: boolean;
20 21
  display: string;
…
130 unchanged lines
modified crates/spectra-mcp/src/api.rs +9 −3
…
202 unchanged lines
203 203
204 204
async fn agent_list(st: &AppState) -> Vec<api::Agent> {
205 205
    #[allow(clippy::type_complexity)]
206 -
    let rows: Vec<(String, String, String, String, String, Option<String>, Option<i64>)> = sqlx::query_as(
207 -
        "SELECT a.name, a.owner_name, a.key, a.kind, a.project, p.status_text, a.disabled_ms FROM agents a LEFT JOIN presence p ON p.agent = a.name ORDER BY a.name",
206 +
    let rows: Vec<(String, String, String, String, String, Option<String>, Option<i64>, String)> = sqlx::query_as(
207 +
        "SELECT a.name, a.owner_name, a.key, a.kind, a.project, p.status_text, a.disabled_ms, a.scopes FROM agents a LEFT JOIN presence p ON p.agent = a.name ORDER BY a.name",
208 208
    )
209 209
    .fetch_all(st.daemon.ledger.pool())
210 210
    .await
…
2 unchanged lines
213 213
    let principals = st.daemon.principals.read().await;
214 214
    let now = now_ms();
215 215
    rows.into_iter()
216 -
        .map(|(name, owner, key, kind, project, status, disabled)| {
216 +
        .map(|(name, owner, key, kind, project, status, disabled, scopes)| {
217 +
            let channels = serde_json::from_str::<Vec<String>>(&scopes)
218 +
                .unwrap_or_default()
219 +
                .into_iter()
220 +
                .filter_map(|s| s.strip_prefix("channel:").map(String::from))
221 +
                .collect();
217 222
            let k = spectra_core::identity::KeyId::from_hex(&key);
218 223
            let state = match k.map(|k| principals.principal(&k, now)) {
219 224
                Some(Ok(_)) => "ok".to_string(),
…
13 unchanged lines
233 238
                status,
234 239
                state,
235 240
                delegation_expires,
241 +
                channels,
236 242
            }
237 243
        })
238 244
        .collect()
…
342 unchanged lines
modified tests/allow.rs +3 −0
…
31 unchanged lines
32 32
33 33
    let o = run(d, &["agent", "allow", "claude-mg", "--channel", "#bubblebobble"]);
34 34
    assert!(o.status.success(), "{}", text(&o));
35 +
    let listed: Value = serde_json::from_slice(&run(d, &["agent", "list", "--json"]).stdout).unwrap();
36 +
    let mg = listed.as_array().unwrap().iter().find(|a| a["name"] == "claude-mg").unwrap();
37 +
    assert_eq!(mg["channels"], json!(["bubblebobble"]), "the API reports the grant: {mg}");
35 38
    assert_eq!(say(&c, "bubblebobble").await["isError"], false, "allowed");
36 39
    assert_eq!(say(&c, "oxygen").await["isError"], false, "its own channel still");
37 40
…
23 unchanged lines