Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
52c3077
feat(github): limit whose PR feedback wakes agents
johnmatthewtennant Oct 27, 2026
e11d459
fix(github): keep membership lookups from holding feedback forever
johnmatthewtennant Oct 27, 2026
2186cee
test(settings): expect the GitHub comment authors card
johnmatthewtennant Oct 27, 2026
1fad9fe
feat(github): decide PR feedback by ordered allow and deny rules
johnmatthewtennant Oct 28, 2026
3f1e0ef
refactor(github): drop converting saved author lists to rules
johnmatthewtennant Oct 28, 2026
bce98da
fix(github): judge waiting feedback by current rules and fresh member…
johnmatthewtennant Oct 29, 2026
0a1f62c
style(github): lay out the comment author rules as a Settings form
johnmatthewtennant Oct 29, 2026
067d37b
fix(github): hold waiting feedback whose author it cannot check
johnmatthewtennant Oct 29, 2026
ecb5490
feat(github): choose what feedback no rule matches gets
johnmatthewtennant Oct 29, 2026
4beed20
test(github): cover rules saved in Settings changing a running watch
johnmatthewtennant Oct 29, 2026
31cdefb
docs(github): say body rules see what a wake quotes
johnmatthewtennant Oct 29, 2026
a604e11
fix(github): recheck new feedback against rules saved during a lookup
johnmatthewtennant Oct 29, 2026
e0d5bef
fix(github): match body rules against the text a wake quotes
johnmatthewtennant Oct 29, 2026
4ce1145
fix(github): leave feedback admission to the final recheck
johnmatthewtennant Oct 29, 2026
b7bbac8
fix(github): stop membership fallback when a watch stops
johnmatthewtennant Oct 29, 2026
50aa164
fix(github): count failed membership reads once a poll
johnmatthewtennant Oct 29, 2026
e5ba22c
fix(github): match rule patterns in linear time
johnmatthewtennant Oct 29, 2026
6490dfc
fix(github): show why rules are not rules on the Rules field
johnmatthewtennant Oct 29, 2026
460f75b
docs(github): allow Copilot by its exact bot login
johnmatthewtennant Oct 29, 2026
d570324
fix(github): match an escaped surrogate pair as one character
johnmatthewtennant Oct 29, 2026
88d72e4
fix(github): reject rules naming more organizations than a read can ask
johnmatthewtennant Oct 29, 2026
9b1965e
fix(github): hold feedback while GitHub answers no membership read
johnmatthewtennant Oct 29, 2026
1829b5f
fix(github): match a legacy octal escape as one character
johnmatthewtennant Oct 29, 2026
5bad688
fix(github): hold feedback while saved rules no longer parse
johnmatthewtennant Oct 29, 2026
69ffeb4
fix(github): read new feedback before rereading what is held
johnmatthewtennant Oct 29, 2026
434b647
Refuse pattern escapes that mean something else alone
johnmatthewtennant Oct 29, 2026
069617e
Keep held feedback held across limited reads
johnmatthewtennant Oct 29, 2026
99676a2
Count membership retries from the requests sent
johnmatthewtennant Oct 29, 2026
d91520d
feat(attention): let sources present their own watches
johnmatthewtennant Oct 28, 2026
98458bb
Merge main: decide watch feedback from the pull request store
johnmatthewtennant Oct 29, 2026
56e11f8
Merge 1746: watches decide from the pull request store
johnmatthewtennant Oct 29, 2026
0bffb49
fix(github): read memberships apart from deciding a watch
johnmatthewtennant Oct 29, 2026
ee226db
fix(github): let a watch's first read learn feedback held elsewhere
johnmatthewtennant Oct 29, 2026
a69d59c
Merge 1746: read memberships apart from deciding
johnmatthewtennant Oct 29, 2026
411e14e
Merge main into attention-source-presentation
johnmatthewtennant Oct 30, 2026
f4bab99
test(github): cover the PR source presenter registration
johnmatthewtennant Oct 30, 2026
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
2 changes: 2 additions & 0 deletions docs/plugin-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,8 @@ plugin is replaced, so ask for it when acting rather than keeping it. Like any t

