0.3.0: choose which channels an agent may post in, from the Agents and Channels views 94e8677a5439
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 cba6caee8261
- When
- · 2026-11-03 20:42 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
94e8677a54390ce2e044c133437df4469bc97789ca2e57217610e2026cb32f94- Depends on
- 5482605b3353 ff39a5e808da
- Depended on by
- 0bfe88762caa
- In bookmarks
- main
- Receipts
- Edits
- 13 atoms, 0 tree operations, 6 files
Browse the tree at this change
modified CHANGELOG.md +3 −0
| … | 1 unchanged lines |
||
| 2 | 2 | ||
| 3 | 3 | ## Unreleased |
|
| 4 | 4 | ||
| 5 | + | ## 0.3.0 |
|
| 6 | + | ||
| 7 | + | - **Added: who may post where.** Right-click an agent and choose **Channels…** to tick the other projects' channels it may post in; right-click a project's channel and choose **Agents…** to tick the agents from other projects who may post there. Each change is signed into Spectra as yours (`spectra agent allow`). The Agents view shows an agent's extra channels, and a channel's tooltip lists every agent who may post in it. |
|
| 5 | 8 | ## 0.2.0 |
|
| 6 | 9 | ||
| 7 | 10 | - **Removed: Open Radar and Open Constellation.** Spectra no longer has a web interface: the editor is its local view, and the website is SpectraHub. |
|
| … | 19 unchanged lines |
modified package.json +31 −1
| … | 1 unchanged lines |
||
| 2 | 2 | "name": "spectra-vscode", |
|
| 3 | 3 | "displayName": "Spectra", |
|
| 4 | 4 | "description": "Spectra in the editor: set up your AI agents, chat with them, and give them their briefs, without the command line", |
|
| 5 | - | "version": "0.2.0", |
|
| 5 | + | "version": "0.3.0", |
|
| 6 | 6 | "publisher": "andymccall", |
|
| 7 | 7 | "license": "MIT OR Apache-2.0", |
|
| 8 | 8 | "repository": { |
|
| … | 220 unchanged lines |
||
| 229 | 229 | "title": "Refresh", |
|
| 230 | 230 | "category": "Spectra", |
|
| 231 | 231 | "icon": "$(refresh)" |
|
| 232 | + | }, |
|
| 233 | + | { |
|
| 234 | + | "command": "spectra.agentChannels", |
|
| 235 | + | "title": "Channels\u2026", |
|
| 236 | + | "category": "Spectra" |
|
| 237 | + | }, |
|
| 238 | + | { |
|
| 239 | + | "command": "spectra.channelAgents", |
|
| 240 | + | "title": "Agents\u2026", |
|
| 241 | + | "category": "Spectra" |
|
| 232 | 242 | } |
|
| 233 | 243 | ], |
|
| 234 | 244 | "menus": { |
|
| … | 13 unchanged lines |
||
| 248 | 258 | { |
|
| 249 | 259 | "command": "spectra.renewAgent", |
|
| 250 | 260 | "when": "view == spectra.agents && viewItem == agent" |
|
| 261 | + | }, |
|
| 262 | + | { |
|
| 263 | + | "command": "spectra.agentChannels", |
|
| 264 | + | "when": "view == spectra.agents && viewItem == agent", |
|
| 265 | + | "group": "1_access" |
|
| 266 | + | }, |
|
| 267 | + | { |
|
| 268 | + | "command": "spectra.channelAgents", |
|
| 269 | + | "when": "view == spectra.channels && viewItem == projectChannel", |
|
| 270 | + | "group": "1_access" |
|
| 271 | + | } |
|
| 272 | + | ], |
|
| 273 | + | "commandPalette": [ |
|
| 274 | + | { |
|
| 275 | + | "command": "spectra.agentChannels", |
|
| 276 | + | "when": "spectra.ready" |
|
| 277 | + | }, |
|
| 278 | + | { |
|
| 279 | + | "command": "spectra.channelAgents", |
|
| 280 | + | "when": "spectra.ready" |
|
| 251 | 281 | } |
|
| 252 | 282 | ] |
|
| 253 | 283 | }, |
|
| … | 36 unchanged lines |
modified src/extension.ts +4 −0
| … | 93 unchanged lines |
||
| 94 | 94 | cmd("spectra.registerFolder", () => setup.registerFolder(hooks)); |
|
| 95 | 95 | cmd("spectra.linkProject", () => setup.linkProject(hooks)); |
|
| 96 | 96 | cmd("spectra.addAgent", () => setup.addAgent(hooks)); |
|
| 97 | + | cmd("spectra.agentChannels", (item?: vscode.TreeItem) => setup.agentChannels(hooks, typeof item?.label === "string" ? item.label : undefined)); |
|
| 98 | + | cmd("spectra.channelAgents", (item?: vscode.TreeItem) => |
|
| 99 | + | setup.channelAgents(hooks, typeof item?.label === "string" ? item.label.replace(/^#/, "") : undefined), |
|
| 100 | + | ); |
|
| 97 | 101 | cmd("spectra.renewAgent", (item?: vscode.TreeItem) => setup.renewAgent(hooks, typeof item?.label === "string" ? item.label : undefined)); |
|
| 98 | 102 | cmd("spectra.openChannel", async (name?: string) => { |
|
| 99 | 103 | const pick = name ?? (await vscode.window.showQuickPick(model.channels.map((c) => c.name), { placeHolder: "Which channel?" })); |
|
| … | 33 unchanged lines |
modified src/setup.ts +68 −0
| … | 328 unchanged lines |
||
| 329 | 329 | await fail(e); |
|
| 330 | 330 | } |
|
| 331 | 331 | } |
|
| 332 | + | ||
| 333 | + | /** Run `spectra agent allow` for each change; report what failed. */ |
|
| 334 | + | async function grant(h: Hooks, changes: { agent: string; channel: string; allow: boolean }[]): Promise<void> { |
|
| 335 | + | const failed: string[] = []; |
|
| 336 | + | for (const c of changes) { |
|
| 337 | + | try { |
|
| 338 | + | await cli.ok(["agent", "allow", c.agent, "--channel", c.channel, ...(c.allow ? [] : ["--remove"])]); |
|
| 339 | + | } catch (e) { |
|
| 340 | + | failed.push(`${c.agent} in #${c.channel}: ${(e as Error).message}`); |
|
| 341 | + | } |
|
| 342 | + | } |
|
| 343 | + | await h.changed(); |
|
| 344 | + | if (failed.length) void vscode.window.showErrorMessage(`Spectra: not changed: ${failed.join("; ")}`); |
|
| 345 | + | else if (changes.length) void vscode.window.setStatusBarMessage(`$(check) ${changes.length} channel permission${changes.length === 1 ? "" : "s"} signed into Spectra`, 5000); |
|
| 346 | + | } |
|
| 347 | + | ||
| 348 | + | /** Which other projects' channels an agent may post in. */ |
|
| 349 | + | export async function agentChannels(h: Hooks, agent?: string): Promise<void> { |
|
| 350 | + | const name = agent ?? (await vscode.window.showQuickPick(h.agents().filter((a) => !a.disabled).map((a) => a.name), { placeHolder: "Which agent?" })); |
|
| 351 | + | const a = h.agents().find((x) => x.name === name); |
|
| 352 | + | if (!a) return; |
|
| 353 | + | const now = new Set(a.channels ?? []); |
|
| 354 | + | const items = h |
|
| 355 | + | .projects() |
|
| 356 | + | .filter((p) => p.name !== a.project) |
|
| 357 | + | .map((p) => ({ label: `#${p.name}`, description: p.description || undefined, picked: now.has(p.name), channel: p.name })); |
|
| 358 | + | if (!items.length) { |
|
| 359 | + | void vscode.window.showInformationMessage(`There are no other projects for ${a.name} to post in.`); |
|
| 360 | + | return; |
|
| 361 | + | } |
|
| 362 | + | const picked = await vscode.window.showQuickPick(items, { |
|
| 363 | + | canPickMany: true, |
|
| 364 | + | title: `Channels ${a.name} may post in`, |
|
| 365 | + | placeHolder: `Besides #${a.project} and #general. Untick to take a channel back.`, |
|
| 366 | + | }); |
|
| 367 | + | if (!picked) return; |
|
| 368 | + | const want = new Set(picked.map((p) => p.channel)); |
|
| 369 | + | const changes = items |
|
| 370 | + | .filter((i) => want.has(i.channel) !== now.has(i.channel)) |
|
| 371 | + | .map((i) => ({ agent: a.name, channel: i.channel, allow: want.has(i.channel) })); |
|
| 372 | + | await grant(h, changes); |
|
| 373 | + | } |
|
| 374 | + | ||
| 375 | + | /** Which agents from other projects may post in a project's channel. */ |
|
| 376 | + | export async function channelAgents(h: Hooks, channel?: string): Promise<void> { |
|
| 377 | + | const name = (channel ?? (await vscode.window.showQuickPick(h.projects().map((p) => p.name), { placeHolder: "Which project's channel?" })))?.replace(/^#/, ""); |
|
| 378 | + | if (!name) return; |
|
| 379 | + | const items = h |
|
| 380 | + | .agents() |
|
| 381 | + | .filter((a) => !a.disabled && a.project !== name) |
|
| 382 | + | .map((a) => ({ label: a.name, description: `${a.kind} · #${a.project}`, picked: (a.channels ?? []).includes(name), agent: a.name })); |
|
| 383 | + | if (!items.length) { |
|
| 384 | + | void vscode.window.showInformationMessage(`Every agent already works on ${name}.`); |
|
| 385 | + | return; |
|
| 386 | + | } |
|
| 387 | + | const own = h.agents().filter((a) => !a.disabled && a.project === name).map((a) => a.name); |
|
| 388 | + | const picked = await vscode.window.showQuickPick(items, { |
|
| 389 | + | canPickMany: true, |
|
| 390 | + | title: `Agents who may post in #${name}`, |
|
| 391 | + | placeHolder: `${own.length ? `${own.join(", ")} always may. ` : ""}Tick agents from other projects; untick to take it back.`, |
|
| 392 | + | }); |
|
| 393 | + | if (!picked) return; |
|
| 394 | + | const want = new Set(picked.map((p) => p.agent)); |
|
| 395 | + | const changes = items |
|
| 396 | + | .filter((i) => want.has(i.agent) !== i.picked) |
|
| 397 | + | .map((i) => ({ agent: i.agent, channel: name, allow: want.has(i.agent) })); |
|
| 398 | + | await grant(h, changes); |
|
| 399 | + | } |
modified src/spectra-api.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 src/views.ts +9 −3
| … | 33 unchanged lines |
||
| 34 | 34 | .filter((a) => !a.disabled) |
|
| 35 | 35 | .map((a) => { |
|
| 36 | 36 | const i = new vscode.TreeItem(a.name); |
|
| 37 | - | i.description = `${a.presence} · #${a.project}${a.status ? ` · ${a.status}` : ""}`; |
|
| 37 | + | const also = (a.channels ?? []).map((c) => `#${c}`); |
|
| 38 | + | i.description = `${a.presence} · #${a.project}${also.length ? ` +${also.join(" ")}` : ""}${a.status ? ` · ${a.status}` : ""}`; |
|
| 38 | 39 | const expires = a.delegation_expires ? `\nMay act for you until ${new Date(a.delegation_expires).toLocaleString()}` : ""; |
|
| 39 | 40 | const state = a.state && a.state !== "ok" ? `\n⚠ ${a.state}` : ""; |
|
| 40 | - | i.tooltip = `${a.display}\n${a.kind}${expires}${state}`; |
|
| 41 | + | const may = also.length ? `\nMay also post in ${also.join(", ")}` : ""; |
|
| 42 | + | i.tooltip = `${a.display}\n${a.kind}${expires}${may}${state}`; |
|
| 41 | 43 | i.iconPath = new vscode.ThemeIcon(a.state && a.state !== "ok" ? "warning" : "circle-filled", new vscode.ThemeColor(PRESENCE[a.presence] ?? "disabledForeground")); |
|
| 42 | 44 | i.contextValue = "agent"; |
|
| 43 | 45 | i.id = `agent:${a.name}`; |
|
| … | 8 unchanged lines |
||
| 52 | 54 | const rows = [...this.model.channels].filter((c) => c.kind !== "system").sort((a, b) => Number(b.name === mine) - Number(a.name === mine)); |
|
| 53 | 55 | return rows.map((c) => { |
|
| 54 | 56 | const i = new vscode.TreeItem(c.display); |
|
| 57 | + | const own = this.model.agents.filter((a) => !a.disabled && a.project === c.name).map((a) => a.name); |
|
| 58 | + | const guests = this.model.agents.filter((a) => !a.disabled && (a.channels ?? []).includes(c.name)).map((a) => a.name); |
|
| 55 | 59 | i.description = c.purpose ?? undefined; |
|
| 56 | - | i.tooltip = `${c.display}${c.purpose ? `\n${c.purpose}` : ""}\n${c.messages} message(s)`; |
|
| 60 | + | const who = c.kind === "project" ? `\nAgents who may post: ${[...own, ...guests].join(", ") || "none yet"}` : ""; |
|
| 61 | + | i.tooltip = `${c.display}${c.purpose ? `\n${c.purpose}` : ""}\n${c.messages} message(s)${who}`; |
|
| 62 | + | i.contextValue = c.kind === "project" ? "projectChannel" : "channel"; |
|
| 57 | 63 | i.iconPath = new vscode.ThemeIcon(c.kind === "direct" ? "mention" : c.name === mine ? "star-full" : "comment-discussion"); |
|
| 58 | 64 | i.command = { command: "spectra.openChannel", title: "Open", arguments: [c.name] }; |
|
| 59 | 65 | i.id = `channel:${c.name}`; |
|
| … | 44 unchanged lines |