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
33 changes: 27 additions & 6 deletions docs/plugin-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -299,12 +299,24 @@ 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.
from `apply`. The `agent` entry registers what runs: `defaults`, `run`,
`models`, `usage`, session state and actions, and the provider's state and
checks, setup included. `present(typeId, (runtime) => ({ ... }))` gives what
the screens show and how agents are managed: `icon`, `Peek`, `Create`,
`CreateModel`, `tabs`, `provider: { DialogBody, DefaultControl }`, `adopt`,
`importShared`, `carry`, `describeMigration`, `migrationWarning`, `summary` and
`sessionScope`. A type that runs here and registers one of those fails.

Only JSON passes between the two entries. The type's `expose` is
`{ topics, commands }`, served to its UI: a topic is a value the UI watches,
and a command is a call that answers when done, after the values it changed.
`runtime` is the UI's link to them, with `read(topic, args)`,
`watch(topic, args, listener)` and `call(command, args)`. The screens read the
type itself, its models, usage, session state and provider state, over the
same kind of link. What arrives is a copy: a field that is `undefined` is
missing, and a call that stops waiting does not stop the runtime's work.
Hosted types, such as Builderlab's, register a whole type from the UI entry
and are unchanged.

A plugin, bundled or installed, can add a tab to the settings of every agent
running on this device, whatever its type, with
Expand All @@ -322,6 +334,15 @@ agent, as the rest of a trusted plugin's code acts as its owner. Names are
lowercase and cannot be a Buzz tool's. The tool is removed when its plugin's
scope ends, so safe mode removes installed plugins' tools.

Tools belong in the `agent` entry. A tool that needs something only the
screens have, such as a sign-in or opening a panel, gets it from what the UI
entry shares: `ctx.agents.share({ topics, commands })` from `apply`, once per
plugin, and `ctx.agents.shared()` from the `agent` entry, a link with the same
`read`, `watch` and `call` as a type's `runtime`. Only JSON crosses. A topic
reads as `null` while the UI shares nothing, and a call fails. Builderlab
shares its sign-in with its Apps tools this way, and GitHub's `link_pr` tells
the sidebar of a link it wrote.

