Skip to content
Draft
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
4 changes: 4 additions & 0 deletions crates/plugin-manager/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -542,6 +542,10 @@ pub fn bundled_manifests() -> Vec<Manifest> {
"../../../src/bundled/changesets/manifest.json"
))
.expect("changesets manifest"),
serde_json::from_str(include_str!(
"../../../src/bundled/review-configuration/manifest.json"
))
.expect("review configuration manifest"),
]
}
/// New bundles must opt in to the default-on policy.
Expand Down
1 change: 1 addition & 0 deletions crates/plugin-manager/tests/management.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1384,6 +1384,7 @@ fn bundled_defaults_preserve_saved_choices_and_only_channels_is_required() {
| "buzz.drafts-and-sent"
| "buzz.changesets"
| "buzz.charts"
| "buzz.review-configuration"
);
assert_eq!(plugin.enabled, default, "{id}");
if id == "buzz.channels" {
Expand Down
31 changes: 30 additions & 1 deletion docs/buzz-help.md
Original file line number Diff line number Diff line change
Expand Up @@ -1278,6 +1278,36 @@ tab, asks **Discard changes?** first; **Keep editing** keeps them.
local installs. Upgrades preserve their previous state, including Bestie off
when no choice was saved. Enable or disable them in **Manage plugins**; saved
choices persist. A deliberate plugin-settings reset uses the current defaults.
- **Review Configuration** is optional and starts off. Enable it in **Manage
plugins** to add its own Messages sidebar entry. Choose a review channel,
register a GitHub repository, and save named lanes with reviewer agents and
instructions. Each lane supports multiple reviewers. Saved
repositories appear in a list; open one to manage its lanes. **Reload**
reads the latest saved versions. Settings live on the community relay and are
visible to channel readers; members who can post can edit them. A conflicting
edit preserves your draft: copy your changes before cancelling and reloading.
**Review Configuration** has matching **Register repository**, **Submit PR**, and **Pull requests** actions at the top, followed by **Repositories**. **Edit lane**
opens one lane; **Save lane** persists it before **Add lane** starts another.
**Back to lanes** cancels the editor; **Back to repositories** returns to the list.
Every new or edited lane requires at least one all/any routing rule matching
title text, labels, changed-file counts, or paths such as `src/**/*.tsx`.
Earlier rule-free lanes still match every PR until edited.
The **Submit PR** page has a full GitHub PR URL field and a
Submit button. It finds the repository in the selected review channel, reads
current PR details, and assigns the first configured reviewer in each matching
lane. Missing configuration or zero matching lanes sends nothing. Results show
assigned agents and their thread buttons. Choose **Pull requests** on the main page to see previous submissions in a bordered table. The table shows GitHub status (Open, Draft, Closed, or Merged); Reload refreshes it. Remind is disabled for closed, merged, loading, or unavailable PRs and rechecks GitHub before sending. The original submitter can click **Remind** to notify assigned reviewers in their current threads, or confirm **Delete** to remove a submission from the shared list; its conversations and GitHub PR remain. Deleting does not stop active reviews. Resubmitting a deleted PR applies current routing and creates new reviewer threads.
Submitting an existing PR reuses its saved reviewers and threads and mentions
them again in those threads. Incomplete initial delivery resumes from the original
browser profile. An interrupted notification retries its saved messages.
Internal **Review status** is separate from GitHub status: Needs review, Waiting on author, or Done. Reviewers report through `pr_review_status_update` using only `done` or `wait_author`; Buzz supplies the review thread. The request includes instructions fetched from that tool. Remind and resubmitting an existing PR reset the lanes to Needs review. Reviews of a PR run sequentially in the same lane threads. Incoming reports update the table; Reload catches missed updates. Reporting requires native Codex thread sessions, a compatible shared harness, or a standalone MCP process bound to that thread by its host. An unsupported host refuses the update; a chat reply alone does not record a result.
The separate Submit PR form has a **Back to Review Configuration** button. All pages remember the selected
review channel. Reviewers must be owned by the signed-in identity and already
belong to the channel. A connected agent runtime is needed for replies;
delivery alone does not mean a review started. Desktop uses signed-in GitHub
access; browser metadata reads support public PRs and may hit rate limits.
PRs above 3,000 changed files or 100 labels are unsupported. This does not post
GitHub comments, track verdicts, pin reviewer checkouts, or schedule reminders.
- A plugin's details in Manage plugins link to the Settings sections that hold
its settings: sections it adds, such as GitHub comment authors, and Decisions
API for a decision provider, such as TypeSafe. Only sections the Settings
Expand Down Expand Up @@ -1499,4 +1529,3 @@ occurs. It contains operation metadata, with no draft text or secrets. See
## Plugin account actions

Plugins can add actions to your avatar menu. Profile-related actions may appear just above Settings; other plugin actions appear below it.

9 changes: 9 additions & 0 deletions docs/plugin-authoring-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,15 @@ export function apply(ctx) {
| `subjectLift` (optional) | Lift a message image's subject | `const lift = ctx.reflect.get("subjectLift")`. `lift?.supports(source)`, then `await lift.lift(source, signal?)` resolves a transparent PNG `File` ready for `session.emoji.upload`, or rejects with a reason. `source` is `session.media(attachment.url)`. macOS 14+ only; see the plugin architecture's Subject lift contract. |
| `pluginInstaller` (optional) | Ask the owner to install another plugin, check installation status, or access retained update reviews | `requestInstall(...)` resolves `"installed" \| "updated" \| "cancelled" \| "unavailable"` and never installs silently. Status methods are described below. `updates` is an optional, host-retained capability; see [Plugin architecture](plugin-architecture.md#plugin-installer). |

Plugins can read the current documentation for a built-in or active plugin tool
with `ctx.agents.describeTool(name)`. It returns a detached
`{ name, description, inputSchema }`, or `undefined` when unavailable, and never
returns the handler. Use this to compose agent instructions from the actual tool
definition instead of maintaining a second copy. Read it when composing each
message; already sent messages keep their original text. Feature-check the method
on older hosts. Describing a tool does not grant access to call it or install it
on another agent's runtime.

The installation-status methods may be missing on older hosts even when `pluginInstaller` is available, so feature-check each method before calling it. `getInstallationStatus` returns `"installed"` when the manifest ID is in the ready catalog, `"absent"` when it is not, and `"unavailable"` while the catalog is not ready. Installed status is independent of whether the plugin is enabled, contributes UI, or came from a particular install source. A subscription reports later status changes; subscribe first, then read the current status to avoid missing a change between the read and subscription. Own the subscription with `ctx.effect`:

```js
Expand Down
46 changes: 46 additions & 0 deletions src/app/pages.integration.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ test("the app runtime exposes ready bundled pages and removes them on disable",
"buzz.focus",
"buzz.charts",
"buzz.drafts-and-sent",
"buzz.review-configuration",
"buzz.changesets",
].includes(plugin.manifest.id),
plugin.manifest.id,
Expand All @@ -103,12 +104,57 @@ test("the app runtime exposes ready bundled pages and removes them on disable",
"buzz.reminders",
"buzz.workflows",
"buzz.drafts-and-sent",
"buzz.review-configuration",
]) {
assert.equal(
services.pages.snapshot().some((page) => page.pluginId === id),
false,
);
}
await services.plugins.change("enable", "buzz.review-configuration");
await vi.waitFor(() =>
assert.ok(
services.pages
.snapshot()
.some(
(page) => page.key === "buzz.review-configuration/configuration",
),
),
);
const reviewConfiguration = services.pages
.snapshot()
.find((page) => page.pluginId === "buzz.review-configuration");
assert.equal(reviewConfiguration.title, "Review Configuration");
assert.equal(reviewConfiguration.primary, true);
assert.equal(reviewConfiguration.placement, "sidebar");
assert.match(
renderToStaticMarkup(createElement(reviewConfiguration.component)),
/Connect to a community to configure reviews/,
);
const submitPr = services.pages
.snapshot()
.find((page) => page.key === "buzz.review-configuration/submit");
assert.ok(submitPr);
assert.equal(submitPr.title, "Submit PR");
assert.equal(submitPr.primary, false);
assert.equal(submitPr.placement, "sidebar");
assert.match(
renderToStaticMarkup(createElement(submitPr.component)),
/Connect to a community to submit PRs/,
);
const allPrs = services.pages
.snapshot()
.find((page) => page.key === "buzz.review-configuration/all");
assert.ok(allPrs);
assert.equal(allPrs.title, "All PRs");
assert.equal(allPrs.primary, false);
await services.plugins.change("disable", "buzz.review-configuration");
assert.equal(
services.pages
.snapshot()
.some((page) => page.pluginId === "buzz.review-configuration"),
false,
);
await services.plugins.change("enable", "buzz.reminders");
await vi.waitFor(() =>
assert.ok(
Expand Down
2 changes: 2 additions & 0 deletions src/app/shell/presentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
NoteIcon,
GearIcon,
LightningIcon,
ListChecksIcon,
RobotIcon,
SendIcon,
} from "../../shared/design-system/icons/index";
Expand Down Expand Up @@ -58,6 +59,7 @@ const bundledIcons = new Map<string, typeof BrowserIcon>([
["buzz.reminders/reminders", AlarmIcon],
["buzz.agents/agents", RobotIcon],
["buzz.workflows/workflows", LightningIcon],
["buzz.review-configuration/configuration", ListChecksIcon],
["buzz.relay-notes/notes", NoteIcon],
]);
/** Resolve host navigation policy once, independently of icons and registrations. */
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 @@ -328,6 +328,7 @@ function setup(
session: vi.fn(async () => {}),
register: vi.fn(),
addTool: vi.fn(),
describeTool: vi.fn(),
createToolOperation: vi.fn(() => {
throw new Error("No operation provider in this fixture");
}),
Expand Down
43 changes: 42 additions & 1 deletion src/bundled/codex/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4880,7 +4880,7 @@ it("offers the tools plugins add, runs them as the agent here, and starts a new
expect(JSON.stringify(reply?.result)).toContain("created");
expect(call).toHaveBeenCalledWith(
expect.objectContaining({ pubkey }),
{ channel: "channel", root },
{ channel: "channel", root, review: { channel: "channel", root } },
{ title: "Ship it" },
);
await f.complete();
Expand Down Expand Up @@ -7377,6 +7377,47 @@ it("neither lists nor runs the retired owner-read tools, while its own reads wor
await f.complete();
});

it("supplies an immutable review thread outside tool arguments and omits it for channel-wide sessions", async () => {
const f = fixture();
const contexts: unknown[] = [];
f.added.push({
name: "inspect_review_context",
description: "Inspect host context.",
inputSchema: { type: "object", properties: {} },
call: async (_client, context) => {
contexts.push(context.review);
return "inspected";
},
});
await f.runtime.run(f.delivery("review"));
await vi.waitFor(() => expect(f.starts()).toHaveLength(1));
await f.tool("inspect_review_context", {
review: { channel: "wrong", root: "wrong" },
});
await f.runtime.run(f.delivery("follow-up"));
await vi.waitFor(() =>
expect(f.sent.some((wire) => wire.method === "turn/steer")).toBe(true),
);
await f.tool("inspect_review_context", {});
expect(contexts).toEqual([
{ channel: "channel", root },
{ channel: "channel", root },
]);
await f.complete();
await vi.waitFor(() =>
expect(f.runtime.sessions(pubkey)[0]?.status).toBe("Idle"),
);
const next = f.delivery("channel scope", "d".repeat(64));
await f.runtime.run({
...next,
config: { ...(next.config as object), scope: "channel" },
});
await vi.waitFor(() => expect(f.starts()).toHaveLength(2));
await f.tool("inspect_review_context", {}, 1);
expect(contexts[2]).toBeUndefined();
await f.complete(1);
});

it("supplies an operation proof only while the native Codex call is active", async () => {
const f = fixture();
const provider = createToolOperation("example/proposal", () => ({
Expand Down
10 changes: 10 additions & 0 deletions src/bundled/codex/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ type Saved = {
protection?: string;
};
type Active = {
readonly review?: Context["review"];
/** The server this turn runs on; steering must reach the same process. */
rpc: AppServer;
threadId?: string;
Expand Down Expand Up @@ -2324,6 +2325,7 @@ export class CodexRuntime {
// A watch or timer turn has no default destination.
const context: Context = request
? {
...(active?.review ? { review: active.review } : {}),
...(request.session ? { session: request.session } : {}),
channel: request.conversation.channelId,
...(request.toolRoot ? { root: request.toolRoot } : {}),
Expand Down Expand Up @@ -2774,6 +2776,14 @@ export class CodexRuntime {
...(adopted && lane.voice ? [lane.voice.signal] : []),
]);
const active: Active = {
...(request.conversation.root && !request.wake
? {
review: {
channel: request.conversation.channelId,
root: request.conversation.root,
},
}
: {}),
rpc: server.rpc,
...(adopted && lane.voice
? { threadId: lane.voice.loaded.threadId, turnId: adopted }
Expand Down
7 changes: 7 additions & 0 deletions src/bundled/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ import changesetsManifest from "./changesets/manifest.json";
import * as changesets from "./changesets";
import workflowsManifest from "./workflows/manifest.json";
import * as workflows from "./workflows";
import reviewConfigurationManifest from "./review-configuration/manifest.json";
import * as reviewConfiguration from "./review-configuration";
import type { BundledPlugin } from "../plugins/manager";
import linksManifest from "./links/manifest.json";
import * as links from "./links";
Expand Down Expand Up @@ -258,6 +260,11 @@ export const bundledPlugins: readonly BundledPlugin[] = [
module: workflows,
enabledByDefault: false,
},
{
manifest: { ...reviewConfigurationManifest, apiVersion: 1 },
module: reviewConfiguration,
enabledByDefault: false,
},
{
manifest: { ...moderationManifest, apiVersion: 1 },
module: moderation,
Expand Down
1 change: 1 addition & 0 deletions src/bundled/navigation-owner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ it("gives every bundled page to the app whose navigation lists it", async () =>
// Pages listed in the Messages sidebar keep Messages selected.
"buzz.drafts-and-sent/drafts-sent": messages,
"buzz.inbox/inbox": messages,
"buzz.review-configuration/configuration": messages,
// Dock apps own themselves.
"buzz.bestie/bestie": "buzz.bestie/bestie",
"buzz.reminders/reminders": "buzz.reminders/reminders",
Expand Down
112 changes: 112 additions & 0 deletions src/bundled/review-configuration/LaneReviewers.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import type { AgentChoicesSnapshot } from "../../features/agents/choices";
import { Avatar } from "../../shared/design-system/ui/Avatar";
import { Button } from "../../shared/design-system/ui/Button";
import { Select } from "../../shared/design-system/ui/Select";

/** Selection records identities only; it grants no channel access or execution authority. */
export function LaneReviewers({
lane,
reviewers,
choices,
disabled,
onChange,
}: {
lane: number;
reviewers: readonly string[];
choices: AgentChoicesSnapshot;
disabled: boolean;
onChange: (reviewers: readonly string[]) => void;
}) {
const available = choices.selectable.filter(
(agent) => !reviewers.includes(agent.pubkey),
);
return (
<div className="review-config-reviewers">
<Select
label="Agents"
variant="field"
value=""
placeholder="Add reviewer…"
disabled={disabled || reviewers.length >= 20 || available.length === 0}
groups={[
{
label: "Agents",
options: available.map((agent) => ({
value: agent.pubkey,
label: `${agent.managedName ?? agent.name} (${agent.pubkey.slice(0, 8)})`,
leading: (
<Avatar
src={agent.avatar}
alt=""
fallback={agent.managedName ?? agent.name}
shape="squircle"
size="tiny"
/>
),
})),
},
]}
onValueChange={(pubkey) => {
if (available.some((agent) => agent.pubkey === pubkey))
onChange([...reviewers, pubkey]);
}}
description="Choose one or more reviewers. The same agent can be assigned to several lanes."
/>
{reviewers.length === 0 && <p>No reviewers assigned.</p>}
{reviewers.map((pubkey) => {
const agent = choices.identities.find((item) => item.pubkey === pubkey);
const name = agent?.managedName ?? agent?.name ?? pubkey;
const selectable = choices.selectable.some(
(item) => item.pubkey === pubkey,
);
return (
<div key={pubkey} className="review-config-agent-row">
<Avatar
src={agent?.avatar}
alt={`${name} avatar`}
fallback={name}
shape="squircle"
size="small"
/>
<span title={pubkey}>
{name}
{!selectable ? " — unavailable in this session" : ""}
</span>
<Button
disabled={disabled}
aria-label={`Remove ${name} from lane ${lane}`}
onClick={() =>
onChange(reviewers.filter((key) => key !== pubkey))
}
>
Remove
</Button>
</div>
);
})}
</div>
);
}

export function ReviewerIdentity({
pubkey,
choices,
}: {
pubkey: string;
choices: AgentChoicesSnapshot;
}) {
const agent = choices.identities.find((item) => item.pubkey === pubkey);
const name = agent?.managedName ?? agent?.name ?? pubkey;
return (
<div className="review-config-agent-row">
<Avatar
src={agent?.avatar}
alt={`${name} avatar`}
fallback={name}
shape="squircle"
size="small"
/>
<span title={pubkey}>{name}</span>
</div>
);
}
Loading
Loading