Plugins can discover active Attention source registrations through `ctx.agents.attentionProviders()`. Each entry includes its `provider` key and optional `description` and JSON-object `configExample` supplied to `addAttentionSource`. The catalog omits executable callbacks and inactive registrations; discovery tools belong in external plugins.

A source may also `present` its watches on the Agent Attention page. Given a watch and its display name, it returns the watch's kind and, optionally, an icon, a target with a subtitle, an external link, and a details component that replaces the name in the expanded row. When a provider has no `present`, or it throws, Buzz shows the watch by its name. The GitHub plugin presents pull request watches this way, linking to its comment author settings.

Each Attention source `run` receives `spawn(processId, options)` for commands using that watch's owning agent. The host binds agent identity, watch destination and run lifetime; plugins cannot select another agent or obtain the raw authenticated connection. Declare the process in `host.processes` and request `host.mcp`, plus `host.files` for tools reading local paths. Options supply arguments, working directory, environment and output callbacks.

Buzz revokes command access on return, failure, cancellation, agent stop, provider unload or community reconnection, and closes the process and its descendants. Successfully emitted output remains queued after a producer returns. The key and MCP connection stay in Buzz; commands retain the app user's operating-system privileges and can use the agent's tools while their run remains active.
Expand Down
1 change: 1 addition & 0 deletions src/bundled/agents/AgentsPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,7 @@ function setup(
for (const listener of listeners) listener();
}),
attentionProviders: () => [],
presentWatch: () => undefined,
addAttentionSource: vi.fn(),
storage: localStore(),
resumeInterrupted: vi.fn(),
Expand Down
5 changes: 4 additions & 1 deletion src/bundled/github/SoloPull.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,10 @@ it("gives agents a github_link_pr tool for the Solo chat they work in", async ()
key: "a".repeat(64),
pubkey: "a".repeat(64),
} as BuzzClient;
expect(inject).toHaveBeenCalledWith(["agents", "host"], expect.any(Function));
expect(inject).toHaveBeenCalledWith(
["agents", "host", "navigation"],
expect.any(Function),
);
expect(tool?.name).toBe("github_link_pr");
await expect(
tool?.call(client, { channel: chat }, { url: "https://github.com/block" }),
Expand Down
51 changes: 51 additions & 0 deletions src/bundled/github/attention.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// @vitest-environment jsdom
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, expect, it, vi } from "vitest";
import { GitPullRequestIcon } from "../../shared/design-system/icons";
import { presentPull } from "./attention";

afterEach(cleanup);

const watch = (pr: unknown) => ({
type: "source" as const,
interest_id: "release",
enabled: true,
provider: "attention/github-pr",
config: { pr },
});

it("names the pull request and opens whose feedback wakes agents", async () => {
const open = vi.fn();
const shown = presentPull({ open })(
watch("https://github.com/block/buzz-app/pull/1578"),
"buzz-app#1578 Release checks",
);
expect(shown).toMatchObject({
type: "GitHub PR",
icon: GitPullRequestIcon,
target: "buzz-app#1578",
subtitle: "Release checks",
link: {
href: "https://github.com/block/buzz-app/pull/1578",
title: "Open on GitHub",
},
});
const Details = shown.details as NonNullable<typeof shown.details>;
render(<Details />);
await userEvent.click(
screen.getByRole("button", { name: "Choose who can wake agents" }),
);
expect(open).toHaveBeenCalledWith({
version: 1,
kind: "settings",
section: "buzz.github/authors",
});
});

