From c690ab7a38e25c768fd463026100ed698bd1c05a Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 27 Oct 2026 11:50:11 -0400 Subject: [PATCH] refactor(agents): give present the exposed handles as another page would A type's `present` now gets its `expose` the way it would get it from another page: each call returns a promise, and stores and keyed reads stay synchronous (`Remote`). The service checks that `expose` holds only plain objects, arrays, JSON, stores, keyed reads and calls; a type exposing anything else, such as a class instance, is not shown. Claude, Codex, goose and Pi expose `handle(object, names)` of their setup, defaults, provider and history objects instead of the objects, so screens reach only the methods their tabs use. The tabs' handle types accept the remote form, so a tab that needed a call's synchronous result would no longer typecheck. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Logan Johnson --- docs/plugin-architecture.md | 10 ++-- src/bundled/claude-code/index.ts | 13 +++++- src/bundled/codex/index.ts | 10 +++- src/bundled/goose/index.ts | 16 ++++++- src/bundled/pi/index.ts | 3 +- src/features/agents/handles.test.ts | 46 ++++++++++++++++++- src/features/agents/handles.ts | 71 +++++++++++++++++++++++++++++ src/features/agents/service.test.ts | 40 ++++++++++++++-- src/features/agents/service.ts | 16 ++++--- 9 files changed, 202 insertions(+), 23 deletions(-) diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md index 3bf84684b6..c1a9831fa5 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -249,11 +249,13 @@ 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`), a call returning a promise, or a keyed read (`keyedRead` in +handles its UI reaches of the runtime: plain objects and arrays of JSON, stores +(`snapshot` and `subscribe`), keyed reads (`keyedRead` in `features/agents/handles.ts`: `read(key)` and `subscribe`, such as one agent's -sessions), so it can later cross to another page. Claude is the first harness -split this way. +sessions) and calls. `handle(object, names)` exposes only the named methods of +an object. `present` gets the handles as it would from another page: each call +returns a promise, and stores and keyed reads stay synchronous. A type whose +`expose` holds any other object, such as a class instance, is not shown. The type's functions of config alone (`defaults`, `importShared`, `adopt`, `carry`, `describeMigration`, `migrationWarning` and `summary`) go to both diff --git a/src/bundled/claude-code/index.ts b/src/bundled/claude-code/index.ts index ad78b50354..d6eb6ea02d 100644 --- a/src/bundled/claude-code/index.ts +++ b/src/bundled/claude-code/index.ts @@ -5,6 +5,7 @@ import { repositoriesContext } from "../../features/communities/repositories"; import { isTauri } from "@tauri-apps/api/core"; import { pubkeyOf } from "../../features/agents/membership"; import { ownerReads } from "../../features/agents/reads"; +import { handle } from "../../features/agents/handles"; import type { AgentTypeConfig } from "../../features/agents/service"; import type { PluginModule } from "../../plugins/api"; import { ClaudeLogoIcon } from "../../shared/design-system/icons"; @@ -164,8 +165,16 @@ function applyAgent(ctx: Parameters[0]) { "Claude on this computer. Each thread it is mentioned in is one continuing session.", ...typeConfig, expose: { - setup, - defaults: providerDefaults, + setup: handle(setup, [ + "snapshot", + "subscribe", + "check", + "loadModels", + "run", + "answer", + "cancel", + ]), + defaults: handle(providerDefaults, ["snapshot", "subscribe", "save"]), runtime: activityHandle(runtime), } satisfies ClaudeExposed, ...(spawn && diff --git a/src/bundled/codex/index.ts b/src/bundled/codex/index.ts index ff5ad296be..13a1c67cf6 100644 --- a/src/bundled/codex/index.ts +++ b/src/bundled/codex/index.ts @@ -2,6 +2,7 @@ import { repositoriesContext } from "../../features/communities/repositories"; import { isTauri } from "@tauri-apps/api/core"; import { pubkeyOf } from "../../features/agents/membership"; import { ownerReads } from "../../features/agents/reads"; +import { handle } from "../../features/agents/handles"; import type { AgentTypeConfig } from "../../features/agents/service"; import type { PluginModule } from "../../plugins/api"; import { CodexLogoIcon } from "../../shared/design-system/icons"; @@ -197,10 +198,15 @@ function applyAgent(ctx: Parameters[0]) { "Codex on this computer, using its native coding tools and your account.", ...typeConfig, expose: { - provider: providerController, + provider: handle(providerController, [ + "snapshot", + "subscribe", + "check", + "saveDefaults", + ]), runtime: activityHandle(runtime), transcript: transcripts(start, runtime), - setup, + setup: handle(setup, ["snapshot", "subscribe", "run", "cancel"]), } satisfies CodexExposed, ...(spawn && readFile && { diff --git a/src/bundled/goose/index.ts b/src/bundled/goose/index.ts index d7e9909b14..d1b10234cf 100644 --- a/src/bundled/goose/index.ts +++ b/src/bundled/goose/index.ts @@ -4,6 +4,7 @@ import { repositoriesContext } from "../../features/communities/repositories"; // in it; goose answers with the in-process Buzz tools, served to it over // loopback HTTP MCP. import { isTauri } from "@tauri-apps/api/core"; +import { handle } from "../../features/agents/handles"; import type { AgentTypeConfig } from "../../features/agents/service"; import type { PluginModule } from "../../plugins/api"; import { GooseLogoIcon } from "../../shared/design-system/icons"; @@ -140,9 +141,20 @@ function applyAgent(ctx: Parameters[0]) { "goose on this computer, with any provider goose supports. Each thread it is mentioned in is one continuing session.", ...typeConfig, expose: { - setup, + setup: handle(setup, [ + "snapshot", + "subscribe", + "check", + "providers", + "models", + "efforts", + "saved", + "save", + "saveDefaults", + "test", + ]), runtime: activityHandle(runtime), - history, + history: handle(history, ["find", "recent", "transcript"]), } satisfies GooseExposed, provider: { snapshot: setup.snapshot, diff --git a/src/bundled/pi/index.ts b/src/bundled/pi/index.ts index f980bb9de4..1ab39e4232 100644 --- a/src/bundled/pi/index.ts +++ b/src/bundled/pi/index.ts @@ -6,6 +6,7 @@ import { isTauri } from "@tauri-apps/api/core"; import { pubkeyOf } from "../../features/agents/membership"; import { ownerReads } from "../../features/agents/reads"; import { repositoriesContext } from "../../features/communities/repositories"; +import { handle } from "../../features/agents/handles"; import type { AgentTypeConfig } from "../../features/agents/service"; import type { PluginModule } from "../../plugins/api"; import { PiLogoIcon } from "../../shared/design-system/icons"; @@ -117,7 +118,7 @@ function applyAgent(ctx: Parameters[0]) { "Pi on this computer, with any provider Pi supports. Each thread it is mentioned in is one continuing conversation.", ...typeConfig, expose: { - setup: runtime.setup, + setup: handle(runtime.setup, ["providers", "models", "login", "test"]), runtime: activityHandle(runtime), } satisfies PiExposed, ...(host.spawn && { diff --git a/src/features/agents/handles.test.ts b/src/features/agents/handles.test.ts index c8ac41309a..51cd5d5c34 100644 --- a/src/features/agents/handles.test.ts +++ b/src/features/agents/handles.test.ts @@ -1,5 +1,5 @@ -import { expect, it } from "vitest"; -import { keyedRead } from "./handles"; +import { expect, it, vi } from "vitest"; +import { handle, keyedRead, local } from "./handles"; it("keeps what a key read before while its content is the same", () => { let sessions: Record = { @@ -21,3 +21,45 @@ it("keeps what a key read before while its content is the same", () => { expect(changed).toEqual([{ key: "one", busy: true }]); expect(handle.read("b")).toBe(b); }); + +it("gives screens a type's handles with calls answering by promise", async () => { + const listeners = new Set<() => void>(); + const store = { + snapshot: () => "ready", + subscribe: (listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + check: () => "checked", + }; + const sessions = keyedRead(() => ["one"], store.subscribe, []); + const exposed = local({ + store, + sessions, + fail: () => { + throw new Error("failed"); + }, + list: [{ load: async () => 3 }], + }); + expect(exposed.store.snapshot()).toBe("ready"); + expect(exposed.store.subscribe).toBe(store.subscribe); + await expect(exposed.store.check()).resolves.toBe("checked"); + expect(exposed.sessions.read("a")).toBe(sessions.read("a")); + await expect(exposed.fail()).rejects.toThrow("failed"); + const [item] = exposed.list; + await expect(item?.load()).resolves.toBe(3); +}); + +it("refuses an exposed object that is not a plain handle", () => { + class Setup { + check() {} + } + expect(() => local({ setup: new Setup() })).toThrow( + "expose.setup is not a plain object", + ); + const setup = new Setup(); + const check = vi.spyOn(setup, "check"); + const picked = local({ setup: handle(setup, ["check"]) }); + void picked.setup.check(); + expect(check).toHaveBeenCalledOnce(); +}); diff --git a/src/features/agents/handles.ts b/src/features/agents/handles.ts index acaaa93de3..7662a6cc2d 100644 --- a/src/features/agents/handles.ts +++ b/src/features/agents/handles.ts @@ -28,3 +28,74 @@ export function keyedRead( empty, }); } + +/** `object`'s methods `names` as a plain handle, so a type exposes only what + * its screens reach. Each calls `object` when called. */ +export function handle( + object: T, + names: readonly K[], +): Readonly> { + return Object.freeze( + Object.fromEntries( + names.map((name) => [ + name, + (...args: unknown[]) => + (object[name] as (...args: unknown[]) => unknown).apply(object, args), + ]), + ), + ) as Pick; +} + +/** Members a screen reads synchronously: a store's and a keyed read's. */ +const SYNC = new Set(["snapshot", "subscribe", "read", "empty"]); + +/** An exposed handle as screens get it: each call returns a promise. Stores + * and keyed reads stay synchronous. */ +export type Remote = T extends (...args: infer A) => infer R + ? (...args: A) => Promise> + : T extends object + ? { + readonly [K in keyof T]: K extends + | "snapshot" + | "subscribe" + | "read" + | "empty" + ? T[K] + : Remote; + } + : T; + +/** `exposed` as screens in this page get it: what they will get from another + * page. Calls return promises; handles must be plain objects, arrays or JSON. */ +export function local(exposed: T): Remote { + return localOf(exposed, "expose") as Remote; +} + +function localOf(value: unknown, path: string): unknown { + if (typeof value === "function") + return (...args: unknown[]) => { + try { + return Promise.resolve(value(...args)); + } catch (error) { + return Promise.reject(error); + } + }; + if (typeof value !== "object" || value === null) return value; + if (Array.isArray(value)) + return Object.freeze( + value.map((item, index) => localOf(item, `${path}[${index}]`)), + ); + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) + throw new TypeError( + `${path} is not a plain object; expose a handle of what screens reach`, + ); + return Object.freeze( + Object.fromEntries( + Object.entries(value).map(([key, member]) => [ + key, + SYNC.has(key) ? member : localOf(member, `${path}.${key}`), + ]), + ), + ); +} diff --git a/src/features/agents/service.test.ts b/src/features/agents/service.test.ts index 66b8b87630..f395341f77 100644 --- a/src/features/agents/service.test.ts +++ b/src/features/agents/service.test.ts @@ -33,6 +33,7 @@ import { fromOwner, teamPrompt, } from "./service"; +import type { Remote } from "./handles"; import type { DecisionFailure, DecisionResult, @@ -271,8 +272,13 @@ it("shows a type with what its plugin presents, from the handles the type expose const { service, plugin } = await setup(); const ready = { readiness: "ready" as const }; let attention: (() => void) | undefined; - const handles = { count: 1 }; - const shown = vi.fn((exposed: typeof handles) => ({ + const snapshot = () => ready; + const handles = { + count: 1, + store: { snapshot, subscribe: () => () => {} }, + load: vi.fn(() => 2), + }; + const shown = vi.fn((exposed: Remote) => ({ icon: () => null, tabs: [{ id: "t", title: "T", component: () => null }], provider: { DialogBody: () => null }, @@ -299,7 +305,13 @@ it("shows a type with what its plugin presents, from the handles the type expose expect(runtimeOnly?.tabs).toBeUndefined(); plugin.agents.present("shown", shown as never); const type = service.types().find((t) => t.key === "example/shown"); - expect(shown).toHaveBeenCalledExactlyOnceWith(handles); + // Screens get the handles as they would from another page: stores read + // synchronously, and calls answer with a promise. + expect(shown).toHaveBeenCalledOnce(); + const [exposed] = shown.mock.calls[0] ?? []; + expect(exposed?.count).toBe(1); + expect(exposed?.store.snapshot).toBe(snapshot); + await expect(exposed?.load()).resolves.toBe(2); expect(type).toMatchObject({ title: "Shown", tabs: [{ id: "t" }] }); expect(type?.run).toBeUndefined(); expect(type?.icon).toBeTypeOf("function"); @@ -348,6 +360,28 @@ it("takes a type's config functions from what it presents, for screens where the }); }); +it("does not show a type that exposes more than plain handles", async () => { + const { service, plugin } = await setup(); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + class Setup { + check() {} + } + const shown = vi.fn(() => ({ tabs: [] })); + plugin.agents.register({ + id: "leaky", + title: "Leaky", + defaults: () => ({ config: {} }), + expose: { setup: new Setup() }, + }); + plugin.agents.present("leaky", shown); + const type = service.types().find((t) => t.key === "example/leaky"); + expect(type).toMatchObject({ title: "Leaky" }); + expect(type?.tabs).toBeUndefined(); + expect(shown).not.toHaveBeenCalled(); + expect(error).toHaveBeenCalledOnce(); + error.mockRestore(); +}); + it("still runs a type whose present throws", async () => { const { service, plugin } = await setup(); const error = vi.spyOn(console, "error").mockImplementation(() => {}); diff --git a/src/features/agents/service.ts b/src/features/agents/service.ts index ebb4a1e755..3464462147 100644 --- a/src/features/agents/service.ts +++ b/src/features/agents/service.ts @@ -18,6 +18,7 @@ import { isSynced, type Mode, resolveStart } from "./agent-start"; import { AgentRunners, type Connection, TIMEOUT_LIMIT_MS } from "./runner"; import { CoreMemoryCache } from "./core-memory-cache"; import { ownerReads } from "./reads"; +import { local, type Remote } from "./handles"; // Agents: agents that are plugins from the start. A plugin registers an agent // type (its summary line, peek view, settings tabs and `run`); each agent made // from it has its own native-held key, an `agent-attention/v1` configuration the @@ -709,9 +710,9 @@ export type AgentType = { /** Makes the type hosted: its provider runs each agent, so it has no `run`, * `models` or `timeoutMs`, and its agents have no attention. */ remote?: RemoteAgentType; - /** Handles for the type's own UI, given to its plugin's `present`. Each is - * JSON, a store (`snapshot` and `subscribe`) or a call returning a promise, - * so that agents can later run in another page than their UI. */ + /** Handles for the type's own UI, given to its plugin's `present`: plain + * objects of JSON, stores (`snapshot` and `subscribe`), keyed reads and + * calls, so that agents can later run in another page than their UI. */ expose?: unknown; }; /** A type's functions of config alone. Both its entries pass the same ones, @@ -1014,10 +1015,11 @@ export type Agents = { }>[]; register(type: AgentType): void; /** Supplies what this plugin's type `typeId` shows, from its UI entry. - * `present` gets the type's `expose`. */ + * `present` gets the type's `expose`, with each call returning a promise, + * as it would from another page. */ present( typeId: string, - present: (exposed: Exposed) => AgentPresentation, + present: (exposed: Remote) => AgentPresentation, ): void; types(): readonly RegisteredAgentType[]; /** Adds a tool to every agent session in the app, beside the Buzz tools. @@ -1915,7 +1917,7 @@ export class AgentsService extends Service implements Agents { } present( typeId: string, - present: (exposed: Exposed) => AgentPresentation, + present: (exposed: Remote) => AgentPresentation, ) { if (typeof present !== "function") throw new Error("present needs a function"); @@ -1938,7 +1940,7 @@ export class AgentsService extends Service implements Agents { let merged = type; try { merged = Object.freeze( - presented(type, from.present(type.expose)), + presented(type, from.present(local(type.expose))), ) as RegisteredAgentType; } catch (error) { // Its agents still run; only what the type shows is missing.