Skip to content
Closed
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
15 changes: 15 additions & 0 deletions docs/plugin-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,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 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
18 changes: 17 additions & 1 deletion src/app/services.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { createAdminAccess } from "../bundled/admin-access";
import { nativeRelayStaff } from "../features/relay-staff/native";
import { createLocalAgentActivity } from "../features/agents/local-activity";
import { link } from "../features/agents/link";
import { linkedActivity, serveActivity } from "../features/agents/runner-link";
// FOUNDATION: Compose the bundled distribution, plugin runtime, and services here.
import {
createIdentity,
Expand Down Expand Up @@ -52,6 +54,20 @@ export function createServices() {
});
const localAgents = createLocalAgents();
const localActivity = createLocalAgentActivity();
// The Manager, which the screens use, and the Runner, which runs agents,
// share this page for now. Only JSON crosses the link between them.
const agentsLink = new MessageChannel();
const runnerLink = link(
agentsLink.port1,
serveActivity(localActivity.reader),
);
const managerLink = link(agentsLink.port2);
const runnerActivity = linkedActivity(managerLink);
ctx.effect(() => () => {
runnerActivity.stop();
managerLink.close();
runnerLink.close();
});
const navigationHost = provideNavigation(ctx);
const navigation = navigationHost.navigation;
const browser = new BrowserService(ctx);
Expand All @@ -72,7 +88,7 @@ export function createServices() {
localAgents.agents,
identity?.ready,
identity ? connectNativeTransport : undefined,
localActivity.reader,
runnerActivity.source,
);
ctx.provide("communityReader", {
snapshot: communities.snapshot,
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 @@ -281,6 +281,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
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
16 changes: 5 additions & 11 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 Down
92 changes: 55 additions & 37 deletions src/bundled/claude-code/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,56 @@ 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,
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 @@ -72,27 +117,17 @@ export const apply: PluginModule["apply"] = (ctx) => {
});
}

const {
Create,
CreateModel,
DefaultControl,
ProviderDialogBody,
SettingsTab,
ActivityTab,
} = createTabs({
setup,
defaults: providerDefaults,
runtime,
});
agents.register<Config>({
id: TYPE_ID,
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,
expose: {
setup,
defaults: providerDefaults,
runtime,
} satisfies ClaudeExposed,
adopt: (carry, saved) => ({
...saved,
instructions: carry.instructions ?? saved.instructions,
Expand Down Expand Up @@ -145,8 +180,6 @@ export const apply: PluginModule["apply"] = (ctx) => {
? () => void setup.run("login")
: undefined;
},
DialogBody: ProviderDialogBody,
DefaultControl,
},
sessions: {
list: (key) => {
Expand All @@ -169,22 +202,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 @@ -25,7 +25,8 @@ import {
} from "../../features/agents/state";
import type { HostProcessOptions } from "../../features/host/service";
import { ClaudeLogoIcon } from "../../shared/design-system/icons";
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 @@ -126,7 +127,6 @@ function applied({
/** Whether Claude lists its models when asked. */
catalog?: { listed: boolean; ready?: Promise<void> };
} = {}) {
let type: AgentType<Config> | undefined;
const spawn = vi.fn(async (id: string, options: HostProcessOptions = {}) => {
// Claude and its install steps run as steps of claude.sh: its script, $0
// and step come first.
Expand Down Expand Up @@ -246,16 +246,13 @@ function applied({
},
agents: {
storage,
register: (registered: AgentType<Config>) => {
type = registered;
},
snapshot: () => ({ status: "ready", agents: [], running: [] }),
relay: () => undefined,
subscribe: () => () => undefined,
},
effect: vi.fn(),
};
apply(ctx as never);
const type = appliedType<Config>(claude, ctx);
return { type, spawn };
}
function installed(options?: Parameters<typeof applied>[0]) {
Expand All @@ -269,8 +266,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 @@ -405,7 +403,8 @@ it("finds the thread's Claude 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
28 changes: 23 additions & 5 deletions src/bundled/claude-code/tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,24 @@ 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"
>;
import { TURN_LIMIT } from "./transcript";

const message = (error: unknown) =>
Expand Down Expand Up @@ -139,7 +157,7 @@ function ClaudeSignIn({
setup,
disabled,
}: {
setup: ClaudeSetup;
setup: SetupHandle;
disabled: boolean;
}) {
const [open, setOpen] = useState(false);
Expand Down Expand Up @@ -180,7 +198,7 @@ function ClaudeSetupPanel({
disabled = false,
variant = "full",
}: {
setup: ClaudeSetup;
setup: SetupHandle;
disabled?: boolean;
variant?: "full" | "compact";
}) {
Expand Down Expand Up @@ -324,9 +342,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