Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions crates/plugin-manager/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,8 @@ pub fn bundled_manifests() -> Vec<Manifest> {
.expect("projects manifest"),
serde_json::from_str(include_str!("../../../src/bundled/agents/manifest.json"))
.expect("agents manifest"),
serde_json::from_str(include_str!("../../../src/bundled/watching/manifest.json"))
.expect("watching manifest"),
serde_json::from_str(include_str!(
"../../../src/bundled/claude-code/manifest.json"
))
Expand Down Expand Up @@ -352,6 +354,7 @@ fn enabled_by_default(id: &str) -> bool {
| "buzz.voice-notes"
| "buzz.huddles"
| "buzz.github"
| "buzz.watching"
| "buzz.pairing"
| "buzz.inbox"
| "buzz.reminders"
Expand Down
4 changes: 2 additions & 2 deletions docs/VISION_ATTENTION_SUBSCRIPTIONS.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Attention subscriptions

Proposed direction, October 23, 2026. UI ownership revised October 27, 2026.
Proposed direction, October 23, 2026. UI ownership revised October 28, 2026.

An Interest holds task instructions. Its subscriptions specify when to wake the agent and an optional delivery address. Instructions belong to the Interest, not individual subscriptions. Agents create Interests and their watches through tools.

Event watches, timers and plugin sources remain distinct trigger types with shared scheduling and delivery. Preserve existing saved objects, timer budgets and producer completion. The bundled Agents plugin owns the Agent Attention overview for inspecting, pausing and deleting existing watches.
Event watches, timers and plugin sources remain distinct trigger types with shared scheduling and delivery. Preserve existing saved objects, timer budgets and producer completion. The bundled Agent Attention UI plugin (`buzz.watching`) owns inspecting, pausing and deleting existing watches, in a Watching tab on each agent on this computer.
Comment thread
johnmatthewtennant marked this conversation as resolved.
Resolved
7 changes: 4 additions & 3 deletions docs/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,13 +57,14 @@ servers do not support these operations.

## Agent Attention

The Agent Attention tab groups the selected community's local agents' subscriptions by Interest. It shows instructions, listening and delivery destinations, enabled state and available wake history. Ask an agent to create watches through its Attention tools; pausing or removing one preserves the other saved objects. Empty Interests remain visible and can be deleted after their watches are removed.
Attention itself (the runtime, tools, storage and sources) is core and runs whether or not anything displays it. Each agent's Settings tab holds the device switch. The bundled Agent Attention UI plugin (`buzz.watching`, on by default) adds a Watching tab to the settings of every agent running on this device, whatever its type. The tab explains Attention, then groups that agent's subscriptions by Interest. It shows instructions, listening and delivery destinations, enabled state and available wake history, and suggests asks while the agent has none. Ask an agent to create watches through its Attention tools; pausing or removing one preserves the other saved objects. Empty Interests remain visible and can be deleted after their watches are removed. Turning the plugin off hides the tab only. The former standalone Agent Attention page is gone, so an old link to `buzz.agents/attention` opens as an unavailable destination.
Comment thread
johnmatthewtennant marked this conversation as resolved.
Resolved

## Agent types and providers

Harness plugins register agent types with the Agents service. An agent type owns
the defaults, migration policy, per-agent settings tabs, model discovery and run
behavior for its agents. The host appends the shared Attention tab.
behavior for its agents. The host adds the device Attention switch to Settings,
and plugins can add a tab to every local agent with `ctx.agents.registerTab`.

Attention is shared by every harness, and a harness has no attention code.
`src/buzz-mcp/session-tools.ts` exports `BUZZ_SESSION`: the Buzz tools and the
Expand Down Expand Up @@ -1048,7 +1049,7 @@ Use `watch_add` with `type: "source"`, a `provider` in `plugin/source` form and

Source output accepts the same optional classifier questions as event watches. Classification preserves output ordering and completion until the resulting delivery settles. `agents.attentionProviders()` lists active registered provider keys with their optional `description` and `configExample`, including providers from unrelated plugins. Disabled and unloaded registrations disappear from discovery.

The bundled Agents plugin owns the command producer and discovery tool. The GitHub plugin owns the GitHub PR producer. Saved provider IDs remain `attention/command` and `attention/github-pr`, and discovery marks them `builtIn: true`. The bundled Agents plugin also owns the Agent Attention overview and controls for existing watches. The GitHub source uses the same PR parser and feedback policy as `github_watch_pr`; it requires the signed-in `gh` CLI. The tool configures Attention subscriptions rather than owning a separate polling loop. Other plugins can register their own source producers through the same lifecycle. Legacy Monitor command registrations and pending wakes are not imported; recreate needed command watches in Attention.
The bundled Agents plugin owns the command producer and discovery tool. The GitHub plugin owns the GitHub PR producer. Saved provider IDs remain `attention/command` and `attention/github-pr`, and discovery marks them `builtIn: true`. The bundled Agent Attention UI plugin owns the per-agent overview and controls for existing watches. The GitHub source uses the same PR parser and feedback policy as `github_watch_pr`; it requires the signed-in `gh` CLI. The tool configures Attention subscriptions rather than owning a separate polling loop. Other plugins can register their own source producers through the same lifecycle. Legacy Monitor command registrations and pending wakes are not imported; recreate needed command watches in Attention.

## Attention delivery destinations

Expand Down
11 changes: 10 additions & 1 deletion docs/plugin-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,7 @@ falling back to the sidebar without failing activation. Older hosts ignore place
and continue to show primary pages in their sidebar.

Harness plugins register an agent type with `ctx.agents.register`. Per-agent
configuration belongs in its `tabs`; the Agents host adds Attention. An optional
configuration belongs in its `tabs`; the Agents host adds the device Attention switch. An optional
`provider` contribution exposes a stable readiness snapshot, subscription,
read-only `check()` and `DialogBody`. The Agents page supplies the Providers
health submenu plus provider title, registered icon, modal framing, dismissal,
Expand All @@ -291,6 +291,15 @@ active registered types appear there; plugin activation remains owned by
Settings → Plugins → Manage plugins. Provider defaults are versioned device preferences, not
agent-record migrations; blank per-agent values inherit them.

A plugin, bundled or installed, can add a tab to the settings of every agent
running on this device, whatever its type, with
`ctx.agents.registerTab({ id, title, component })`. The component receives
`{ agent }`; it reads and saves through the plugin's own `ctx.agents`. The tab
follows Activity and precedes History and Settings, in the agent's Build view and
its profile panel, and is not offered for hosted or elsewhere agents. It is
removed when the plugin is turned off or its scope ends. The bundled Agent
Attention UI plugin uses it.

A plugin, bundled or installed, can add a tool to every agent session in the
app with `ctx.agents.addTool({ name, description, inputSchema, call })`. `call`
gets the agent's Buzz client and where it is working, so the tool acts as the
Expand Down
37 changes: 27 additions & 10 deletions src/app/entity-navigation.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,15 @@ vi.mock("../bundled", async () => ({
manifest: { id: "buzz.agents", name: "Agents", apiVersion: 1 },
module: await import("../bundled/agents"),
},
{
manifest: {
id: "buzz.watching",
name: "Agent Attention UI",
apiVersion: 1,
},
module: await import("../bundled/watching"),
enabledByDefault: true,
},
{
manifest: { id: "buzz.inbox", name: "Inbox", apiVersion: 1 },
module: await import("../bundled/inbox"),
Expand Down Expand Up @@ -350,18 +359,29 @@ it("lists active primary pages in their sidebar and header placements", async ()
).not.toBeInTheDocument();
expect(screen.getByRole("tab", { name: "Agents" })).toBeInTheDocument();

const attentionTab = screen.getByRole("tab", { name: "Agent Attention" });
await userEvent.click(attentionTab);
expect(
await screen.findByRole("region", { name: "Agent Attention" }),
).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "New watch" })).toBeNull();

// The fixture page is active and searchable but never a sidebar row.
expect(current.pages.snapshot().map((page) => page.key)).toContain(
"fixture.notes/notes",
);

// The Watching tab is on by default, and attention keeps running without it.
const providers = () =>
current.agents.attentionProviders().map(({ provider }) => provider);
await waitFor(() =>
expect(current.agents.addedTabs().map((tab) => tab.key)).toEqual([
"buzz.watching/watching",
]),
);
await act(() => current.plugins.change("disable", "buzz.watching"));
await waitFor(() => expect(current.agents.addedTabs()).toEqual([]));
expect(providers()).toContain("attention/command");
await act(() => current.plugins.change("enable", "buzz.watching"));
await waitFor(() =>
expect(current.agents.addedTabs().map((tab) => tab.key)).toEqual([
"buzz.watching/watching",
]),
);

await act(() => current.plugins.change("disable", "buzz.agents"));
await waitFor(() =>
expect(
Expand All @@ -372,9 +392,6 @@ it("lists active primary pages in their sidebar and header placements", async ()
);

expect(screen.queryByRole("tab", { name: "Agents" })).not.toBeInTheDocument();
expect(
screen.queryByRole("tab", { name: "Agent Attention" }),
).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Search Buzz" }));
const search = await screen.findByRole("dialog", { name: "Search Buzz" });
expect(
Expand Down
20 changes: 6 additions & 14 deletions src/app/pages.integration.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ test("the app runtime exposes ready bundled pages and removes them on disable",
);
assert.notEqual(services.accountActions.snapshot()[0], firstFeedback);
await settle();
assert.equal(services.pages.snapshot().length, 7);
assert.equal(services.pages.snapshot().length, 6);
const { bundledPlugins } = await vite.ssrLoadModule(
"/src/bundled/index.ts",
);
Expand Down Expand Up @@ -101,16 +101,9 @@ test("the app runtime exposes ready bundled pages and removes them on disable",
.find((page) => page.key === "buzz.channels/channels");
assert.equal(messages.primary, true);
assert.equal(messages.placement, "topbar");
const attention = services.pages
.snapshot()
.find((page) => page.key === "buzz.agents/attention");
assert.equal(attention.title, "Agent Attention");
assert.equal(attention.layout, "workspace");
assert.equal(attention.placement, "topbar");
assert.equal(attention.primary, true);
assert.match(
renderToStaticMarkup(createElement(attention.component)),
/Agent Attention/,
assert.equal(
services.pages.snapshot().some((p) => p.key === "buzz.agents/attention"),
false,
);
const inbox = services.pages
.snapshot()
Expand Down Expand Up @@ -341,7 +334,7 @@ test("the app runtime exposes ready bundled pages and removes them on disable",
services.pages.snapshot().some((page) => page.pluginId === "buzz.bestie"),
false,
);
assert.equal(services.pages.snapshot().length, 7);
assert.equal(services.pages.snapshot().length, 6);
await services.plugins.change("enable", "buzz.bestie");
// Management completion is not activation completion; Cordis still owns import/disposal barriers.
await vi.waitFor(() =>
Expand Down Expand Up @@ -420,7 +413,7 @@ test("the app runtime exposes ready bundled pages and removes them on disable",
const projects = services.pages
.snapshot()
.find((page) => page.pluginId === "buzz.repos");
assert.equal(services.pages.snapshot().length, 7);
assert.equal(services.pages.snapshot().length, 6);
assert.equal(projects.pluginId, "buzz.repos");
assert.equal(projects.id, "repos");
assert.equal(projects.title, "Repository");
Expand Down Expand Up @@ -457,7 +450,6 @@ test("the app runtime exposes ready bundled pages and removes them on disable",
"buzz.channels",
"buzz.reminders",
"buzz.agents",
"buzz.agents",
"buzz.inbox",
"buzz.bestie",
],
Expand Down
15 changes: 3 additions & 12 deletions src/app/shell/presentation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import {
BellIcon,
BrowserIcon,
ChatsCircleIcon,
EyeIcon,
RobotIcon,
} from "../../shared/design-system/icons/index";
import {
Expand Down Expand Up @@ -35,18 +34,11 @@ const messages = page("buzz.channels/channels", "Channels");
const inbox = page("buzz.inbox/inbox", "Inbox");
const bestie = page("buzz.bestie/bestie", "Bestie");
const projects = page("fixture.projects/projects", "Projects");
const attention = page("buzz.agents/attention", "Agent Attention");

test("bundled page order ignores activation order without mutating the registry", () => {
const input = Object.freeze([attention, projects, bestie, messages, inbox]);
expect(orderPages(input)).toEqual([
messages,
inbox,
bestie,
projects,
attention,
]);
expect(input).toEqual([attention, projects, bestie, messages, inbox]);
const input = Object.freeze([projects, bestie, messages, inbox]);
expect(orderPages(input)).toEqual([messages, inbox, bestie, projects]);
expect(input).toEqual([projects, bestie, messages, inbox]);
expect(orderPages([messages, projects])).toEqual([messages, projects]);
expect(orderPages([projects])).toEqual([projects]);
expect(orderPages([])).toEqual([]);
Expand Down Expand Up @@ -87,7 +79,6 @@ test("other pages sort by label then full key and cannot claim bundled slots", (

test("bundled pages get their own icons and Bestie's row shows its artwork", () => {
expect(pagePresentation(inbox).icon).toBe(BellIcon);
expect(pagePresentation(attention).icon).toBe(EyeIcon);
expect(pagePresentation(page("buzz.agents/agents", "Agents")).icon).toBe(
RobotIcon,
);
Expand Down
3 changes: 0 additions & 3 deletions src/app/shell/presentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import {
BrowserIcon,
ChartBarIcon,
ChatsCircleIcon,
EyeIcon,
NoteIcon,
GearIcon,
LightningIcon,
Expand All @@ -30,7 +29,6 @@ const bundledOrder = [
];
export function orderPages(pages: readonly RegisteredPage[]) {
const rank = (page: RegisteredPage) => {
if (page.key === "buzz.agents/attention") return bundledOrder.length + 1;
const slot = bundledOrder.indexOf(page.key);
return slot === -1 ? bundledOrder.length : slot;
};
Expand All @@ -50,7 +48,6 @@ const bundledIcons = new Map<string, typeof BrowserIcon>([
["buzz.reminders/reminders", AlarmIcon],
["buzz.bestie/bestie", BestieIcon],
["buzz.agents/agents", RobotIcon],
["buzz.agents/attention", EyeIcon],
["buzz.workflows/workflows", LightningIcon],
["buzz.relay-notes/notes", NoteIcon],
["buzz.usage/usage", ChartBarIcon],
Expand Down
2 changes: 2 additions & 0 deletions src/bundled/agents/AgentDetails.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ it("shows every affected community with unsaved shared settings and submits the
},
snapshot: () => ({ agents: [agent] }),
configHistory: () => undefined,
addedTabs: () => [],
registerTab: () => {},
memberships: () => [
{ key: agent.key, origin: "https://current.example", connected: true },
{ key: "other", origin: "wss://other.example", connected: false },
Expand Down
51 changes: 51 additions & 0 deletions src/bundled/agents/AgentTabs.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,8 @@ function editingFixture(split: boolean, save = vi.fn(async () => {})) {
classifier: () => "unavailable",
memberships: () => [],
configHistory: () => undefined,
addedTabs: () => [],
registerTab: () => {},
} as unknown as Agents,
agent,
type,
Expand Down Expand Up @@ -385,6 +387,8 @@ it.each(["here", "hosted"] as const)(
subscribe: () => () => {},
memberships: () => [],
configHistory: () => undefined,
addedTabs: () => [],
registerTab: () => {},
} as unknown as Agents
}
agent={agent}
Expand Down Expand Up @@ -478,6 +482,8 @@ it("shows History for an agent whose config syncs", () => {
{
save: vi.fn(),
configHistory: () => history,
addedTabs: () => [],
registerTab: () => {},
snapshot: () => ({ agents: [agent] }),
subscribe: () => () => {},
} as unknown as Agents
Expand All @@ -493,3 +499,48 @@ it("shows History for an agent whose config syncs", () => {
);
expect(screen.getByRole("tab", { name: "History" })).toBeInTheDocument();
});

it("places plugin tabs after Activity and before Settings", () => {
const { props } = editingFixture(true);
const Page = () => <p />;
render(
<AgentTabs
{...props}
type={
{
tabs: [
...(props.type as unknown as { tabs: unknown[] }).tabs,
{
id: "activity",
title: "Activity",
role: "activity",
component: Page,
},
],
} as unknown as RegisteredAgentType
}
agents={
{
...props.agents,
addedTabs: () => [
{
id: "watching",
key: "buzz.watching/watching",
pluginId: "buzz.watching",
revision: "1",
title: "Watching",
component: Page,
},
],
} as unknown as Agents
}
/>,
);
expect(screen.getAllByRole("tab").map((tab) => tab.textContent)).toEqual([
"Identity",
"Memory",
"Activity",
"Watching",
"Settings",
]);
});
1 change: 1 addition & 0 deletions src/bundled/agents/AgentTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export function AgentTabs(props: {
...supplied.filter((section) => section.value.startsWith("agent:type:")),
...supplied.filter((section) => section.value === "agent:memory"),
...supplied.filter((section) => section.value === "agent:activity"),
...supplied.filter((section) => section.value.startsWith("agent:added:")),
...supplied.filter((section) => section.value === "agent:history"),
{
value: "agent:management",
Expand Down
2 changes: 2 additions & 0 deletions src/bundled/agents/AgentsPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,8 @@ function setup(
registerHook: vi.fn(),
dispatchHook: vi.fn(async () => undefined),
configHistory: () => undefined,
addedTabs: () => [],
registerTab: () => {},
} satisfies Agents;
const disconnected = { status: "disconnected", generation: 0 };
const relay = {
Expand Down
13 changes: 0 additions & 13 deletions src/bundled/agents/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { createPage } from "./attention/Page";
import type { PluginModule } from "../../plugins/api";
import { registerAttention } from "../../features/agents/attention-command";
import { registerDiscovery } from "../../features/agents/attention-discovery";
Expand Down Expand Up @@ -35,18 +34,6 @@ export const apply: PluginModule["apply"] = (ctx) => {
/>
),
});
ctx.pages.register({
id: "attention",
title: "Agent Attention",
placement: "topbar",
layout: "workspace",
primary: true,
component: createPage({
agents,
relay,
navigation: navigator,
}),
});
ctx.pages.register({
id: "agents",
title: "Agents",
Expand Down
2 changes: 2 additions & 0 deletions src/bundled/claude-code/tabs.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1172,6 +1172,8 @@ it.each(["Identity", "Settings"])(
const agents = {
save,
configHistory: () => undefined,
addedTabs: () => [],
registerTab: () => {},
memberships: () => [],
subscribe: () => () => {},
} as unknown as Agents;
Expand Down
Loading
Loading