From 62ccbbd76be9e572e3437e16d8d791eb60cbabef Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Thu, 29 Oct 2026 08:41:11 -0400 Subject: [PATCH] refactor(plugins): give plugins an agent entry, and split each harness into runtime and UI A plugin may export an `agent` entry beside `apply`. Code agents need while they run (an agent type's runtime, tools, wakes and Attention sources) goes there; `apply` stays the UI. Both start in the same page for now. Claude, Codex, goose and Pi now register their types from their agent entries and present them from their UI entries, reaching the runtime only through the type's `expose` handles. GitHub's agent entry adds the github_pr_state tool and the pull request watches, with its own pull request store, since agents will run in a page of their own. Its UI entry shows how a watch looks in Attention through the new `ctx.agents.presentSource`, so the agent entry needs no navigation. An Attention source no longer takes `present`. Inert: the app behaves the same, except that a pull request both shown and watched is read once for each store. Squashes #1716, #1719, #1720, #1721 and #1723. Signed-off-by: Logan Johnson Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 --- docs/plugin-architecture.md | 17 ++- src/app/pages.integration.test.mjs | 1 + src/bundled/agents/AgentsPage.test.tsx | 2 + src/bundled/agents/ProviderDialog.tsx | 2 +- src/bundled/claude-code/index.test.ts | 28 ++-- src/bundled/claude-code/index.ts | 110 +++++++++------- src/bundled/claude-code/tabs.test.tsx | 17 ++- src/bundled/claude-code/tabs.tsx | 33 ++++- src/bundled/codex/index.test.ts | 15 +-- src/bundled/codex/index.ts | 114 +++++++++++------ src/bundled/codex/runtime.test.ts | 6 +- src/bundled/codex/tabs.test.tsx | 11 +- src/bundled/codex/tabs.tsx | 52 ++++++-- src/bundled/github/SoloPull.test.tsx | 26 ++-- src/bundled/github/attention.tsx | 5 +- src/bundled/github/index.tsx | 21 ++- src/bundled/github/watcher.test.ts | 18 +-- src/bundled/github/watcher.ts | 3 - src/bundled/goose/index.test.ts | 16 +-- src/bundled/goose/index.ts | 104 ++++++++------- src/bundled/goose/tabs.tsx | 22 +++- src/bundled/pi/index.test.ts | 33 +++-- src/bundled/pi/index.ts | 87 +++++++------ src/bundled/pi/tabs.tsx | 2 +- src/features/agents/attention-sources.ts | 2 - src/features/agents/plugin-testing.ts | 24 ++++ src/features/agents/service.test.ts | 76 +++++++++-- src/features/agents/service.ts | 156 ++++++++++++++++++++++- src/features/panels/service.test.tsx | 13 +- src/plugins/api.ts | 7 + src/plugins/modules.ts | 9 +- src/plugins/runtime.test.mjs | 53 ++++++++ src/plugins/runtime.ts | 52 +++++--- 33 files changed, 804 insertions(+), 333 deletions(-) create mode 100644 src/features/agents/plugin-testing.ts diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md index 36329b254b..62e9b74408 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -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 @@ -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. diff --git a/src/app/pages.integration.test.mjs b/src/app/pages.integration.test.mjs index 98fd08707f..58019a3752 100644 --- a/src/app/pages.integration.test.mjs +++ b/src/app/pages.integration.test.mjs @@ -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; diff --git a/src/bundled/agents/AgentsPage.test.tsx b/src/bundled/agents/AgentsPage.test.tsx index c530b1c9ce..383d27eeab 100644 --- a/src/bundled/agents/AgentsPage.test.tsx +++ b/src/bundled/agents/AgentsPage.test.tsx @@ -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. @@ -373,6 +374,7 @@ function setup( }), attentionProviders: () => [], presentWatch: () => undefined, + presentSource: () => {}, addAttentionSource: vi.fn(), storage: localStore(), resumeInterrupted: vi.fn(), diff --git a/src/bundled/agents/ProviderDialog.tsx b/src/bundled/agents/ProviderDialog.tsx index 96761eb77c..f480357e24 100644 --- a/src/bundled/agents/ProviderDialog.tsx +++ b/src/bundled/agents/ProviderDialog.tsx @@ -82,7 +82,7 @@ function ManagedProviderDialog({ finalFocus={() => finalFocus.current} > - + {Body && } ); diff --git a/src/bundled/claude-code/index.test.ts b/src/bundled/claude-code/index.test.ts index 7f72616185..53d9b0db5e 100644 --- a/src/bundled/claude-code/index.test.ts +++ b/src/bundled/claude-code/index.test.ts @@ -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()); @@ -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" }, @@ -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({ diff --git a/src/bundled/claude-code/index.ts b/src/bundled/claude-code/index.ts index 4c4ef91607..49f4135780 100644 --- a/src/bundled/claude-code/index.ts +++ b/src/bundled/claude-code/index.ts @@ -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[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(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 = { + inject: ["agents", "host"], + apply: (ctx) => applyAgent(ctx), +}; +function applyAgent(ctx: Parameters[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)); @@ -88,37 +142,18 @@ export const apply: PluginModule["apply"] = (ctx) => { }); } - const { - Create, - CreateModel, - DefaultControl, - ProviderDialogBody, - SettingsTab, - ActivityTab, - } = createTabs({ - setup, - defaults: providerDefaults, - runtime, - }); agents.register({ 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, @@ -171,8 +206,6 @@ export const apply: PluginModule["apply"] = (ctx) => { ? () => void setup.run("login") : undefined; }, - DialogBody: ProviderDialogBody, - DefaultControl, }, sessions: { list: (key) => { @@ -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)), }); -}; +} diff --git a/src/bundled/claude-code/tabs.test.tsx b/src/bundled/claude-code/tabs.test.tsx index 2432aea68e..2537bfcc76 100644 --- a/src/bundled/claude-code/tabs.test.tsx +++ b/src/bundled/claude-code/tabs.test.tsx @@ -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"; @@ -145,7 +146,6 @@ function applied({ /** Whether Claude lists its models when asked. */ catalog?: { listed: boolean; ready?: Promise }; } = {}) { - let type: AgentType | undefined; const home = mkdtempSync(join(tmpdir(), "claude-tabs-")); homes.push(home); for (const [path, content] of Object.entries(files)) { @@ -256,9 +256,6 @@ function applied({ }, agents: { storage, - register: (registered: AgentType) => { - type = registered; - }, snapshot: () => ({ status: "ready", agents: [], running: [] }), relay: () => channels && @@ -272,7 +269,7 @@ function applied({ }, effect: vi.fn(), }; - apply(ctx as never); + const type = appliedType(claude, ctx); return { type, spawn, home }; } function installed(options?: Parameters[0]) { @@ -286,8 +283,9 @@ function tab(type: AgentType, index: number) { return component; } function providerSetup(type: AgentType) { - 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(); @@ -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", diff --git a/src/bundled/claude-code/tabs.tsx b/src/bundled/claude-code/tabs.tsx index 1bceaab5a9..b24fac3abc 100644 --- a/src/bundled/claude-code/tabs.tsx +++ b/src/bundled/claude-code/tabs.tsx @@ -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; +type ActivityHandle = Pick< + ClaudeRuntime, + | "subscribe" + | "sessions" + | "conversations" + | "find" + | "transcript" + | "channelLabel" +>; import { type ClaudeTranscript, TURN_LIMIT } from "./transcript"; const message = (error: unknown) => @@ -139,7 +162,7 @@ function ClaudeSignIn({ setup, disabled, }: { - setup: ClaudeSetup; + setup: SetupHandle; disabled: boolean; }) { const [open, setOpen] = useState(false); @@ -180,7 +203,7 @@ function ClaudeSetupPanel({ disabled = false, variant = "full", }: { - setup: ClaudeSetup; + setup: SetupHandle; disabled?: boolean; variant?: "full" | "compact"; }) { @@ -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( diff --git a/src/bundled/codex/index.test.ts b/src/bundled/codex/index.test.ts index 35f4c7366b..be52d61e75 100644 --- a/src/bundled/codex/index.test.ts +++ b/src/bundled/codex/index.test.ts @@ -1,11 +1,11 @@ // @vitest-environment jsdom import { afterEach, beforeEach, expect, it, vi } from "vitest"; -import type { AgentType } from "../../features/agents/service"; import { CodexLogoIcon } from "../../shared/design-system/icons"; import { CodexRuntime } from "./runtime"; import { CodexSetup } from "./setup"; import { CodexProvider } from "./tabs"; -import { apply } from "./index"; +import * as codex from "./index"; +import { appliedType } from "../../features/agents/plugin-testing"; const desktop = vi.hoisted(() => ({ tauri: true })); vi.mock("@tauri-apps/api/core", () => ({ isTauri: () => desktop.tauri })); @@ -21,23 +21,18 @@ afterEach(() => { /** Applies the plugin to a stand-in context and returns the registered type. */ function applied() { - let type: AgentType | undefined; const subscribe = () => () => undefined; const ctx = { pluginOwner: { id: "buzz.codex", revision: "r1" }, host: { spawn: vi.fn(), request: vi.fn() }, agents: { - register: (registered: AgentType) => { - type = registered; - }, snapshot: () => ({ status: "ready", agents: [], running: [] }), relay: () => undefined, subscribe, }, effect: vi.fn(), }; - apply(ctx as never); - return type; + return appliedType(codex, ctx); } it("registers only where the desktop app can run codex and base64", () => { @@ -123,7 +118,7 @@ it("stops agents that stop running even when the agent list could not be read", const agent = { pubkey: "agent", type: "buzz.codex/codex" }; let state = { status: "ready", agents: [agent], running: [agent] }; let notify = () => undefined; - apply({ + codex.agent.apply({ pluginOwner: { id: "buzz.codex" }, host: { spawn: vi.fn(), request: vi.fn() }, agents: { @@ -187,7 +182,7 @@ it("starts the server once Codex is updated and checked, only while a Codex agen notify(); }; let cleanup: () => void = () => undefined; - apply({ + codex.agent.apply({ pluginOwner: { id: "buzz.codex" }, host: { spawn: vi.fn(), request: vi.fn() }, agents: { diff --git a/src/bundled/codex/index.ts b/src/bundled/codex/index.ts index 0d64dabb74..baca58ba4c 100644 --- a/src/bundled/codex/index.ts +++ b/src/bundled/codex/index.ts @@ -9,15 +9,77 @@ import { type Config, config, defaults, validWorkspace } from "./config"; import { codexRun, TYPE_ID } from "./run"; import { CodexRuntime } from "./runtime"; import { CodexSetup, withScript } from "./setup"; -import { CodexProvider, createTabs } from "./tabs"; +import { CodexProvider, createTabs, transcripts } from "./tabs"; import { readUsage } from "./usage"; /** How often Buzz's copy of Codex looks for a new release. */ const UPDATE_MS = 6 * 60 * 60_000; -export const inject = ["agents", "host"]; +// Only the desktop app can start `codex`, 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. */ +type CodexExposed = Readonly<{ + provider: Parameters[0]; + runtime: Parameters[1]; + transcript: Parameters[2]; + setup: Parameters[3]; +}>; + +export const inject = ["agents"]; export const apply: PluginModule["apply"] = (ctx) => { - // Only the desktop app can start `codex`, 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( + TYPE_ID, + ({ provider, runtime, transcript, setup }) => { + const { + Create, + CreateModel, + DefaultControl, + ProviderDialogBody, + SettingsTab, + ActivityTab, + } = createTabs(provider, runtime, transcript, setup); + return { + icon: CodexLogoIcon, + Create, + CreateModel, + importShared: (source) => + source.runtime === "codex" && + (!source.provider || source.provider === "openai") + ? { ...defaults, model: source.model } + : undefined, + exportShared: (saved) => ({ + runtime: "codex", + 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 = { + inject: ["agents", "host"], + apply: (ctx) => applyAgent(ctx), +}; +function applyAgent(ctx: Parameters[0]) { + if (!supported()) return; const spawn = ctx.host.spawn && withScript(ctx.host.spawn.bind(ctx.host)); const readFile = ctx.host.readFile?.bind(ctx.host); const setup = new CodexSetup(spawn, (input) => ctx.host.request(input)); @@ -110,30 +172,19 @@ export const apply: PluginModule["apply"] = (ctx) => { runtime.dispose(); setup.dispose(); }); - const { - Create, - CreateModel, - DefaultControl, - ProviderDialogBody, - SettingsTab, - ActivityTab, - } = createTabs(providerController, runtime, start, setup); ctx.agents.register({ id: TYPE_ID, hooks: { events: CODEX_HOOK_EVENTS }, title: "Codex", - icon: CodexLogoIcon, description: "Codex on this computer, using its native coding tools and your account.", defaults: () => ({ config: { ...defaults } }), - Create, - CreateModel, - importShared: (source) => - source.runtime === "codex" && - (!source.provider || source.provider === "openai") - ? { ...defaults, model: source.model } - : undefined, - exportShared: (saved) => ({ runtime: "codex", model: config(saved).model }), + expose: { + provider: providerController, + runtime, + transcript: transcripts(start, runtime), + setup, + } satisfies CodexExposed, adopt: (carry, saved) => ({ ...saved, instructions: carry.instructions ?? saved.instructions, @@ -178,8 +229,6 @@ export const apply: PluginModule["apply"] = (ctx) => { snapshot: providerController.snapshot, subscribe: providerController.subscribe, check: () => providerController.check(), - DialogBody: ProviderDialogBody, - DefaultControl, }, sessions: { list: (key) => { @@ -200,21 +249,6 @@ export const apply: PluginModule["apply"] = (ctx) => { }, subscribe: runtime.subscribe, }, - 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. ...codexRun(runtime, async (signal) => { await setup.installed(signal); @@ -222,4 +256,4 @@ export const apply: PluginModule["apply"] = (ctx) => { await providerController.check(); }), }); -}; +} diff --git a/src/bundled/codex/runtime.test.ts b/src/bundled/codex/runtime.test.ts index dd77547dbc..bce2a1a65c 100644 --- a/src/bundled/codex/runtime.test.ts +++ b/src/bundled/codex/runtime.test.ts @@ -1,7 +1,7 @@ import { createHash } from "node:crypto"; import { schnorr } from "@noble/curves/secp256k1.js"; import { bytesToHex } from "nostr-tools/utils"; -import { apply } from "./index"; +import { agent as codexAgent } from "./index"; import { describe, afterEach, expect, it, vi } from "vitest"; import { pubkeyOf } from "../../features/agents/membership"; import { ownerReads } from "../../features/agents/reads"; @@ -456,7 +456,7 @@ function fixture( if (plugin) { vi.stubGlobal("localStorage", local); vi.stubGlobal("navigator", { platform: "MacIntel" }); - apply({ + codexAgent.apply({ host: { spawn }, agents: { snapshot: () => ({ status: "ready", agents: [], running }), @@ -474,7 +474,7 @@ function fixture( effect: (effect: () => () => void) => { pluginDispose = effect(); }, - } as unknown as Parameters[0]); + } as unknown as Parameters[0]); } const starts = () => sent.filter((wire) => wire.method === "turn/start"); const interrupt = () => { diff --git a/src/bundled/codex/tabs.test.tsx b/src/bundled/codex/tabs.test.tsx index 381594e5b3..d0c4ee2500 100644 --- a/src/bundled/codex/tabs.test.tsx +++ b/src/bundled/codex/tabs.test.tsx @@ -31,7 +31,7 @@ import type { RelayData } from "../../features/relay/service"; import { PanelWorkspace } from "../../features/panels/PanelWorkspace"; import { defaults, type Config } from "./config"; import { sessionTag } from "./prompt"; -import { CodexProvider, createTabs } from "./tabs"; +import { CodexProvider, createTabs, transcripts } from "./tabs"; import { CodexRuntime } from "./runtime"; import type { Wire } from "./rpc"; import { CodexSetup } from "./setup"; @@ -204,7 +204,12 @@ function fixture({ sessions, (raw) => provider.resolve(raw), ); - const tabs = createTabs(provider, runtime, spawn, setup); + const tabs = createTabs( + provider, + runtime, + transcripts(spawn, runtime), + setup, + ); const props = (settings = defaults, save = vi.fn(async () => {})) => ({ agent: { @@ -1474,7 +1479,7 @@ it("installs and signs in Buzz's copy of Codex from its provider settings", asyn const { ProviderDialogBody } = createTabs( provider, f.runtime, - f.spawn, + transcripts(f.spawn, f.runtime), f.setup, ); render(); diff --git a/src/bundled/codex/tabs.tsx b/src/bundled/codex/tabs.tsx index a0092908e6..d3dd10f2c2 100644 --- a/src/bundled/codex/tabs.tsx +++ b/src/bundled/codex/tabs.tsx @@ -53,7 +53,12 @@ import { import { AppServer, listModels, needsSignIn, type Spawn } from "./rpc"; import type { CodexRuntime, SavedConversation } from "./runtime"; import type { CodexSetup } from "./setup"; -import { readTranscript, TURN_LIMIT, type CodexTranscript } from "./transcript"; +import { + readTranscript, + TURN_LIMIT, + type CodexTranscript, + type Lookup, +} from "./transcript"; const label = ({ channelId, name, root }: SavedConversation) => `${name || channelId.slice(0, 8)} · ${root ? `thread ${root.slice(0, 8)}` : "channel"}`; @@ -311,7 +316,7 @@ function CodexSignIn({ check, disabled, }: { - login: CodexSetup; + login: SetupHandle; step: "install" | "login"; checking: boolean; check(): void; @@ -413,11 +418,39 @@ function CodexSignIn({ ); } +// What the tabs reach of the runtime: stores and calls returning promises, +// as an agent type's `expose` allows. +type ProviderHandle = Pick< + CodexProvider, + "snapshot" | "subscribe" | "check" | "resolve" | "saveDefaults" +>; +type ActivityHandle = Pick< + CodexRuntime, + "subscribe" | "sessions" | "conversations" | "find" | "channelLabel" +>; +type SetupHandle = Pick< + CodexSetup, + "snapshot" | "subscribe" | "run" | "cancel" +>; +/** Reads one conversation's Codex thread. */ +export type TranscriptRead = ( + agent: Lookup["agent"], + conversation: SavedConversation, + signal: AbortSignal, +) => Promise; +/** Transcripts read with `spawn`, named by the runtime. */ +export const transcripts = + (spawn: Spawn, runtime: Pick): TranscriptRead => + (agent, conversation, signal) => + readTranscript(spawn, { agent, ...conversation }, signal, (pubkey) => + runtime.name(agent.key, pubkey), + ); + export function createTabs( - provider: CodexProvider, - runtime: CodexRuntime, - spawn: Spawn, - setup: CodexSetup, + provider: ProviderHandle, + runtime: ActivityHandle, + transcript: TranscriptRead, + setup: SetupHandle, ) { const login = setup; @@ -739,11 +772,10 @@ export function createTabs( useEffect(() => { const abort = new AbortController(); setLoading(true); - void readTranscript( - spawn, - { agent: { key: agent.key, name: agent.name }, ...conversation }, + void transcript( + { key: agent.key, name: agent.name }, + conversation, abort.signal, - (pubkey) => runtime.name(agent.key, pubkey), ) .then( (transcript): Read => diff --git a/src/bundled/github/SoloPull.test.tsx b/src/bundled/github/SoloPull.test.tsx index 2009b326e9..ddbe3f227c 100644 --- a/src/bundled/github/SoloPull.test.tsx +++ b/src/bundled/github/SoloPull.test.tsx @@ -8,7 +8,7 @@ import type { RelayData } from "../../features/relay/service"; import type { RelaySession } from "../../features/relay/session"; import type { AddedTool } from "../../buzz-mcp/session-tools"; import type { BuzzClient } from "../../buzz-mcp/client"; -import { apply } from "./index"; +import { agent, apply } from "./index"; import type { SoloPull, SoloPulls } from "./solo-pull"; import { SoloPullIcon, SoloPullLink, SoloPullMenu } from "./SoloPull"; @@ -161,7 +161,14 @@ it("gives agents a github_link_pr tool for the Solo chat they work in", async () { id: team, channelType: "stream" }, ]); let tool: AddedTool | undefined; - const inject = vi.fn(); + const agents = { + addTool: (added: AddedTool) => { + if (added.name === "github_link_pr") tool = added; + }, + relay: () => relay, + // The agent side also lets agents follow pull requests. + addAttentionSource: () => {}, + }; apply({ host: {}, panels: { register: () => {} }, @@ -171,22 +178,15 @@ it("gives agents a github_link_pr tool for the Solo chat they work in", async () registerLink: () => {}, }, relay, - agents: { - addTool: (added: AddedTool) => { - if (added.name === "github_link_pr") tool = added; - }, - relay: () => relay, - }, - inject, + agents, + inject: () => {}, } as never); + void agent.apply({ host: {}, agents, effect: () => {} } as never); const client = { key: "a".repeat(64), pubkey: "a".repeat(64), } as BuzzClient; - expect(inject).toHaveBeenCalledWith( - ["agents", "host", "navigation"], - expect.any(Function), - ); + expect(agent.inject).toEqual(["agents", "host"]); expect(tool?.name).toBe("github_link_pr"); await expect( tool?.call(client, { channel: chat }, { url: "https://github.com/block" }), diff --git a/src/bundled/github/attention.tsx b/src/bundled/github/attention.tsx index 860113aa2a..efd139c0c6 100644 --- a/src/bundled/github/attention.tsx +++ b/src/bundled/github/attention.tsx @@ -1,7 +1,8 @@ // How a pull request watch looks in Agent Attention. import { GitPullRequestIcon } from "../../shared/design-system/icons"; import { Button } from "../../shared/design-system/ui/Button"; -import type { AttentionSource } from "../../features/agents/attention-sources"; +import type { SourcePresentation } from "../../features/agents/attention-sources"; +import type { SourceWatch } from "../../features/agents/attention"; import type { Navigation } from "../../features/navigation/controller"; import { pullOf } from "./watch"; @@ -9,7 +10,7 @@ import { pullOf } from "./watch"; * feedback wakes agents. */ export function presentPull( navigation: Pick, -): NonNullable { +): (watch: SourceWatch, title: string) => SourcePresentation { const Authors = () => (