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
17 changes: 16 additions & 1 deletion docs/plugin-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,21 @@ 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.

Code that agents need while they run, such as an agent type's runtime, tools,
wakes and Attention sources, belongs in the plugin's optional `agent` export:
`export const agent = { inject, apply(ctx) }`, with its own `inject`. `apply`
stays the plugin's UI. Both parts now start in the same page, under the same
plugin identity; the plugin is active once both are, and fails if either does.
This separation lets agents later run apart from the UI page.

A harness plugin registers its type from its `agent` entry and presents it
from `apply`: `ctx.agents.present(typeId, (exposed) => ({ icon, Peek, Create,
CreateModel, tabs, provider: { DialogBody, DefaultControl } }))`. The type's
readers see one type with both halves. `exposed` is the type's `expose`: the
handles its UI reaches of the runtime. Each is JSON, a store (`snapshot` and
`subscribe`) or a call returning a promise, so it can later cross to another
page. Claude is the first harness split this way.

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
Expand Down Expand Up @@ -353,7 +368,7 @@ 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.
A plugin's UI entry may also present its source's watches on the Agent Attention page with `ctx.agents.presentSource(sourceId, present)`. The source runs in the agent entry, but how its watches look is a screen concern, so it stays with the screens. Given a watch and its display name, `present` 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 source has no presentation, 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.

