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 08:10:28 +01:00
parent 6c9468e083
commit 0aeb1ae1f9
42 changed files with 1621 additions and 272 deletions
+45
View File
@@ -250,6 +250,51 @@ describe("plugin composer SDK boundary", () => {
});
});
describe("plugin composers by reference SDK boundary", () => {
it("reaches a running agent's composer from any component and from every command context", () => {
expect(
typeErrors(`
import { useComposers, type PluginClientContext } from "@getpaseo/plugin/client";
function Button() {
const [focused] = useComposers({ focused: true });
const byId = useComposers({ agentId: "agent-1" });
if (!focused) return null;
const first: string = byId[0]?.prompt ?? "";
focused.insertText("text");
focused.setMetadata({ flag: true });
// @ts-expect-error a selector names an agent or the focused agent
useComposers({ active: true });
// @ts-expect-error focused is true or absent
useComposers({ focused: false });
return null;
}
export default function contribute(client: PluginClientContext) {
client.addWorkspacePanel({ id: "panel", title: "P", icon: "File", context: "workspace", Component: Button });
client.addCommandCenterItem({
id: "mark",
title: "Mark",
icon: "Flag",
context: "agent",
async onSelect(context) {
for (const composer of context.composers({ agentId: context.agent.id })) {
await composer.setMetadata({ review: true });
}
},
});
client.addCommandCenterItem({
id: "global",
title: "Global",
icon: "Flag",
context: "global",
onSelect: ({ composers }) => composers({ focused: true })[0]?.insertText("x"),
});
return () => {};
}
`),
).toEqual([]);
});
});
describe("plugin New workspace SDK boundary", () => {
it("lets a New workspace item read the setup and change it through typed commands and a Combobox", () => {
expect(
+52 -1
View File
@@ -1,4 +1,10 @@
import { createContext, useContext, type ComponentType, type ReactNode } from "react";
import {
createContext,
useContext,
useSyncExternalStore,
type ComponentType,
type ReactNode,
} from "react";
import type { JsonValue } from "@getpaseo/protocol/agent-types";
import type { PluginAttachmentItem } from "../attachments.js";
import type { PluginHostProps } from "./contracts.js";
@@ -76,3 +82,48 @@ export function useComposer(): PluginComposer {
if (!composer) throw new Error("useComposer must run inside a composer item");
return composer;
}
/**
* Names the running agent whose composer a plugin wants. Both forms resolve to the same
* `PluginComposer` an item of that agent's composer gets from `useComposer()`.
*/
export type PluginComposerSelector = { agentId: string } | { focused: true };
/** What `useComposers` and `composers(selector)` read. Paseo implements it per installed plugin. */
export interface PluginComposersSource {
subscribe(listener: () => void): () => void;
/** The composers `selector` names now: one for a known running agent, none otherwise. */
resolve(selector: PluginComposerSelector): readonly PluginComposer[];
}
const PluginComposersContext = createContext<PluginComposersSource | null>(null);
export function PluginComposersProvider({
source,
children,
}: {
source: PluginComposersSource;
children: ReactNode;
}) {
return (
<PluginComposersContext.Provider value={source}>{children}</PluginComposersContext.Provider>
);
}
export function usePluginComposersSource(): PluginComposersSource | null {
return useContext(PluginComposersContext);
}
/**
* The composers `selector` names, whether or not their tabs are open. Runs in any plugin
* component. Empty for an unknown agent, or for `{ focused: true }` when no agent is focused.
*/
export function useComposers(selector: PluginComposerSelector): readonly PluginComposer[] {
const source = useContext(PluginComposersContext);
if (!source) throw new Error("useComposers must run inside a plugin component");
return useSyncExternalStore(
source.subscribe,
() => source.resolve(selector),
() => source.resolve(selector),
);
}
+7 -1
View File
@@ -4,7 +4,11 @@ import type { PaseoApi } from "@getpaseo/client";
import type { AgentTimelineItem, JsonValue } from "@getpaseo/protocol/agent-types";
import type { ZodType, input as ZodInput, output as ZodOutput } from "zod";
import type { PluginFile } from "./file.js";
import type { PluginComposerItemContribution } from "./composer.js";
import type {
PluginComposer,
PluginComposerItemContribution,
PluginComposerSelector,
} from "./composer.js";
import type { PluginNewWorkspaceItemContribution } from "./new-workspace.js";
import type {
PluginRpcContract,
@@ -318,6 +322,8 @@ export interface PluginCommandCapabilities {
input: PluginWireInput<ZodInput<InputSchema>>,
): Promise<ZodOutput<OutputSchema>>;
openScreen(input: PluginOpenScreenInput): void;
/** The composers `selector` names, whether or not their tabs are open; see `useComposers`. */
composers(selector: PluginComposerSelector): readonly PluginComposer[];
/** @deprecated Use `openScreen`. */
openSurface(id: string): void;
openSettings(id: string): void;
+5 -1
View File
@@ -1,7 +1,11 @@
import type { PluginText } from "../text.js";
import { PluginAttachmentSearchPayloadSchema } from "../attachments.js";
export { PluginClientStateProvider, type PluginClientStateSource } from "./client-state.js";
export { PluginComposerProvider } from "./composer.js";
export {
PluginComposerProvider,
PluginComposersProvider,
type PluginComposersSource,
} from "./composer.js";
export { PluginNewWorkspaceProvider } from "./new-workspace.js";
export {
usePluginRuntimeContextBridge,
+2 -1
View File
@@ -71,12 +71,13 @@ export {
} from "./file.js";
export { usePaseo } from "./paseo-context.js";
export { useAgent, useWorkspace } from "./client-state.js";
export { useComposer } from "./composer.js";
export { useComposer, useComposers } from "./composer.js";
export type {
PluginComposer,
PluginComposerAttachment,
PluginComposerItemContribution,
PluginComposerItemProps,
PluginComposerSelector,
} from "./composer.js";
export { useNewWorkspace } from "./new-workspace.js";
export type {
@@ -1,6 +1,11 @@
import { PluginTranslationProvider, usePluginTranslationRuntime } from "./translations.js";
import { useCallback, type ReactNode } from "react";
import { PluginComposerProvider, usePluginComposerValue } from "./composer.js";
import {
PluginComposerProvider,
PluginComposersProvider,
usePluginComposerValue,
usePluginComposersSource,
} from "./composer.js";
import { PluginClientStateProvider, usePluginClientStateSource } from "./client-state.js";
import { PaseoApiProvider, usePaseoContextValue } from "./paseo-context.js";
import { PluginRpcProvider, usePluginRpcContextValue } from "./rpc-context.js";
@@ -14,6 +19,7 @@ export function usePluginRuntimeContextBridge(): PluginRuntimeContextBridge {
const rpc = usePluginRpcContextValue();
const state = usePluginClientStateSource();
const composer = usePluginComposerValue();
const composers = usePluginComposersSource();
if (!paseo || !rpc) {
throw new Error("Plugin UI must run inside a contributed plugin surface");
@@ -26,11 +32,16 @@ export function usePluginRuntimeContextBridge(): PluginRuntimeContextBridge {
) : (
children
);
const content = composer ? (
<PluginComposerProvider composer={composer}>{withState}</PluginComposerProvider>
const withComposers = composers ? (
<PluginComposersProvider source={composers}>{withState}</PluginComposersProvider>
) : (
withState
);
const content = composer ? (
<PluginComposerProvider composer={composer}>{withComposers}</PluginComposerProvider>
) : (
withComposers
);
return (
<PaseoApiProvider paseo={paseo}>
<PluginRpcProvider invoke={rpc.invoke} subscribe={rpc.subscribe}>
@@ -39,6 +50,6 @@ export function usePluginRuntimeContextBridge(): PluginRuntimeContextBridge {
</PaseoApiProvider>
);
},
[composer, paseo, rpc, state, translations],
[composer, composers, paseo, rpc, state, translations],
);
}