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
10 changes: 6 additions & 4 deletions docs/plugin-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 11 additions & 2 deletions src/bundled/claude-code/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -164,8 +165,16 @@ function applyAgent(ctx: Parameters<PluginModule["apply"]>[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 &&
Expand Down
10 changes: 8 additions & 2 deletions src/bundled/codex/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -197,10 +198,15 @@ function applyAgent(ctx: Parameters<PluginModule["apply"]>[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 && {
Expand Down
16 changes: 14 additions & 2 deletions src/bundled/goose/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -140,9 +141,20 @@ function applyAgent(ctx: Parameters<PluginModule["apply"]>[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,
Expand Down
3 changes: 2 additions & 1 deletion src/bundled/pi/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -117,7 +118,7 @@ function applyAgent(ctx: Parameters<PluginModule["apply"]>[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 && {
Expand Down
46 changes: 44 additions & 2 deletions src/features/agents/handles.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, { key: string; busy: boolean }[]> = {
Expand All @@ -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();
});
71 changes: 71 additions & 0 deletions src/features/agents/handles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,74 @@ export function keyedRead<T>(
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<T extends object, K extends keyof T & string>(
object: T,
names: readonly K[],
): Readonly<Pick<T, K>> {
return Object.freeze(
Object.fromEntries(
names.map((name) => [
name,
(...args: unknown[]) =>
(object[name] as (...args: unknown[]) => unknown).apply(object, args),
]),
),
) as Pick<T, K>;
}

/** 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> = T extends (...args: infer A) => infer R
? (...args: A) => Promise<Awaited<R>>
: T extends object
? {
readonly [K in keyof T]: K extends
| "snapshot"
| "subscribe"
| "read"
| "empty"
? T[K]
: Remote<T[K]>;
}
: 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<T>(exposed: T): Remote<T> {
return localOf(exposed, "expose") as Remote<T>;
}

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}`),
]),
),
);
}
40 changes: 37 additions & 3 deletions src/features/agents/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
fromOwner,
teamPrompt,
} from "./service";
import type { Remote } from "./handles";
import type {
DecisionFailure,
DecisionResult,
Expand Down Expand Up @@ -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<typeof handles>) => ({
icon: () => null,
tabs: [{ id: "t", title: "T", component: () => null }],
provider: { DialogBody: () => null },
Expand All @@ -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");
Expand Down Expand Up @@ -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(() => {});
Expand Down
16 changes: 9 additions & 7 deletions src/features/agents/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -709,9 +710,9 @@ export type AgentType<Config = unknown> = {
/** 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,
Expand Down Expand Up @@ -1014,10 +1015,11 @@ export type Agents = {
}>[];
register<Config>(type: AgentType<Config>): 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<Exposed = unknown, Config = unknown>(
typeId: string,
present: (exposed: Exposed) => AgentPresentation<Config>,
present: (exposed: Remote<Exposed>) => AgentPresentation<Config>,
): void;
types(): readonly RegisteredAgentType[];
/** Adds a tool to every agent session in the app, beside the Buzz tools.
Expand Down Expand Up @@ -1915,7 +1917,7 @@ export class AgentsService extends Service implements Agents {
}
present<Exposed, Config>(
typeId: string,
present: (exposed: Exposed) => AgentPresentation<Config>,
present: (exposed: Remote<Exposed>) => AgentPresentation<Config>,
) {
if (typeof present !== "function")
throw new Error("present needs a function");
Expand All @@ -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.
Expand Down
Loading