Expand Down
1 change: 1 addition & 0 deletions src/app/pages.integration.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ test("the app runtime exposes ready bundled pages and removes them on disable",
value: {
getItem: (key) => values.get(key) ?? null,
setItem: (key, value) => values.set(key, value),
removeItem: (key) => values.delete(key),
},
});
let services;
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 @@ -292,6 +292,7 @@ function setup(
session: vi.fn(async () => {}),
register: vi.fn(),
addTool: vi.fn(),
present: vi.fn(),
tools: () => [],
create: vi.fn(async () => {
// `shown: false`: it was made for a community no longer selected.
Expand Down Expand Up @@ -373,6 +374,7 @@ function setup(
}),
attentionProviders: () => [],
presentWatch: () => undefined,
presentSource: () => {},
addAttentionSource: vi.fn(),
storage: localStore(),
resumeInterrupted: vi.fn(),
Expand Down
2 changes: 1 addition & 1 deletion src/bundled/agents/ProviderDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ function ManagedProviderDialog({
finalFocus={() => finalFocus.current}
>
<ProviderDialogFooter value={footer}>
<Body onClose={onClose} />
{Body && <Body onClose={onClose} />}
</ProviderDialogFooter>
</Dialog>
);
Expand Down
28 changes: 8 additions & 20 deletions src/bundled/claude-code/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
// @vitest-environment jsdom
import { afterEach, expect, it, vi } from "vitest";
import type { AgentType } from "../../features/agents/service";
import { ClaudeRuntime } from "./runtime";
import { apply } from "./index";
import * as claude from "./index";
import { appliedType } from "../../features/agents/plugin-testing";

vi.mock("@tauri-apps/api/core", () => ({ isTauri: () => true }));
afterEach(() => vi.restoreAllMocks());
Expand All @@ -26,18 +26,12 @@ it("observes saved sessions with the live status of running ones", () => {
{ key: "room/r", busy: true },
{ key: "dm", busy: false },
]);
let type: AgentType | undefined;
apply({
const type = appliedType(claude, {
pluginOwner: { id: "buzz.claude-code", revision: "r1" },
host: { request: vi.fn() },
agents: {
register: (registered: AgentType) => {
type = registered;
},
relay: () => undefined,
},
agents: { relay: () => undefined },
effect: vi.fn(),
} as never);
});
expect(type?.sessions?.list(agent)).toEqual([
{ channelId: "room", root: "r", at: 3, status: "Working" },
{ channelId: "dm", at: 2, status: "Idle" },
Expand All @@ -48,18 +42,12 @@ it("observes saved sessions with the live status of running ones", () => {

it("shares a Claude agent's model as the claude runtime and imports it back", () => {
vi.spyOn(navigator, "platform", "get").mockReturnValue("MacIntel");
let type: AgentType | undefined;
apply({
const type = appliedType(claude, {
pluginOwner: { id: "buzz.claude-code", revision: "r1" },
host: { request: vi.fn() },
agents: {
register: (registered: AgentType) => {
type = registered;
},
relay: () => undefined,
},
agents: { relay: () => undefined },
effect: vi.fn(),
} as never);
});
const shared = type?.exportShared?.({ model: "opus", workspace: "/private" });
expect(shared).toEqual({ runtime: "claude", model: "opus" });
expect(type?.importShared?.(shared ?? { model: "" })).toMatchObject({
Expand Down
110 changes: 64 additions & 46 deletions src/bundled/claude-code/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,65 @@ export { TYPE_ID };
/** How often Buzz's copy of Claude looks for a new release. */
const UPDATE_MS = 6 * 60 * 60_000;

export const inject = ["agents", "host"];
// Only the desktop app can start `claude`, and setup and file uploads run
// `bash` and `base64`, which stock Windows lacks; no unusable type elsewhere.
const supported = () => isTauri() && /Mac|Linux/i.test(navigator.platform);

/** What the type's UI reaches of its runtime. */
export type ClaudeExposed = Parameters<typeof createTabs>[0];

export const inject = ["agents"];
export const apply: PluginModule["apply"] = (ctx) => {
// Only the desktop app can start `claude`, and setup and file uploads run
// `bash` and `base64`, which stock Windows lacks; no unusable type elsewhere.
if (!isTauri() || !/Mac|Linux/i.test(navigator.platform)) return;
if (!supported()) return;
ctx.agents.present<ClaudeExposed, Config>(TYPE_ID, (exposed) => {
const {
Create,
CreateModel,
DefaultControl,
ProviderDialogBody,
SettingsTab,
ActivityTab,
} = createTabs(exposed);
return {
icon: ClaudeLogoIcon,
Create,
CreateModel,
importShared: (source) =>
source.runtime === "claude" &&
(!source.provider || source.provider === "anthropic")
? { ...DEFAULT_CONFIG, model: source.model }
: undefined,
exportShared: (saved) => ({
runtime: "claude",
model: config(saved).model,
}),
provider: { DialogBody: ProviderDialogBody, DefaultControl },
tabs: [
{
id: "settings",
title: "Settings",
role: "settings",
splitSettings: true,
component: SettingsTab,
},
{
id: "activity",
title: "Activity",
role: "activity",
component: ActivityTab,
},
],
};
});
};

// The runtime, which runs where the agents do.
export const agent: NonNullable<PluginModule["agent"]> = {
inject: ["agents", "host"],
apply: (ctx) => applyAgent(ctx),
};
function applyAgent(ctx: Parameters<PluginModule["apply"]>[0]) {
if (!supported()) return;
const { agents, host } = ctx;
const typeKey = `${ctx.pluginOwner?.id ?? "buzz.claude-code"}/${TYPE_ID}`;
const spawn = host.spawn && withScript(host.spawn.bind(host));
Expand Down Expand Up @@ -88,37 +142,18 @@ export const apply: PluginModule["apply"] = (ctx) => {
});
}

const {
Create,
CreateModel,
DefaultControl,
ProviderDialogBody,
SettingsTab,
ActivityTab,
} = createTabs({
setup,
defaults: providerDefaults,
runtime,
});
agents.register<Config>({
id: TYPE_ID,
hooks: { events: CLAUDE_HOOK_EVENTS },
title: "Claude",
icon: ClaudeLogoIcon,
description:
"Claude on this computer. Each thread it is mentioned in is one continuing session.",
defaults: () => ({ config: DEFAULT_CONFIG }),
Create,
CreateModel,
importShared: (source) =>
source.runtime === "claude" &&
(!source.provider || source.provider === "anthropic")
? { ...DEFAULT_CONFIG, model: source.model }
: undefined,
exportShared: (saved) => ({
runtime: "claude",
model: config(saved).model,
}),
expose: {
setup,
defaults: providerDefaults,
runtime,
} satisfies ClaudeExposed,
adopt: (carry, saved) => ({
...saved,
instructions: carry.instructions ?? saved.instructions,
Expand Down Expand Up @@ -171,8 +206,6 @@ export const apply: PluginModule["apply"] = (ctx) => {
? () => void setup.run("login")
: undefined;
},
DialogBody: ProviderDialogBody,
DefaultControl,
},
sessions: {
list: (key) => {
Expand All @@ -195,22 +228,7 @@ export const apply: PluginModule["apply"] = (ctx) => {
},
subscribe: (listener) => runtime.subscribe(listener),
},
tabs: [
{
id: "settings",
title: "Settings",
role: "settings",
splitSettings: true,
component: SettingsTab,
},
{
id: "activity",
title: "Activity",
role: "activity",
component: ActivityTab,
},
],
// A mention during the first download waits for it, within its deadline.
...claudeRun(runtime, (signal) => setup.installed(signal)),
});
};
}
17 changes: 8 additions & 9 deletions src/bundled/claude-code/tabs.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ import type { ChannelSummary } from "../../features/relay/contracts";
import type { RelayData } from "../../features/relay/service";
import { ClaudeLogoIcon } from "../../shared/design-system/icons";
import { manifestProcesses } from "./claude-testing";
import { apply, inject } from "./index";
import * as claude from "./index";
import { appliedType } from "../../features/agents/plugin-testing";
import { type Config, DEFAULT_CONFIG } from "./runtime";
import { ClaudeSetup } from "./setup";
import { ClaudeDefaults } from "./defaults";
Expand Down Expand Up @@ -145,7 +146,6 @@ function applied({
/** Whether Claude lists its models when asked. */
catalog?: { listed: boolean; ready?: Promise<void> };
} = {}) {
let type: AgentType<Config> | undefined;
const home = mkdtempSync(join(tmpdir(), "claude-tabs-"));
homes.push(home);
for (const [path, content] of Object.entries(files)) {
Expand Down Expand Up @@ -256,9 +256,6 @@ function applied({
},
agents: {
storage,
register: (registered: AgentType<Config>) => {
type = registered;
},
snapshot: () => ({ status: "ready", agents: [], running: [] }),
relay: () =>
channels &&
Expand All @@ -272,7 +269,7 @@ function applied({
},
effect: vi.fn(),
};
apply(ctx as never);
const type = appliedType<Config>(claude, ctx);
return { type, spawn, home };
}
function installed(options?: Parameters<typeof applied>[0]) {
Expand All @@ -286,8 +283,9 @@ function tab(type: AgentType<Config>, index: number) {
return component;
}
function providerSetup(type: AgentType<Config>) {
if (!type.provider) throw new Error("no provider contribution");
return type.provider.DialogBody;
const body = type.provider?.DialogBody;
if (!body) throw new Error("no provider contribution");
return body;
}
async function choose(label: string, option: string) {
const user = userEvent.setup();
Expand Down Expand Up @@ -652,7 +650,8 @@ it("shows the channel's current kind for a session another build started", async
});

it("is an agent type on the desktop app", () => {
expect(inject).toEqual(["agents", "host"]);
expect(claude.inject).toEqual(["agents"]);
expect(claude.agent.inject).toEqual(["agents", "host"]);
const { type } = installed();
expect(type).toMatchObject({
id: "claude-code",
Expand Down
33 changes: 28 additions & 5 deletions src/bundled/claude-code/tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,29 @@ import {
import type { ClaudeModel } from "./claude";
import type { ClaudeSetup } from "./setup";
import type { ClaudeDefaults } from "./defaults";

// What the tabs reach of the runtime: stores and calls returning promises,
// as an agent type's `expose` allows.
type SetupHandle = Pick<
ClaudeSetup,
| "snapshot"
| "subscribe"
| "check"
| "loadModels"
| "run"
| "answer"
| "cancel"
>;
type DefaultsHandle = Pick<ClaudeDefaults, "snapshot" | "subscribe" | "save">;
type ActivityHandle = Pick<
ClaudeRuntime,
| "subscribe"
| "sessions"
| "conversations"
| "find"
| "transcript"
| "channelLabel"
>;
import { type ClaudeTranscript, TURN_LIMIT } from "./transcript";

const message = (error: unknown) =>
Expand Down Expand Up @@ -139,7 +162,7 @@ function ClaudeSignIn({
setup,
disabled,
}: {
setup: ClaudeSetup;
setup: SetupHandle;
disabled: boolean;
}) {
const [open, setOpen] = useState(false);
Expand Down Expand Up @@ -180,7 +203,7 @@ function ClaudeSetupPanel({
disabled = false,
variant = "full",
}: {
setup: ClaudeSetup;
setup: SetupHandle;
disabled?: boolean;
variant?: "full" | "compact";
}) {
Expand Down Expand Up @@ -325,9 +348,9 @@ export function createTabs({
defaults,
runtime,
}: Readonly<{
setup: ClaudeSetup;
defaults: ClaudeDefaults;
runtime: ClaudeRuntime;
setup: SetupHandle;
defaults: DefaultsHandle;
runtime: ActivityHandle;
}>) {
function useSetupCatalog() {
const state = useSyncExternalStore(
Expand Down
Loading
Loading