it("leaves a watch without a pull request URL to its name", () => {
expect(presentPull({ open: vi.fn() })(watch(42), "Release")).toEqual({
type: "GitHub PR",
icon: GitPullRequestIcon,
});
});
42 changes: 42 additions & 0 deletions src/bundled/github/attention.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// How a pull request watch looks in Agent Attention.
import { GitPullRequestIcon } from "../../shared/design-system/icons";
import { Button } from "../../shared/design-system/ui/Button";
import type { AttentionSource } from "../../features/agents/attention-sources";
import type { Navigation } from "../../features/navigation/controller";
import { pullOf } from "./watch";

/** Names the pull request a watch follows, and opens the settings for whose
* feedback wakes agents. */
export function presentPull(
navigation: Pick<Navigation, "open">,
): NonNullable<AttentionSource["present"]> {
const Authors = () => (
<Button
variant="link"
size="inline"
type="button"
onClick={() =>
void navigation.open({
version: 1,
kind: "settings",
section: "buzz.github/authors",
})
}
>
Choose who can wake agents
</Button>
);
return (watch, title) => {
const pull =
typeof watch.config.pr === "string" ? pullOf(watch.config.pr) : undefined;
if (!pull) return { type: "GitHub PR", icon: GitPullRequestIcon };
return {
type: "GitHub PR",
icon: GitPullRequestIcon,
target: `${pull.name}#${pull.number}`,
subtitle: title.replace(/^(?:PR\s+\d+:?\s*|[^ ]*#\d+\s*)/i, ""),
link: { href: pull.url, title: "Open on GitHub" },
details: Authors,
};
};
}
5 changes: 4 additions & 1 deletion src/bundled/github/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import { stateIcons } from "./pull-state-view";
import { PullLink } from "./PullLink";
import { SoloPullIcon, SoloPullLink, SoloPullMenu } from "./SoloPull";
import { watchPulls } from "./watcher";
import { presentPull } from "./attention";
import { SubmitReview } from "./SubmitReview";
import { AuthorSettings } from "./AuthorSettings";

Expand Down Expand Up @@ -184,7 +185,9 @@ export const apply: PluginModule["apply"] = (ctx) => {
});
addPullStateTool(ctx.agents, states);
// Agents can watch pull requests where the app runs them.
ctx.inject(["agents", "host"], (inner) => watchPulls(inner, states, gh));
ctx.inject(["agents", "host", "navigation"], (scope) =>
watchPulls(scope, states, gh, presentPull(scope.navigation)),
);
// Whose comments and reviews wake them.
ctx.inject(["settingsCards"], (scope) => {
scope.settingsCards.register({
Expand Down
18 changes: 13 additions & 5 deletions src/bundled/github/watcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,10 @@ const asks = (app: ReturnType<typeof setup>, login: string) =>
([query]) => membershipRead(query) && query.includes(`login:"${login}"`),
).length;

function setup(initial: Pull | null = answer()) {
function setup(
initial: Pull | null = answer(),
present?: AttentionSource["present"],
) {
let gh = initial;
let texts = true;
let members: readonly string[] | null = [];
Expand Down Expand Up @@ -286,7 +289,7 @@ function setup(initial: Pull | null = answer()) {
},
effect: (fn: () => () => void) => disposers.push(fn()),
} as unknown as Context;
watchPulls(ctx, states, github as unknown as GitHubCli);
watchPulls(ctx, states, github as unknown as GitHubCli, present);
const tool = tools[0] as AddedTool;
const call = (
args: Record<string, unknown>,
Expand Down Expand Up @@ -355,10 +358,15 @@ describe("github_watch_pr", () => {
});
afterEach(() => vi.useRealTimers());

it("registers PR watching as an Attention source", () => {
const app = setup();
it("registers PR watching as an Attention source with its presenter", () => {
const present = vi.fn();
const app = setup(answer(), present);
expect(app.addAttentionSource).toHaveBeenCalledWith(
expect.objectContaining({ id: "github-pr", run: expect.any(Function) }),
expect.objectContaining({
id: "github-pr",
run: expect.any(Function),
present,
}),
);
app.dispose();
});
Expand Down
3 changes: 3 additions & 0 deletions src/bundled/github/watcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// and delivery.
import type { Context } from "@deepseek-ai/cordis";
import { callAttentionTool } from "../../buzz-mcp/attention";
import type { AttentionSource } from "../../features/agents/attention-sources";
import type { GitHubCli } from "./gh";
import type { Feedback, PullStates } from "./pull-states";
import {
Expand Down Expand Up @@ -91,6 +92,7 @@ export function watchPulls(
ctx: Context,
states: PullStates,
gh: GitHubCli | undefined,
present?: AttentionSource["present"],
) {
localStorage.removeItem(RETIRED_STORAGE_KEY);
const cursors = load();
Expand Down Expand Up @@ -227,6 +229,7 @@ export function watchPulls(
id: "github-pr",
description: "Follow GitHub pull request feedback and CI",
configExample: { pr: "https://github.com/OWNER/REPO/pull/NUMBER" },
...(present && { present }),
Comment thread
johnmatthewtennant marked this conversation as resolved.
async run({ id, agent, watch: source, signal, emit }) {
const pull =
typeof source.config.pr === "string"
Expand Down
30 changes: 28 additions & 2 deletions src/bundled/watching/Tab.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ it("shows one agent's watches, reacts to changes and surfaces save errors", asyn
const ctx = {
react: React,
agents: {
presentWatch: () => undefined,
snapshot: () => snapshot,
subscribe: (listener: () => void) => {
changed = listener;
Expand Down Expand Up @@ -271,6 +272,7 @@ it.each(["source", "event", "timer"] as const)(
const ctx = {
react: React,
agents: {
presentWatch: () => undefined,
snapshot: () => snapshot,
subscribe: (listener: () => void) => {
changed = listener;
Expand Down Expand Up @@ -399,7 +401,12 @@ it("keeps subscription controls disabled and the tab open while saving, and pres
const save = vi.fn(() => held);
const ctx = {
react: React,
agents: { snapshot: () => snapshot, subscribe: () => () => {}, save },
agents: {
presentWatch: () => undefined,
snapshot: () => snapshot,
subscribe: () => () => {},
save,
},
relay: { snapshot: () => disconnected, subscribe: () => () => {} },
} as unknown as Context;
const report = vi.fn();
Expand Down Expand Up @@ -737,6 +744,19 @@ it.each([false, true])(
const ctx = {
react: React,
agents: {
presentWatch: (watch: { provider: string }) =>
watch.provider === "attention/github-pr"
? {
type: "GitHub PR",
target: "buzz-app#1578",
subtitle: "Release checks",
link: {
href: "https://github.com/block/buzz-app/pull/1578",
title: "Open on GitHub",
},
details: () => React.createElement("p", null, "Who wakes it"),
}
: undefined,
snapshot: () => snapshot,
subscribe: (listener: () => void) => {
changed = listener;
Expand Down Expand Up @@ -778,6 +798,7 @@ it.each([false, true])(
);
expect(screen.getByRole("heading", { name: "Recent events" })).toBeTruthy();
expect(screen.getByText("CI completed: all checks passed")).toBeTruthy();
expect(screen.getByText("Who wakes it")).toBeTruthy();
fireEvent.click(
screen.getByRole("button", { name: "Details for Discussion" }),
);
Expand Down Expand Up @@ -861,7 +882,12 @@ it("keeps overview and destination navigation in the selected community", async
const open = vi.fn(),
save = vi.fn();
const ctx = {
agents: { snapshot: () => snapshot, subscribe: () => () => {}, save },
agents: {
presentWatch: () => undefined,
snapshot: () => snapshot,
subscribe: () => () => {},
save,
},
relay: {
snapshot: () => connection,
subscribe: (listener: () => void) => {
Expand Down
Loading
Loading