feat(plugins): reach a running agent's composer by reference

useComposers(selector) in any plugin component and composers(selector) on every command context return the composer of a running agent, named by id or by focus, whether or not its tab is open.

A running agent now has one store-backed composer model apart from any mounted tab. The mounted composer plugs its cursor-aware insert in while mounted; otherwise insertText and setPrompt write the agent's saved prompt. model and thinking resolve through the provider catalog with the picker's own function, so a read equals what the picker shows.

The host reads one ComposerModel from context and no longer knows whether it is in an agent, a draft or New workspace. The daemon rejects an unlisted model or thinking option for a running agent, replacing the app-side guard.
This commit is contained in:
Mohamed Boudra
2026-11-04 14:10:40 +01:00
parent a182e47a9b
commit e64b0709d2
42 changed files with 1621 additions and 272 deletions
+2
View File
@@ -1,3 +1,4 @@
import { pluginComposersSource } from "./composer-items/source";
import { callPluginRpc } from "@getpaseo/plugin/client/host";
import type {
PluginAgentCommandContext,
@@ -40,6 +41,7 @@ export function createPluginCapabilities(
navigation.openSettings(plugin.id, screenId);
},
openScreen,
composers: (selector) => pluginComposersSource(plugin).resolve(selector),
// COMPAT(pluginSidebarAliases): added in v0.11.0, remove after 2027-03-29
openSurface: (screenId) => openScreen({ screenId }),
};
@@ -149,6 +149,7 @@ function harness() {
},
openScreen() {},
openSurface() {},
composers: () => [],
async playAudio() {},
openPanel() {},
addComposerPill: () => ({ update() {}, remove() {} }),
@@ -6,7 +6,7 @@ import {
ComposerControlSurfaceProvider,
type ComposerControlSurface,
} from "@/composer/agent-controls/composer-control";
import { useComposerAgent, useComposerInput } from "@/composer/session";
import { useComposerModel } from "@/composer/model";
import { PluginItemList, pluginThemeMapping, usePluginItemHost } from "../item-host";
import { PluginEnvironmentProvider } from "../popover";
import { useInstalledPlugins } from "../registry";
@@ -61,19 +61,16 @@ function PluginComposerItemsHost({
plugin: InstalledPlugin;
theme: PluginHostProps["theme"];
}) {
const agent = useComposerAgent();
const composerInput = useComposerInput();
const model = useComposerModel();
const { environment, props } = usePluginItemHost(plugin, theme);
const textSource = composerInput?.textSource;
const prompt = useSyncExternalStore(
textSource?.subscribe ?? subscribeNothing,
textSource?.getSnapshot ?? emptyText,
textSource?.getSnapshot ?? emptyText,
const state = useSyncExternalStore(
model?.subscribe ?? subscribeNothing,
model ? model.getState : noState,
model ? model.getState : noState,
);
const composer = useMemo(
() =>
agent && composerInput ? toPluginComposer({ plugin, agent, composerInput, prompt }) : null,
[agent, composerInput, plugin, prompt],
() => (model && state ? toPluginComposer({ plugin, model, state }) : null),
[model, plugin, state],
);
if (!composer) return null;
return (
@@ -91,6 +88,6 @@ function subscribeNothing() {
return () => undefined;
}
function emptyText() {
return "";
function noState(): null {
return null;
}
@@ -5,6 +5,7 @@ import type { JsonValue } from "@getpaseo/protocol/agent-types";
import { z } from "zod";
import { describe, expect, it } from "vitest";
import type { UserComposerAttachment } from "@/attachments/types";
import { composerModelFromHalves } from "@/composer/halves-model";
import type { ComposerAgent, ComposerInput } from "@/composer/session";
import type { InstalledPlugin } from "../types";
import { toPluginComposer } from "./model";
@@ -112,16 +113,17 @@ function fakeInput(initial: UserComposerAttachment[] = [], locked = false) {
return { input, state };
}
function compose(agent: ComposerAgent, input: ComposerInput) {
const model = composerModelFromHalves({ agent, input });
return toPluginComposer({ plugin: installed(), model, state: model.getState() });
}
describe("toPluginComposer", () => {
it("reads the agent the composer configures and the context it sits in", () => {
const { agent } = fakeAgent("new-workspace");
const { input } = fakeInput();
const composer = toPluginComposer({
plugin: installed(),
agent,
composerInput: input,
prompt: "hello",
});
const { input, state } = fakeInput();
state.text = "hello";
const composer = compose(agent, input);
expect(composer.context).toBe("new-workspace");
expect(composer.prompt).toBe("hello");
expect(composer.agent).toEqual({
@@ -139,12 +141,7 @@ describe("toPluginComposer", () => {
async (context) => {
const { agent, calls } = fakeAgent(context);
const { input } = fakeInput();
const composer = toPluginComposer({
plugin: installed(),
agent,
composerInput: input,
prompt: "",
});
const composer = compose(agent, input);
await composer.setModel("gpt");
await composer.setMode("plan");
await composer.setThinking("high");
@@ -163,35 +160,20 @@ describe("toPluginComposer", () => {
it("lets a draft change provider and tells a running agent it cannot", async () => {
const { input } = fakeInput();
const draft = fakeAgent("draft");
await toPluginComposer({
plugin: installed(),
agent: draft.agent,
composerInput: input,
prompt: "",
}).setProvider("codex");
await compose(draft.agent, input).setProvider("codex");
expect(draft.calls).toEqual(["provider:codex"]);
const running = fakeAgent("agent");
await expect(
toPluginComposer({
plugin: installed(),
agent: running.agent,
composerInput: input,
prompt: "",
}).setProvider("codex"),
).rejects.toThrow("A running agent cannot change provider.");
await expect(compose(running.agent, input).setProvider("codex")).rejects.toThrow(
"A running agent cannot change provider.",
);
expect(running.calls).toEqual([]);
});
it("changes the prompt and inserts at the cursor", () => {
const { agent } = fakeAgent("draft");
const { input, state } = fakeInput();
const composer = toPluginComposer({
plugin: installed(),
agent,
composerInput: input,
prompt: "",
});
const composer = compose(agent, input);
composer.setPrompt("a");
composer.insertText("b");
expect(state.text).toBe("ab");
@@ -200,12 +182,7 @@ describe("toPluginComposer", () => {
it("refuses every prompt and agent command while the composer is locked", async () => {
const { agent, calls } = fakeAgent("new-workspace", true);
const { input, state } = fakeInput([], true);
const composer = toPluginComposer({
plugin: installed(),
agent,
composerInput: input,
prompt: "",
});
const composer = compose(agent, input);
expect(() => composer.setPrompt("a")).toThrow("The composer is locked");
expect(() => composer.insertText("b")).toThrow("The composer is locked");
@@ -225,12 +202,7 @@ describe("toPluginComposer", () => {
it("refuses a command a plugin kept from before the composer locked", async () => {
const { agent } = fakeAgent("new-workspace");
const { input } = fakeInput();
const composer = toPluginComposer({
plugin: installed(),
agent,
composerInput: input,
prompt: "",
});
const composer = compose(agent, input);
// The plugin's action was awaiting an RPC when the user submitted.
const lock = { locked: true };
@@ -251,12 +223,7 @@ describe("toPluginComposer", () => {
const { agent } = fakeAgent("draft");
agent.metadata = { nested: { list: [1] } };
const { input } = fakeInput();
const composer = toPluginComposer({
plugin: installed(),
agent,
composerInput: input,
prompt: "",
});
const composer = compose(agent, input);
expect(Object.isFrozen(composer.agent.options)).toBe(true);
expect(Object.isFrozen(composer.agent.metadata)).toBe(true);
@@ -276,23 +243,13 @@ describe("toPluginComposer", () => {
item: issue,
};
const { input, state } = fakeInput([other]);
const composer = toPluginComposer({
plugin: installed(),
agent,
composerInput: input,
prompt: "",
});
const composer = compose(agent, input);
composer.addAttachment({ sourceId: "issues", item: issue });
composer.addAttachment({ sourceId: "issues", item: issue });
expect(state.attachments).toHaveLength(2);
const next = toPluginComposer({
plugin: installed(),
agent,
composerInput: input,
prompt: "",
});
const next = compose(agent, input);
expect(next.attachments).toEqual([{ sourceId: "issues", item: issue }]);
composer.removeAttachment({ sourceId: "issues", itemId: "ISS-1" });
@@ -302,12 +259,7 @@ describe("toPluginComposer", () => {
it("rejects an attachment from a source the plugin did not register", () => {
const { agent } = fakeAgent("agent");
const { input } = fakeInput();
const composer = toPluginComposer({
plugin: installed(),
agent,
composerInput: input,
prompt: "",
});
const composer = compose(agent, input);
expect(() => composer.addAttachment({ sourceId: "nope", item: issue })).toThrow(
"Unknown attachment source: nope",
);
@@ -1,7 +1,7 @@
import type { JsonValue } from "@getpaseo/protocol/agent-types";
import type { PluginComposer } from "@getpaseo/plugin/client";
import type { UserComposerAttachment } from "@/attachments/types";
import type { ComposerAgent, ComposerInput } from "@/composer/session";
import type { ComposerModel, ComposerState } from "@/composer/model";
import type { InstalledPlugin } from "../types";
import { createPluginResourceAttachment } from "../attachments/model";
import { cloneAndFreeze } from "../freeze";
@@ -20,55 +20,36 @@ function isPluginAttachment(
);
}
const LOCKED_MESSAGE = "The composer is locked while the agent is being created.";
function assertUnlocked(isLocked: () => boolean): void {
if (isLocked()) throw new Error(LOCKED_MESSAGE);
}
async function whenUnlocked(agent: ComposerAgent, command: () => Promise<void>): Promise<void> {
assertUnlocked(agent.isLocked);
await command();
}
/**
* What a plugin sees of the composer it sits in. `agent` and `input` are the composer's own
* halves, so the plugin's commands reach the same models the built-in controls use.
* What a plugin sees of a composer. Reads are `state`; every command goes to the model, so the
* mounted composer, a panel's handle and a Command Center item all reach the same one.
*/
export function toPluginComposer(input: {
plugin: InstalledPlugin;
agent: ComposerAgent;
composerInput: ComposerInput;
prompt: string;
model: ComposerModel;
state: ComposerState;
}): PluginComposer {
const { plugin, agent, composerInput, prompt } = input;
const { plugin, model, state } = input;
return {
context: agent.context,
prompt,
attachments: composerInput.attachments.flatMap((attachment) =>
context: state.context,
prompt: state.prompt,
attachments: state.attachments.flatMap((attachment) =>
attachment.kind === "plugin_resource" && attachment.pluginId === plugin.id
? [{ sourceId: attachment.sourceId, item: attachment.item }]
: [],
),
// Provider options are JSON on the wire.
agent: {
provider: agent.provider,
model: agent.model,
mode: agent.mode,
thinking: agent.thinking,
options: cloneAndFreeze(agent.options as Record<string, JsonValue>),
metadata: cloneAndFreeze(agent.metadata),
},
setPrompt: (text) => {
assertUnlocked(composerInput.isLocked);
composerInput.setText(text);
},
insertText: (text) => {
assertUnlocked(composerInput.isLocked);
composerInput.insertText(text);
provider: state.provider,
model: state.model,
mode: state.mode,
thinking: state.thinking,
options: cloneAndFreeze(state.options as Record<string, JsonValue>),
metadata: cloneAndFreeze(state.metadata),
},
setPrompt: (text) => model.setPrompt(text),
insertText: (text) => model.insertText(text),
addAttachment({ sourceId, item }) {
assertUnlocked(composerInput.isLocked);
const source = plugin.attachmentSources.find((candidate) => candidate.id === sourceId);
if (!source) throw new Error(`Unknown attachment source: ${sourceId}`);
const attachment = createPluginResourceAttachment(
@@ -80,24 +61,22 @@ export function toPluginComposer(input: {
},
item,
);
composerInput.changeAttachments((current) =>
model.changeAttachments((current) =>
current.some((candidate) => isPluginAttachment(candidate, plugin.id, sourceId, item.id))
? current
: [...current, attachment],
);
},
removeAttachment({ sourceId, itemId }) {
assertUnlocked(composerInput.isLocked);
composerInput.changeAttachments((current) =>
model.changeAttachments((current) =>
current.filter((candidate) => !isPluginAttachment(candidate, plugin.id, sourceId, itemId)),
);
},
setProvider: (provider) => whenUnlocked(agent, () => agent.setProvider(provider)),
setModel: (modelId) => whenUnlocked(agent, () => agent.setModel(modelId)),
setMode: (modeId) => whenUnlocked(agent, () => agent.setMode(modeId)),
setThinking: (thinkingOptionId) =>
whenUnlocked(agent, () => agent.setThinking(thinkingOptionId)),
setOptions: (patch) => whenUnlocked(agent, () => agent.setOptions(patch)),
setMetadata: (patch) => whenUnlocked(agent, () => agent.setMetadata(patch)),
setProvider: (provider) => model.setProvider(provider),
setModel: (modelId) => model.setModel(modelId),
setMode: (modeId) => model.setMode(modeId),
setThinking: (thinkingOptionId) => model.setThinking(thinkingOptionId),
setOptions: (patch) => model.setOptions(patch),
setMetadata: (patch) => model.setMetadata(patch),
};
}
@@ -0,0 +1,88 @@
import type { PluginComposer, PluginComposerSelector } from "@getpaseo/plugin/client";
import type { PluginComposersSource } from "@getpaseo/plugin/client/host";
import { agentComposer } from "@/composer/agent-composer";
import type { ComposerState } from "@/composer/model";
import { queryClient } from "@/data/query-client";
import { useDraftStore } from "@/stores/draft-store";
import {
getLastWorkspaceSelection,
subscribeLastWorkspaceSelection,
} from "@/stores/navigation-active-workspace-store";
import { useSessionStore } from "@/stores/session-store";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { getFocusedAgentId } from "../command-center/context";
import type { InstalledPlugin } from "../types";
import { toPluginComposer } from "./model";
const NO_COMPOSERS: readonly PluginComposer[] = Object.freeze([]);
const sources = new WeakMap<InstalledPlugin, PluginComposersSource>();
/**
* The composers a plugin reaches by reference: one per running agent, open in a tab or not. The
* same source serves `useComposers` in every surface and `composers()` in every command.
*/
export function pluginComposersSource(plugin: InstalledPlugin): PluginComposersSource {
let source = sources.get(plugin);
if (!source) {
source = createSource(plugin);
sources.set(plugin, source);
}
return source;
}
function createSource(plugin: InstalledPlugin): PluginComposersSource {
const { serverId } = plugin;
// Handles and result arrays are reused while nothing they read has changed, so a subscriber
// that re-reads on every notification renders only when its composers did change.
const handles = new WeakMap<ComposerState, PluginComposer>();
const results = new WeakMap<PluginComposer, readonly PluginComposer[]>();
function runningAgentId(agentId: string): string | null {
const session = useSessionStore.getState().sessions[serverId];
const agent = session?.agents.get(agentId) ?? session?.agentDetails.get(agentId) ?? null;
return agent && !agent.archivedAt ? agent.id : null;
}
function focusedAgentId(): string | null {
const selection = getLastWorkspaceSelection();
if (selection?.serverId !== serverId) return null;
const layout =
useWorkspaceLayoutStore.getState().layoutByWorkspace[
`${selection.serverId}:${selection.workspaceId}`
] ?? null;
return getFocusedAgentId(layout);
}
return {
subscribe(listener) {
const stops = [
useSessionStore.subscribe(listener),
useDraftStore.subscribe(listener),
useWorkspaceLayoutStore.subscribe(listener),
subscribeLastWorkspaceSelection(listener),
queryClient.getQueryCache().subscribe(listener),
];
return () => {
for (const stop of stops) stop();
};
},
resolve(selector: PluginComposerSelector) {
const requested = "agentId" in selector ? selector.agentId : focusedAgentId();
const agentId = requested === null ? null : runningAgentId(requested);
if (agentId === null) return NO_COMPOSERS;
const model = agentComposer(serverId, agentId);
const state = model.getState();
let composer = handles.get(state);
if (!composer) {
composer = toPluginComposer({ plugin, model, state });
handles.set(state, composer);
}
let result = results.get(composer);
if (!result) {
result = Object.freeze([composer]);
results.set(composer, result);
}
return result;
},
};
}
+1
View File
@@ -110,6 +110,7 @@ export type PluginClientRuntime = Pick<
| "openSettings"
| "openScreen"
| "openSurface"
| "composers"
| "openPanel"
| "subscribe"
| "addComposerPill"
@@ -1,10 +1,12 @@
import { QueryClientProvider } from "@tanstack/react-query";
import {
PaseoApiProvider,
PluginComposersProvider,
PluginRpcProvider,
PluginTranslationProvider,
} from "@getpaseo/plugin/client/host";
import React, { type ReactNode } from "react";
import { pluginComposersSource } from "./composer-items/source";
import type { InstalledPlugin } from "./types";
/** Every plugin surface renders under its installation: one query cache, one Paseo client, RPCs. */
@@ -19,7 +21,11 @@ export function PluginInstallationProvider({
<QueryClientProvider client={plugin.queryClient}>
<PaseoApiProvider paseo={plugin.paseo}>
<PluginRpcProvider invoke={plugin.invoke} subscribe={plugin.subscribe}>
<PluginTranslationProvider runtime={plugin.i18n}>{children}</PluginTranslationProvider>
<PluginTranslationProvider runtime={plugin.i18n}>
<PluginComposersProvider source={pluginComposersSource(plugin)}>
{children}
</PluginComposersProvider>
</PluginTranslationProvider>
</PluginRpcProvider>
</PaseoApiProvider>
</QueryClientProvider>
@@ -57,6 +57,7 @@ function registry(
},
openScreen: () => {},
openSurface: () => {},
composers: () => [],
openSettings: () => {},
playAudio: async () => {},
openPanel: () => {},
@@ -35,6 +35,7 @@ function registry(version: string) {
},
openScreen() {},
openSurface() {},
composers: () => [],
async playAudio() {},
openPanel() {},
addComposerPill: () => ({ update() {}, remove() {} }),