`ctx.agents.wake(pubkey, { channel, root?, text })` wakes an agent this copy of
the app runs in one of its conversations, as a mention there would, with no
message. `root` is the thread's root event id (64 hex); leave it out for a Solo
Expand Down
4 changes: 3 additions & 1 deletion src/app/navigation.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ vi.mock("../bundled", async () => ({
component: () => <p>Gated card body</p>,
});
const state = { readiness: "ready" as const };
ctx.agents.present("provider", () => ({
provider: { DialogBody: () => <p>Gated provider controls</p> },
}));
ctx.agents.register({
id: "provider",
title: "Gated provider",
Expand All @@ -56,7 +59,6 @@ vi.mock("../bundled", async () => ({
snapshot: () => state,
subscribe: () => () => {},
check() {},
DialogBody: () => <p>Gated provider controls</p>,
},
});
},
Expand Down
14 changes: 9 additions & 5 deletions src/app/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,10 +59,13 @@ export function useAppNavigation(
? services.agents.subscribe(listener)
: () => {},
() =>
!!provider &&
!!services.agents
?.types()
.some((type) => type.key === provider && type.provider),
!provider || !services.agents
? false
: services.agents.typePending(provider)
? "pending"
: services.agents
.types()
.some((type) => type.key === provider && !!type.provider),
);
const visibility = useSyncExternalStore(
services.settingsCards.subscribe,
Expand Down Expand Up @@ -176,7 +179,8 @@ export function useAppNavigation(
const owner = provider.split("/")[0] ?? "";
if (
startup === "loading" ||
plugins.activation[owner]?.status === "starting"
plugins.activation[owner]?.status === "starting" ||
providerAvailable === "pending"
)
waiting = true;
else if (!providerAvailable) failure = "unavailable";
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,8 @@ function setup(
register: vi.fn(),
addTool: vi.fn(),
present: vi.fn(),
share: vi.fn(),
shared: vi.fn(),
tools: () => [],
create: vi.fn(async () => {
// `shown: false`: it was made for a community no longer selected.
Expand Down
8 changes: 2 additions & 6 deletions src/bundled/bestie/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import type { ConversationExtensions } from "../../features/conversation/contrac
import type { ConversationHostServices } from "../../features/conversation/ConversationHost";
import type { Navigation } from "../../features/navigation/controller";
import { BestiePage } from "./index";
import { bestieFixture, cleanups, HOME } from "./testing";
import { bestieFixture, cleanups, stubHome } from "./testing";

const native = vi.hoisted(() => ({ isTauri: () => true, invoke: vi.fn() }));
vi.mock("@tauri-apps/api/core", () => native);
Expand All @@ -28,11 +28,7 @@ beforeEach(() => {
configurable: true,
value: "MacIntel",
});
let next = 0;
const randomUUID = crypto.randomUUID.bind(crypto);
vi.spyOn(crypto, "randomUUID").mockImplementation(() =>
next++ === 0 ? HOME : randomUUID(),
);
stubHome();
vi.spyOn(console, "warn").mockImplementation(() => {});
});
afterEach(async () => {
Expand Down
9 changes: 2 additions & 7 deletions src/bundled/bestie/setup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { MAX_INSTRUCTIONS_LENGTH } from "../builderlab/agents/client";
import { bestieInstructions, promptVersion } from "./prompt";
import { BUILDERLAB_TYPE } from "./setup";
import { bestieFixture, cleanups, HOME, MODEL } from "./testing";
import { bestieFixture, cleanups, HOME, MODEL, stubHome } from "./testing";

const native = vi.hoisted(() => ({ isTauri: () => true, invoke: vi.fn() }));
vi.mock("@tauri-apps/api/core", () => native);
Expand All @@ -28,12 +28,7 @@ beforeEach(() => {
},
},
});
let next = 0;
const randomUUID = crypto.randomUUID.bind(crypto);
// The first UUID is Bestie's home; the rest stay random.
vi.spyOn(crypto, "randomUUID").mockImplementation(() =>
next++ === 0 ? HOME : randomUUID(),
);
stubHome();
// Builderlab doesn't support profile() yet; silence its warning.
vi.spyOn(console, "warn").mockImplementation(() => {});
});
Expand Down
13 changes: 13 additions & 0 deletions src/bundled/bestie/testing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,18 @@ import {

export const COMMUNITY = "https://community.example";
export const HOME = "11111111-1111-4111-8111-111111111111";
let homeNext = false;
/** Makes the first UUID asked for once a fixture is ready Bestie's home; the
* rest, and those the app asks for while it starts, stay random. */
export function stubHome() {
homeNext = false;
const randomUUID = crypto.randomUUID.bind(crypto);
vi.spyOn(crypto, "randomUUID").mockImplementation(() => {
if (!homeNext) return randomUUID();
homeNext = false;
return HOME;
});
}
export const cleanups: (() => Promise<void> | void)[] = [];

/** The fake catalog's model and an effort it supports. */
Expand Down Expand Up @@ -394,6 +406,7 @@ export async function bestieFixture(invoke: Mock) {
});
const provided = root.get("builderlab");
if (!provided) throw new Error("Builderlab not provided");
homeNext = true;
const raw = createRemoteBestie(provided, agents, relay);
// The owner picks this model whenever setup asks; `raw` stops at the choice.
const bestie: RemoteBestie = {
Expand Down
2 changes: 1 addition & 1 deletion src/bundled/builderlab/apps/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export type AppsClient = ReturnType<typeof createAppsClient>;

export function createAppsClient(
host: Host,
session: OAuthSession,
session: Pick<OAuthSession, "snapshot" | "credential">,
base: string,
) {
// One client belongs to one tool operation, including all its HTTP steps.
Expand Down
15 changes: 11 additions & 4 deletions src/bundled/builderlab/apps/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ import type { AddedTool } from "../../../buzz-mcp/session-tools";
import type { BuzzClient } from "../../../buzz-mcp/client";
import type { HostResponse, HostRequest } from "../../../features/host/service";
import { createOAuthSession } from "../oauth/session";
import { applyApps } from "./index";
import {
delivered,
runtimeLink,
} from "../../../features/agents/plugin-testing";
import { applyApps, applyAppTools } from "./index";

afterEach(() => {
vi.useRealTimers();
Expand Down Expand Up @@ -55,7 +59,9 @@ async function setup(
host: { request: respond, upload: respond },
agents: { addTool: (tool: AddedTool) => tools.set(tool.name, tool) },
};
applyApps(ctx as never, session);
// The tools reach the UI's sign-in over a link, as from another page.
applyAppTools(ctx as never, runtimeLink(applyApps(ctx as never, session)));
await delivered();
const call = (name: string, args: Record<string, unknown>, context = {}) => {
const tool = tools.get(name);
if (!tool) throw new Error(`No tool ${name}`);
Expand Down Expand Up @@ -85,6 +91,7 @@ it("preserves an accepted upload after sign-in changes and starts later tools wi
await uploading;
session.signOut();
await session.signIn();
await delivered();
accept(json({ version_id: "v1", deployment_id: "d1", external_url: APP }));
const result = JSON.parse(String(await pending));
expect(result).toMatchObject({
Expand Down Expand Up @@ -120,8 +127,8 @@ it("adds the app tools only for a tenant with an Apps control plane", async () =
});

it("returns once the deployed version serves", async () => {
vi.useFakeTimers();
const { call, responses, respond } = await setup();
vi.useFakeTimers();
responses.push(
json({ ok: true, version_id: "ver-1", external_url: APP }),
json({ ready: false }),
Expand Down Expand Up @@ -201,8 +208,8 @@ it("stops polling on a terminal readiness status", async () => {
});

it("uses the tenant readiness deadline instead of giving up after 90 seconds", async () => {
vi.useFakeTimers();
const { call, responses, respond } = await setup();
vi.useFakeTimers();
responses.push(
json({ version_id: "v1" }),
...Array.from({ length: 31 }, () => json({ ready: false })),
Expand Down
60 changes: 58 additions & 2 deletions src/bundled/builderlab/apps/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ import { createElement } from "react";
import type { PluginModule } from "../../../plugins/api";
import type { PanelProps } from "../../../features/panels/service";
import type { OAuthSession } from "../oauth/session";
import type { Credential } from "../oauth/browser";
import {
linkedStore,
type RuntimeLink,
} from "../../../features/agents/exposed";
import type { Json, Served } from "../../../features/agents/link";
import { createAppsClient, type RuntimeProfile } from "./client";
import { appName, controlPlane, isAppUrl } from "./environment";

Expand Down Expand Up @@ -34,7 +40,10 @@ const DEPLOY_RECIPE = `Deploy a Builderlab app. Read apps_contract before choosi
4. Call this with the archive's absolute path; it must be in your owner's home directory.
It returns the version/deployment IDs once accepted, even if readiness checking fails. Use apps_status for that exact version; do not redeploy to retry readiness. Redeploy the same app_id after source changes; a validation error names what to fix in the archive.`;

export function applyApps(ctx: Ctx, session: OAuthSession) {
/** The panel that shows a deployed app, and what the app tools need of the
* UI: the sign-in, and opening an app beside a conversation. The plugin
* shares what this returns with its agent entry. */
export function applyApps(ctx: Ctx, session: OAuthSession): Served {
// Cordis wraps service functions on access; keep React's component identity.
const View = ctx.browser.View;
ctx.panels.register({
Expand All @@ -52,8 +61,55 @@ export function applyApps(ctx: Ctx, session: OAuthSession) {
label: appName(target),
}),
});
// Each new credential counts, so the tools see a sign-in change even when
// its value is the same.
let held: Credential | undefined;
let generation = 0;
const signIn = () => {
const now =
session.snapshot().status === "signed-in"
? session.credential()
: undefined;
if (now !== held) {
held = now;
generation += 1;
}
return { credential: now ?? null, generation };
};
return {
topics: {
signIn: () => ({
get: () => signIn() as unknown as Json,
subscribe: (listener) => session.subscribe(listener),
}),
},
commands: {
open: (args) =>
ctx.panels.requestOpen(
args as unknown as Parameters<Ctx["panels"]["requestOpen"]>[0],
),
},
};
}

/** The tools that create, deploy, share and open apps, for the agent entry,
* with what the UI shares (`applyApps`). */
export function applyAppTools(ctx: Ctx, shared: RuntimeLink) {
const base = controlPlane();
if (!base) return;
const signIn = linkedStore<Readonly<{
credential: Credential | null;
}> | null>(shared, "signIn", null);
const session: Pick<OAuthSession, "snapshot" | "credential"> = {
snapshot: () => ({
status: signIn.snapshot()?.credential ? "signed-in" : "signed-out",
}),
credential() {
const credential = signIn.snapshot()?.credential;
if (!credential) throw new Error("Sign in to Builderlab first.");
return credential;
},
};
const apps = () => createAppsClient(ctx.host, session, base);
const reply = (value: unknown) => JSON.stringify(value, null, 2);
ctx.agents.addTool({
Expand Down Expand Up @@ -288,7 +344,7 @@ export function applyApps(ctx: Ctx, session: OAuthSession) {
if (!isAppUrl(url)) throw new Error("That is not a Builderlab app URL");
if (!context.channel)
throw new Error("There is no conversation to open it beside");
const opened = ctx.panels.requestOpen({
const opened = await shared.call("open", {
target: url,
channelId: context.channel,
...(context.root ? { rootId: context.root } : {}),
Expand Down
9 changes: 7 additions & 2 deletions src/bundled/builderlab/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { startKnownCommunitiesSync } from "./known-communities/sync";
import type { AgentProviderSnapshot } from "../../features/agents/service";
import { Header, InlineHeader } from "../../shared/design-system/ui/Header";
import { hostedAgentSettings } from "./service";
import { applyApps } from "./apps";
import { applyApps, applyAppTools } from "./apps";

export const inject = [
"host",
Expand All @@ -28,6 +28,11 @@ export const inject = [
"browser",
"knownCommunities",
];
// The app tools run where agents do, with the sign-in the UI shares.
export const agent: NonNullable<PluginModule["agent"]> = {
inject: ["agents", "host"],
apply: (ctx) => applyAppTools(ctx, ctx.agents.shared()),
};
export const apply: PluginModule["apply"] = (ctx) => {
let unavailable = "";
try {
Expand All @@ -44,7 +49,7 @@ export const apply: PluginModule["apply"] = (ctx) => {
return browserCredential(ctx.host, signal);
});
ctx.effect(() => () => session.dispose());
applyApps(ctx, session);
ctx.agents.share(applyApps(ctx, session));
const backupSession = () => {
if (!nativeIdentityEnabled()) return;
handoffBackupSession(
Expand Down
4 changes: 3 additions & 1 deletion src/bundled/claude-code/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ export type ClaudeProviderDefaults = Readonly<{
model: string;
effort?: string;
}>;
const NATIVE_DEFAULTS: ClaudeProviderDefaults = Object.freeze({ model: "" });
export const NATIVE_DEFAULTS: ClaudeProviderDefaults = Object.freeze({
model: "",
});
const STORAGE_KEY = "buzz.claude-code.provider-defaults.v1";

/** Device-wide defaults are separate from agent records; a blank agent value
Expand Down
Loading
Loading