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 |