feat(sdk,plugins): typage et stabilisation runtime UI/layout publique (#128)

This commit is contained in:
2026-08-02 13:36:17 +02:00
parent e3e887d6a4
commit 8c4f1ea2e3
9 changed files with 1090 additions and 20 deletions

View File

@ -30,7 +30,10 @@ function cell(overrides: Partial<CustomPluginLayoutCell> = {}): CustomPluginLayo
};
}
let latestLayoutProps: PluginLayoutProps | null = null;
function MockLayoutComponent(props: PluginLayoutProps) {
latestLayoutProps = props;
return (
<div>
<p data-testid="state">{JSON.stringify(props.state)}</p>
@ -87,8 +90,11 @@ describe("PluginLayoutCellView", () => {
it("renders the mock registered component when the provider is loaded", () => {
const registry = new PluginRuntimeRegistry();
registry.add(stubPlugin());
latestLayoutProps = null;
renderCell(registry);
expect(screen.getByTestId("state").textContent).toBe(JSON.stringify({ commits: 3 }));
expect(latestLayoutProps).not.toBeNull();
expect(Object.prototype.hasOwnProperty.call(latestLayoutProps, "gateways")).toBe(false);
});
it("round-trips state through setState → onStateChange", () => {

View File

@ -13,7 +13,6 @@
import { Component, type ErrorInfo, type ReactNode } from "react";
import type { CustomPluginLayoutCell, PluginAdmin } from "@/domain";
import { useGateways } from "@/app/di";
import { usePluginRuntime } from "./PluginRuntimeProvider";
import { PluginLayoutFallback } from "./PluginLayoutFallback";
import { resolvePluginLayoutAvailability } from "./layoutAvailability";
@ -68,7 +67,6 @@ export function PluginLayoutCellView({
onChooseAnotherLayout,
}: PluginLayoutCellViewProps) {
const { registry } = usePluginRuntime();
const gateways = useGateways();
const availability = resolvePluginLayoutAvailability(registry, cell, installedPlugins);
const providerDisplayName =
registry.get(cell.pluginId)?.displayName ??
@ -108,15 +106,6 @@ export function PluginLayoutCellView({
state={cell.state}
setState={onStateChange}
availability="available"
gateways={{
project: gateways.project,
git: gateways.git,
terminal: gateways.terminal,
agents: gateways.agent,
system: gateways.system,
workState: gateways.workState,
focusedProject: gateways.focusedProject,
}}
/>
</PluginLayoutErrorBoundary>
);

View File

@ -75,6 +75,11 @@ export function PluginRuntimeProvider({ children, value: injected }: PluginRunti
system: gateways.system,
workState: gateways.workState,
focusedProject: gateways.focusedProject,
pluginWorkspace: gateways.pluginWorkspace,
pluginTask: gateways.pluginTask,
pluginToolchain: gateways.pluginToolchain,
pluginEvents: gateways.pluginEvents,
pluginConfig: gateways.pluginConfig,
}),
)
.then((result) => {

View File

@ -24,12 +24,52 @@ export {
type BackgroundTaskRetryResult,
type BackgroundTaskService,
type BackgroundTaskStatus,
type ConfigDocument,
type ConfigDocumentFormat,
type ConfigDocumentReadOptions,
type ConfigDocumentService,
type ConfigDocumentUpdateOptions,
type ConfigDocumentWriteResult,
type ConfigUpdateMode,
type CommandTaskStatus,
type DiagnosticMessage,
type EnvDiagnostic,
type EnvRequirement,
type EventHandler,
type EventService,
type EventSubscribeOptions,
type EventSubscription,
type FileDiagnostic,
type FileRequirement,
type PluginServices,
type PublicEvent,
type PublicEventType,
type RunCommandTaskOptions,
type TerminalOpenOptions,
type TerminalReattachOptions,
type TerminalReattachResult,
type TerminalService,
type TerminalSession,
type ToolchainDiagnostic,
type ToolchainDiagnosticRequest,
type ToolDiagnostic,
type ToolingService,
type ToolRequirement,
type ProjectConvention,
type ProjectModule,
type ProjectStructure,
type ProjectStructureEntry,
type ProjectStructureEntryKind,
type WorkspaceBinaryFile,
type WorkspaceDirEntry,
type WorkspaceDirectoryListing,
type WorkspaceResolvedPath,
type WorkspaceStat,
type WorkspaceStructureQuery,
type WorkspaceTextFile,
type WorkspaceWatch,
type WorkspaceWatchEvent,
type WorkspaceWatchHandler,
type WorkspaceProject,
type WorkspaceService,
} from "./services";

View File

@ -196,9 +196,27 @@ describe("loadPlugins", () => {
it("injects the public plugin services facade for plugins declaring tooling", async () => {
const bundle = dataUrl(`
export function activate(ctx) {
globalThis.__activationContextKeys = Object.keys(ctx).sort();
globalThis.__hasPrivateGateway = [
"project",
"git",
"terminal",
"agents",
"system",
"workState",
"focusedProject",
"pluginWorkspace",
"pluginTask",
"pluginToolchain",
"pluginEvents",
"pluginConfig",
].some((key) => key in ctx);
globalThis.__serviceKeys = Object.keys(ctx.services).sort();
globalThis.__workspaceServiceKeys = Object.keys(ctx.services.workspace).sort();
globalThis.__taskServiceKeys = Object.keys(ctx.services.tasks).sort();
globalThis.__toolingServiceKeys = Object.keys(ctx.services.tooling).sort();
globalThis.__eventServiceKeys = Object.keys(ctx.services.events).sort();
globalThis.__configServiceKeys = Object.keys(ctx.services.config).sort();
globalThis.__terminalServiceKeys = Object.keys(ctx.services.terminal).sort();
}
`);
@ -215,23 +233,59 @@ describe("loadPlugins", () => {
);
expect(failures).toEqual([]);
expect((globalThis as Record<string, unknown>).__activationContextKeys).toEqual([
"commands",
"layouts",
"logger",
"menu",
"pluginDisplayName",
"pluginId",
"services",
"subscriptions",
"version",
]);
expect((globalThis as Record<string, unknown>).__hasPrivateGateway).toBe(false);
expect((globalThis as Record<string, unknown>).__serviceKeys).toEqual([
"config",
"events",
"tasks",
"terminal",
"tooling",
"workspace",
]);
expect((globalThis as Record<string, unknown>).__workspaceServiceKeys).toEqual([
"getCurrentProject",
"getProjectRoot",
"listDirectory",
"queryStructure",
"readBinaryFile",
"readProjectContext",
"readTextFile",
"resolvePath",
"stat",
"updateProjectContext",
"watch",
"writeBinaryFile",
"writeTextFile",
]);
expect((globalThis as Record<string, unknown>).__taskServiceKeys).toEqual([
"attachOutput",
"cancel",
"getCommandStatus",
"getStatus",
"list",
"retry",
"runCommand",
]);
expect((globalThis as Record<string, unknown>).__toolingServiceKeys).toEqual([
"diagnose",
]);
expect((globalThis as Record<string, unknown>).__eventServiceKeys).toEqual([
"subscribe",
]);
expect((globalThis as Record<string, unknown>).__configServiceKeys).toEqual([
"readDocument",
"updateDocument",
]);
expect((globalThis as Record<string, unknown>).__terminalServiceKeys).toEqual([
"close",

View File

@ -3,8 +3,8 @@
*
* At UI bootstrap, the app calls {@link loadPlugins} once with the catalog
* from `PluginGateway.listRuntimeContributions()` (already filtered by the
* backend to `enabled && !pendingUninstall`, carnet §1.3) and the stable
* gateways the plugin context exposes. For each entry it dynamically imports
* backend to `enabled && !pendingUninstall`, carnet §1.3) and the gateway set
* used to build the public service facade. For each entry it dynamically imports
* the bundle URL, validates the module shape, and calls `activate(ctx)`,
* scoping the command/layout registries to exactly the ids declared in that
* plugin's manifest (enforced by {@link PluginCommandRegistry}/
@ -29,7 +29,7 @@ import { createPluginServices, type PluginServices } from "./services";
export type { PluginGatewaySet } from "./registry";
export interface IdeaPluginContext extends PluginGatewaySet {
export interface IdeaPluginContext {
pluginId: string;
pluginDisplayName: string;
version: string;
@ -251,7 +251,6 @@ async function loadOne(
commands: createCommandContext(commands),
layouts,
menu,
...gateways,
};
if (hasCapability(entry, "tooling")) {
ctx.services = createPluginServices(gateways);

View File

@ -23,13 +23,18 @@ import type {
AgentGateway,
FocusedProjectGateway,
GitGateway,
PluginConfigGateway,
PluginEventGateway,
PluginTaskGateway,
PluginToolchainGateway,
PluginWorkspaceGateway,
ProjectGateway,
SystemGateway,
TerminalGateway,
WorkStateGateway,
} from "@/ports";
/** The stable gateways a plugin's `activate(ctx)` is allowed to reach (carnet §6). */
/** Internal gateways used to build the public plugin service facade. */
export interface PluginGatewaySet {
project: ProjectGateway;
git: GitGateway;
@ -38,6 +43,11 @@ export interface PluginGatewaySet {
system: SystemGateway;
workState: WorkStateGateway;
focusedProject: FocusedProjectGateway;
pluginWorkspace: PluginWorkspaceGateway;
pluginTask: PluginTaskGateway;
pluginToolchain: PluginToolchainGateway;
pluginEvents: PluginEventGateway;
pluginConfig: PluginConfigGateway;
}
/** A disposable handle returned by every `register*` call. */
@ -97,7 +107,6 @@ export interface PluginLayoutProps {
state: unknown;
setState(next: unknown): void;
availability: "available";
gateways: PluginGatewaySet;
}
export interface PluginLayoutDefinition {

View File

@ -4,6 +4,11 @@ import type { ProjectWorkState } from "@/domain";
import type {
BackgroundTaskAttachment,
FocusedProjectGateway,
PluginConfigGateway,
PluginEventGateway,
PluginTaskGateway,
PluginToolchainGateway,
PluginWorkspaceGateway,
ProjectGateway,
ReattachResult,
TerminalGateway,
@ -27,6 +32,11 @@ function gateways(overrides: {
project?: Partial<ProjectGateway>;
workState?: Partial<WorkStateGateway>;
terminal?: Partial<TerminalGateway>;
pluginWorkspace?: Partial<PluginWorkspaceGateway>;
pluginTask?: Partial<PluginTaskGateway>;
pluginToolchain?: Partial<PluginToolchainGateway>;
pluginEvents?: Partial<PluginEventGateway>;
pluginConfig?: Partial<PluginConfigGateway>;
} = {}) {
const focusedProject: FocusedProjectGateway = {
setFocusedProject: vi.fn(),
@ -79,8 +89,163 @@ function gateways(overrides: {
closeTerminal: vi.fn(),
...overrides.terminal,
};
const pluginWorkspace: PluginWorkspaceGateway = {
readText: vi.fn(async ({ path }) => ({ path, content: "file text" })),
readBinary: vi.fn(async ({ path }) => ({ path, bytes: new Uint8Array([67]) })),
writeText: vi.fn(),
writeBinary: vi.fn(),
listDir: vi.fn(async ({ path }) => ({
path,
entries: [{ name: "main.ts", path: `${path}/main.ts`, isDir: false }],
})),
stat: vi.fn(async ({ path }) => ({
path,
exists: true,
isFile: true,
isDir: false,
len: 9,
})),
queryProjectStructure: vi.fn(async ({ projectId, path }) => ({
projectId,
rootPath: path ?? "",
entries: [{ path: "package.json", name: "package.json", kind: "file" as const }],
conventions: [{ id: "node-package", markerPath: "package.json" }],
modules: [{ path: "", markerPath: "package.json", conventionId: "node-package" }],
truncated: false,
})),
...overrides.pluginWorkspace,
};
const pluginTask: PluginTaskGateway = {
runCommand: vi.fn(async (input) => ({
taskId: "task-command-1",
ownerAgentId: input.ownerAgentId,
projectId: input.projectId,
kind: "command",
state: "running" as const,
exitCode: null,
summary: input.label,
stdoutTail: null,
stderrTail: null,
createdAtMs: 10,
updatedAtMs: 10,
})),
getStatus: vi.fn(async ({ taskId }) => ({
taskId,
ownerAgentId: "agent-1",
projectId: "project-1",
kind: "command",
state: "completed" as const,
exitCode: 0,
summary: "ok",
stdoutTail: "done",
stderrTail: null,
createdAtMs: 10,
updatedAtMs: 20,
})),
...overrides.pluginTask,
};
const pluginToolchain: PluginToolchainGateway = {
diagnose: vi.fn(async ({ projectId, cwd }) => ({
projectId,
cwd: cwd ?? "",
ok: true,
tools: [
{
id: "node",
executable: "node",
present: true,
ok: true,
status: "ok" as const,
required: true,
exitCode: 0,
version: "v20.0.0",
stdout: "v20.0.0\n",
stderr: null,
error: null,
},
],
env: [
{
name: "CI",
present: true,
ok: true,
required: false,
value: "1",
status: "ok" as const,
},
],
files: [
{
path: "package.json",
exists: true,
ok: true,
required: true,
kind: "file" as const,
expectedKind: "file" as const,
len: 42,
},
],
messages: [],
})),
...overrides.pluginToolchain,
};
const pluginEvents: PluginEventGateway = {
subscribe: vi.fn(async ({ projectId, eventTypes, capacity }) => ({
subscriptionId: "subscription-1",
projectId,
eventTypes: eventTypes?.length
? eventTypes
: ["workspaceFileChanged", "backgroundTaskChanged"],
capacity: capacity ?? 100,
retention: "bestEffortBounded",
})),
poll: vi.fn(async ({ subscriptionId }) => ({
subscriptionId,
events: [],
dropped: 0,
})),
unsubscribe: vi.fn(async ({ subscriptionId }) => ({
subscriptionId,
projectId: "project-1",
eventTypes: [],
capacity: 0,
retention: "disposed",
})),
...overrides.pluginEvents,
};
const pluginConfig: PluginConfigGateway = {
readDocument: vi.fn(async ({ projectId, path, format }) => ({
projectId,
path,
format: format ?? "json",
value: { enabled: true, nested: { count: 1 } },
})),
updateDocument: vi.fn(async ({ projectId, path, format, mode }) => ({
projectId,
path,
format: format ?? "json",
mode: mode ?? "mergePatch",
bytesWritten: 42,
})),
...overrides.pluginConfig,
};
return { focusedProject, project, workState, terminal };
return {
focusedProject,
project,
workState,
terminal,
pluginWorkspace,
pluginTask,
pluginToolchain,
pluginEvents,
pluginConfig,
};
}
async function flushMicrotasks(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
}
describe("createPluginServices", () => {
@ -155,6 +320,298 @@ describe("createPluginServices", () => {
expect(detach).toHaveBeenCalled();
});
it("launches command-backed tasks through the public plugin task gateway", async () => {
const g = gateways();
const services = createPluginServices(g);
await expect(
services.tasks.runCommand({
ownerAgentId: "agent-1",
command: "npm",
args: ["test", "--", "workspace"],
cwd: "frontend",
env: { CI: "1" },
recordOnly: true,
deadlineMs: 123_000,
}),
).resolves.toMatchObject({
taskId: "task-command-1",
ownerAgentId: "agent-1",
projectId: "project-1",
state: "running",
summary: "npm test -- workspace",
});
expect(g.pluginTask.runCommand).toHaveBeenCalledWith({
projectId: "project-1",
ownerAgentId: "agent-1",
label: "npm test -- workspace",
command: "npm",
args: ["test", "--", "workspace"],
cwd: "frontend",
env: [["CI", "1"]],
recordOnly: true,
deadlineMs: 123_000,
});
await expect(services.tasks.getCommandStatus("task-command-1")).resolves.toMatchObject({
taskId: "task-command-1",
state: "completed",
exitCode: 0,
stdoutTail: "done",
});
expect(g.pluginTask.getStatus).toHaveBeenCalledWith({ taskId: "task-command-1" });
});
it("delegates workspace file and structure operations through the public plugin gateway", async () => {
const g = gateways();
const services = createPluginServices(g);
await expect(services.workspace.resolvePath("src/main.ts")).resolves.toEqual({
projectId: "project-1",
root: "/workspace/project-one",
path: "src/main.ts",
});
await expect(services.workspace.readTextFile("README.md")).resolves.toEqual({
path: "README.md",
content: "file text",
});
await expect(services.workspace.readBinaryFile("asset.bin")).resolves.toEqual({
path: "asset.bin",
bytes: new Uint8Array([67]),
});
await services.workspace.writeTextFile("generated.txt", "hello");
expect(g.pluginWorkspace.writeText).toHaveBeenCalledWith({
projectId: "project-1",
path: "generated.txt",
content: "hello",
});
await services.workspace.writeBinaryFile("generated.bin", new Uint8Array([1, 2]));
expect(g.pluginWorkspace.writeBinary).toHaveBeenCalledWith({
projectId: "project-1",
path: "generated.bin",
bytes: new Uint8Array([1, 2]),
});
await expect(services.workspace.listDirectory("src")).resolves.toMatchObject({
path: "src",
entries: [{ name: "main.ts", path: "src/main.ts", isDir: false }],
});
await expect(services.workspace.stat("README.md")).resolves.toMatchObject({
path: "README.md",
exists: true,
});
await expect(services.workspace.queryStructure({ maxDepth: 2 })).resolves.toMatchObject({
projectId: "project-1",
conventions: [{ id: "node-package", markerPath: "package.json" }],
});
});
it("delegates generic toolchain diagnostics through the public plugin gateway", async () => {
const g = gateways();
const services = createPluginServices(g);
await expect(
services.tooling.diagnose({
cwd: "frontend",
tools: [
{
id: "node",
executable: "node",
versionArgs: ["--version"],
required: true,
env: { CI: "1" },
},
],
env: [{ name: "CI" }],
files: [{ path: "package.json", required: true, kind: "file" }],
}),
).resolves.toMatchObject({
projectId: "project-1",
ok: true,
tools: [{ id: "node", version: "v20.0.0" }],
});
expect(g.pluginToolchain.diagnose).toHaveBeenCalledWith({
projectId: "project-1",
cwd: "frontend",
tools: [
{
id: "node",
executable: "node",
versionArgs: ["--version"],
required: true,
env: [["CI", "1"]],
},
],
env: [{ name: "CI" }],
files: [{ path: "package.json", required: true, kind: "file" }],
});
});
it("subscribes to public plugin events and disposes the host subscription", async () => {
const handler = vi.fn();
const onDropped = vi.fn();
const g = gateways({
pluginEvents: {
poll: vi.fn(async ({ subscriptionId }) => ({
subscriptionId,
dropped: 2,
events: [
{
type: "backgroundTaskChanged" as const,
sequence: 7,
occurredAtMs: 10,
projectId: "project-1",
taskId: "task-1",
ownerAgentId: "agent-1",
state: "completed",
},
],
})),
},
});
const services = createPluginServices(g);
const subscription = await services.events.subscribe(
{
eventTypes: ["backgroundTaskChanged"],
capacity: 10,
maxEventsPerPoll: 5,
pollIntervalMs: 60_000,
onDropped,
},
handler,
);
await flushMicrotasks();
expect(subscription.subscriptionId).toBe("subscription-1");
expect(g.pluginEvents.subscribe).toHaveBeenCalledWith({
projectId: "project-1",
eventTypes: ["backgroundTaskChanged"],
capacity: 10,
});
expect(g.pluginEvents.poll).toHaveBeenCalledWith({
subscriptionId: "subscription-1",
maxEvents: 5,
});
expect(onDropped).toHaveBeenCalledWith(2);
expect(handler).toHaveBeenCalledWith(
expect.objectContaining({ type: "backgroundTaskChanged", taskId: "task-1" }),
);
subscription.dispose();
expect(g.pluginEvents.unsubscribe).toHaveBeenCalledWith({
subscriptionId: "subscription-1",
});
});
it("implements workspace.watch through public workspace file events", async () => {
const handler = vi.fn();
const g = gateways({
pluginWorkspace: {
stat: vi.fn(async ({ path }) => ({
path,
exists: true,
isFile: false,
isDir: true,
len: null,
})),
},
pluginEvents: {
poll: vi.fn(async ({ subscriptionId }) => ({
subscriptionId,
dropped: 0,
events: [
{
type: "workspaceFileChanged" as const,
sequence: 1,
occurredAtMs: 11,
projectId: "project-1",
path: "src/main.ts",
operation: "writeText",
},
{
type: "workspaceFileChanged" as const,
sequence: 2,
occurredAtMs: 12,
projectId: "project-1",
path: "README.md",
operation: "writeText",
},
],
})),
},
});
const services = createPluginServices(g);
const watch = await services.workspace.watch("src", handler);
await flushMicrotasks();
expect(g.pluginEvents.subscribe).toHaveBeenCalledWith({
projectId: "project-1",
eventTypes: ["workspaceFileChanged"],
capacity: undefined,
});
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith({
path: "src/main.ts",
kind: "created",
operation: "writeText",
projectId: "project-1",
});
watch.dispose();
expect(g.pluginEvents.unsubscribe).toHaveBeenCalledWith({
subscriptionId: "subscription-1",
});
});
it("delegates structured config document operations through the public plugin gateway", async () => {
const g = gateways();
const services = createPluginServices(g);
await expect(
services.config.readDocument({
path: "config/settings.json",
}),
).resolves.toMatchObject({
projectId: "project-1",
path: "config/settings.json",
format: "json",
value: { enabled: true },
});
await expect(
services.config.updateDocument({
path: "config/settings.json",
mode: "mergePatch",
value: { enabled: false, removeMe: null },
}),
).resolves.toMatchObject({
projectId: "project-1",
path: "config/settings.json",
format: "json",
mode: "mergePatch",
bytesWritten: 42,
});
expect(g.pluginConfig.readDocument).toHaveBeenCalledWith({
projectId: "project-1",
path: "config/settings.json",
format: undefined,
});
expect(g.pluginConfig.updateDocument).toHaveBeenCalledWith({
projectId: "project-1",
path: "config/settings.json",
format: undefined,
mode: "mergePatch",
value: { enabled: false, removeMe: null },
});
});
it("opens terminal sessions with project-root defaults and delegates controls", async () => {
const g = gateways();
const services = createPluginServices(g);

View File

@ -1,9 +1,21 @@
import type { BackgroundCompletion } from "@/domain";
import type {
BackgroundCompletion,
JsonValue,
PluginCommandTask,
PluginPublicEvent,
PluginPublicEventType,
} from "@/domain";
import type {
BackgroundTaskAttachment,
FocusedProject,
FocusedProjectGateway,
OpenTerminalOptions,
PluginConfigGateway,
PluginEventGateway,
PluginProjectStructureQuery,
PluginTaskGateway,
PluginToolchainGateway,
PluginWorkspaceGateway,
ProjectGateway,
TerminalGateway,
WorkStateGateway,
@ -12,6 +24,9 @@ import type {
export interface PluginServices {
workspace: WorkspaceService;
tasks: BackgroundTaskService;
tooling: ToolingService;
events: EventService;
config: ConfigDocumentService;
terminal: TerminalService;
}
@ -26,6 +41,98 @@ export interface WorkspaceService {
getProjectRoot(projectId?: string): Promise<string>;
readProjectContext(projectId?: string): Promise<string>;
updateProjectContext(content: string, projectId?: string): Promise<void>;
resolvePath(path: string, projectId?: string): Promise<WorkspaceResolvedPath>;
readTextFile(path: string, projectId?: string): Promise<WorkspaceTextFile>;
readBinaryFile(path: string, projectId?: string): Promise<WorkspaceBinaryFile>;
writeTextFile(path: string, content: string, projectId?: string): Promise<void>;
writeBinaryFile(path: string, bytes: Uint8Array, projectId?: string): Promise<void>;
listDirectory(path?: string, projectId?: string): Promise<WorkspaceDirectoryListing>;
stat(path: string, projectId?: string): Promise<WorkspaceStat>;
watch(path: string, handler: WorkspaceWatchHandler, projectId?: string): Promise<WorkspaceWatch>;
queryStructure(query?: WorkspaceStructureQuery): Promise<ProjectStructure>;
}
export interface WorkspaceResolvedPath {
projectId: string;
root: string;
path: string;
}
export interface WorkspaceTextFile {
path: string;
content: string;
}
export interface WorkspaceBinaryFile {
path: string;
bytes: Uint8Array;
}
export interface WorkspaceDirEntry {
name: string;
path: string;
isDir: boolean;
}
export interface WorkspaceDirectoryListing {
path: string;
entries: WorkspaceDirEntry[];
}
export interface WorkspaceStat {
path: string;
exists: boolean;
isFile: boolean;
isDir: boolean;
len: number | null;
}
export interface WorkspaceWatchEvent {
path: string;
kind: "created" | "modified" | "deleted" | "renamed" | "unknown";
operation: string;
projectId: string;
}
export type WorkspaceWatchHandler = (event: WorkspaceWatchEvent) => void;
export interface WorkspaceWatch {
dispose(): void;
}
export interface WorkspaceStructureQuery {
projectId?: string;
path?: string;
maxDepth?: number;
maxEntries?: number;
}
export type ProjectStructureEntryKind = "file" | "directory";
export interface ProjectStructureEntry {
path: string;
name: string;
kind: ProjectStructureEntryKind;
}
export interface ProjectConvention {
id: string;
markerPath: string;
}
export interface ProjectModule {
path: string;
markerPath: string;
conventionId: string;
}
export interface ProjectStructure {
projectId: string;
rootPath: string;
entries: ProjectStructureEntry[];
conventions: ProjectConvention[];
modules: ProjectModule[];
truncated: boolean;
}
export interface BackgroundTaskStatus {
@ -52,7 +159,177 @@ export interface BackgroundTaskRetryResult {
taskId?: string;
}
export interface RunCommandTaskOptions {
projectId?: string;
ownerAgentId: string;
label?: string;
command: string;
args?: string[];
cwd?: string;
env?: Record<string, string> | Array<[string, string]>;
recordOnly?: boolean;
deadlineMs?: number;
}
export interface CommandTaskStatus {
taskId: string;
ownerAgentId: string;
projectId: string;
kind: string;
state: "queued" | "running" | "waiting" | "completed" | "failed" | "cancelled" | "expired";
exitCode: number | null;
summary: string | null;
stdoutTail: string | null;
stderrTail: string | null;
createdAtMs: number;
updatedAtMs: number;
}
export interface ToolRequirement {
id: string;
executable: string;
versionArgs?: string[];
required?: boolean;
env?: Record<string, string> | Array<[string, string]>;
}
export interface EnvRequirement {
name: string;
required?: boolean;
equals?: string;
}
export interface FileRequirement {
path: string;
required?: boolean;
kind?: "file" | "directory" | "any";
}
export interface ToolchainDiagnosticRequest {
projectId?: string;
cwd?: string;
tools?: ToolRequirement[];
env?: EnvRequirement[];
files?: FileRequirement[];
}
export interface ToolchainDiagnostic {
projectId: string;
cwd: string;
ok: boolean;
tools: ToolDiagnostic[];
env: EnvDiagnostic[];
files: FileDiagnostic[];
messages: DiagnosticMessage[];
}
export interface ToolDiagnostic {
id: string;
executable: string;
present: boolean;
ok: boolean;
status: "ok" | "failed" | "missing";
required: boolean;
exitCode: number | null;
version: string | null;
stdout: string | null;
stderr: string | null;
error: string | null;
}
export interface EnvDiagnostic {
name: string;
present: boolean;
ok: boolean;
required: boolean;
value: string | null;
status: "ok" | "missing" | "mismatch";
}
export interface FileDiagnostic {
path: string;
exists: boolean;
ok: boolean;
required: boolean;
kind: "file" | "directory" | "other" | "missing";
expectedKind: "file" | "directory" | "any" | null;
len: number | null;
}
export interface DiagnosticMessage {
level: "info" | "warning" | "error";
message: string;
}
export interface ToolingService {
diagnose(request: ToolchainDiagnosticRequest): Promise<ToolchainDiagnostic>;
}
export type PublicEvent = PluginPublicEvent;
export type PublicEventType = PluginPublicEventType;
export interface EventSubscribeOptions {
projectId?: string;
eventTypes?: PublicEventType[];
capacity?: number;
pollIntervalMs?: number;
maxEventsPerPoll?: number;
onDropped?: (count: number) => void;
}
export interface EventSubscription {
readonly subscriptionId: string;
readonly projectId: string;
readonly eventTypes: PublicEventType[];
readonly retention: string;
dispose(): void;
}
export type EventHandler = (event: PublicEvent) => void;
export interface EventService {
subscribe(options: EventSubscribeOptions, handler: EventHandler): Promise<EventSubscription>;
}
export type ConfigDocumentFormat = "json";
export type ConfigUpdateMode = "mergePatch" | "replace";
export interface ConfigDocumentReadOptions {
projectId?: string;
path: string;
format?: ConfigDocumentFormat;
}
export interface ConfigDocumentUpdateOptions extends ConfigDocumentReadOptions {
mode?: ConfigUpdateMode;
value: JsonValue;
}
export interface ConfigDocument<T extends JsonValue = JsonValue> {
projectId: string;
path: string;
format: ConfigDocumentFormat;
value: T;
}
export interface ConfigDocumentWriteResult {
projectId: string;
path: string;
format: ConfigDocumentFormat;
mode: ConfigUpdateMode;
bytesWritten: number;
}
export interface ConfigDocumentService {
readDocument<T extends JsonValue = JsonValue>(
options: ConfigDocumentReadOptions,
): Promise<ConfigDocument<T>>;
updateDocument(options: ConfigDocumentUpdateOptions): Promise<ConfigDocumentWriteResult>;
}
export interface BackgroundTaskService {
runCommand(options: RunCommandTaskOptions): Promise<CommandTaskStatus>;
getCommandStatus(taskId: string): Promise<CommandTaskStatus | null>;
list(projectId?: string): Promise<BackgroundTaskStatus[]>;
getStatus(taskId: string, projectId?: string): Promise<BackgroundTaskStatus | null>;
attachOutput(
@ -98,10 +375,17 @@ interface PluginServiceGatewaySet {
terminal: TerminalGateway;
workState: WorkStateGateway;
focusedProject: FocusedProjectGateway;
pluginWorkspace: PluginWorkspaceGateway;
pluginTask: PluginTaskGateway;
pluginToolchain: PluginToolchainGateway;
pluginEvents: PluginEventGateway;
pluginConfig: PluginConfigGateway;
}
const DEFAULT_ROWS = 24;
const DEFAULT_COLS = 80;
const DEFAULT_EVENT_POLL_INTERVAL_MS = 1000;
const MIN_EVENT_POLL_INTERVAL_MS = 100;
function noopDataHandler(): void {
// Intentionally empty: plugin code may opt into output bytes per call.
@ -126,6 +410,58 @@ function toBackgroundTaskStatus(task: BackgroundCompletion): BackgroundTaskStatu
};
}
function toCommandTaskStatus(task: PluginCommandTask): CommandTaskStatus {
return {
taskId: task.taskId,
ownerAgentId: task.ownerAgentId,
projectId: task.projectId,
kind: task.kind,
state: task.state,
exitCode: task.exitCode,
summary: task.summary,
stdoutTail: task.stdoutTail,
stderrTail: task.stderrTail,
createdAtMs: task.createdAtMs,
updatedAtMs: task.updatedAtMs,
};
}
function envEntries(env: RunCommandTaskOptions["env"]): Array<[string, string]> {
if (!env) return [];
return Array.isArray(env) ? env : Object.entries(env);
}
function toolEnvEntries(env: ToolRequirement["env"]): Array<[string, string]> {
if (!env) return [];
return Array.isArray(env) ? env : Object.entries(env);
}
function commandLabel(options: RunCommandTaskOptions): string {
if (options.label?.trim()) return options.label;
return [options.command, ...(options.args ?? [])].join(" ");
}
function eventPollIntervalMs(options: EventSubscribeOptions): number {
return Math.max(options.pollIntervalMs ?? DEFAULT_EVENT_POLL_INTERVAL_MS, MIN_EVENT_POLL_INTERVAL_MS);
}
function workspaceWatchKind(operation: string): WorkspaceWatchEvent["kind"] {
const normalized = operation.toLowerCase();
if (normalized.includes("create") || normalized.includes("write")) return "created";
if (normalized.includes("delete") || normalized.includes("remove")) return "deleted";
if (normalized.includes("rename") || normalized.includes("move")) return "renamed";
if (normalized.includes("modify") || normalized.includes("update")) return "modified";
return "unknown";
}
function workspacePathMatches(watchedPath: string, eventPath: string): boolean {
return (
watchedPath === "" ||
eventPath === watchedPath ||
eventPath.startsWith(`${watchedPath}/`)
);
}
export function createPluginServices(gateways: PluginServiceGatewaySet): PluginServices {
async function currentProject(): Promise<WorkspaceProject | null> {
const focused = await gateways.focusedProject.getFocusedProject();
@ -144,6 +480,55 @@ export function createPluginServices(gateways: PluginServiceGatewaySet): PluginS
return focused;
}
async function subscribeToEvents(
options: EventSubscribeOptions,
handler: EventHandler,
): Promise<EventSubscription> {
const project = await requireProject(options.projectId);
const subscription = await gateways.pluginEvents.subscribe({
projectId: project.id,
eventTypes: options.eventTypes ?? [],
capacity: options.capacity,
});
let disposed = false;
let polling = false;
const poll = async () => {
if (disposed || polling) return;
polling = true;
try {
const batch = await gateways.pluginEvents.poll({
subscriptionId: subscription.subscriptionId,
maxEvents: options.maxEventsPerPoll,
});
if (disposed) return;
if (batch.dropped > 0) options.onDropped?.(batch.dropped);
for (const event of batch.events) {
handler(event);
}
} catch (error) {
if (!disposed) console.warn("[plugin-events] poll failed", error);
} finally {
polling = false;
}
};
void poll();
const timer = setInterval(() => void poll(), eventPollIntervalMs(options));
return {
subscriptionId: subscription.subscriptionId,
projectId: subscription.projectId,
eventTypes: subscription.eventTypes,
retention: subscription.retention,
dispose() {
if (disposed) return;
disposed = true;
clearInterval(timer);
void gateways.pluginEvents.unsubscribe({
subscriptionId: subscription.subscriptionId,
});
},
};
}
const workspace: WorkspaceService = {
getCurrentProject: currentProject,
async getProjectRoot(projectId) {
@ -157,6 +542,63 @@ export function createPluginServices(gateways: PluginServiceGatewaySet): PluginS
const project = await requireProject(projectId);
await gateways.project.updateProjectContext(project.id, content);
},
async resolvePath(path, projectId) {
const project = await requireProject(projectId);
const stat = await gateways.pluginWorkspace.stat({ projectId: project.id, path });
return { projectId: project.id, root: project.root, path: stat.path };
},
async readTextFile(path, projectId) {
const project = await requireProject(projectId);
return gateways.pluginWorkspace.readText({ projectId: project.id, path });
},
async readBinaryFile(path, projectId) {
const project = await requireProject(projectId);
return gateways.pluginWorkspace.readBinary({ projectId: project.id, path });
},
async writeTextFile(path, content, projectId) {
const project = await requireProject(projectId);
await gateways.pluginWorkspace.writeText({ projectId: project.id, path, content });
},
async writeBinaryFile(path, bytes, projectId) {
const project = await requireProject(projectId);
await gateways.pluginWorkspace.writeBinary({ projectId: project.id, path, bytes });
},
async listDirectory(path = ".", projectId) {
const project = await requireProject(projectId);
return gateways.pluginWorkspace.listDir({ projectId: project.id, path });
},
async stat(path, projectId) {
const project = await requireProject(projectId);
return gateways.pluginWorkspace.stat({ projectId: project.id, path });
},
async watch(path, handler, projectId) {
const project = await requireProject(projectId);
const stat = await gateways.pluginWorkspace.stat({ projectId: project.id, path });
const subscription = await subscribeToEvents(
{ projectId: project.id, eventTypes: ["workspaceFileChanged"] },
(event) => {
if (event.type !== "workspaceFileChanged") return;
if (!workspacePathMatches(stat.path, event.path)) return;
handler({
path: event.path,
kind: workspaceWatchKind(event.operation),
operation: event.operation,
projectId: event.projectId,
});
},
);
return { dispose: () => subscription.dispose() };
},
async queryStructure(query = {}) {
const project = await requireProject(query.projectId);
const input: PluginProjectStructureQuery = {
projectId: project.id,
path: query.path,
maxDepth: query.maxDepth,
maxEntries: query.maxEntries,
};
return gateways.pluginWorkspace.queryProjectStructure(input);
},
};
async function listTasks(projectId?: string): Promise<BackgroundTaskStatus[]> {
@ -168,6 +610,28 @@ export function createPluginServices(gateways: PluginServiceGatewaySet): PluginS
}
const tasks: BackgroundTaskService = {
async runCommand(options) {
const project = await requireProject(options.projectId);
if (options.ownerAgentId.trim() === "") {
throw new Error("ownerAgentId is required to correlate a plugin command task");
}
const task = await gateways.pluginTask.runCommand({
projectId: project.id,
ownerAgentId: options.ownerAgentId,
label: commandLabel(options),
command: options.command,
args: options.args ?? [],
cwd: options.cwd,
env: envEntries(options.env),
recordOnly: options.recordOnly ?? false,
deadlineMs: options.deadlineMs,
});
return toCommandTaskStatus(task);
},
async getCommandStatus(taskId) {
const task = await gateways.pluginTask.getStatus({ taskId });
return task ? toCommandTaskStatus(task) : null;
},
list: listTasks,
async getStatus(taskId, projectId) {
return (await listTasks(projectId)).find((task) => task.taskId === taskId) ?? null;
@ -191,6 +655,53 @@ export function createPluginServices(gateways: PluginServiceGatewaySet): PluginS
},
};
const tooling: ToolingService = {
async diagnose(request) {
const project = await requireProject(request.projectId);
return gateways.pluginToolchain.diagnose({
projectId: project.id,
cwd: request.cwd,
tools: (request.tools ?? []).map((tool) => ({
id: tool.id,
executable: tool.executable,
versionArgs: tool.versionArgs ?? [],
required: tool.required ?? false,
env: toolEnvEntries(tool.env),
})),
env: request.env ?? [],
files: request.files ?? [],
});
},
};
const events: EventService = {
subscribe: subscribeToEvents,
};
const config: ConfigDocumentService = {
async readDocument<T extends JsonValue = JsonValue>(
options: ConfigDocumentReadOptions,
): Promise<ConfigDocument<T>> {
const project = await requireProject(options.projectId);
const document = await gateways.pluginConfig.readDocument({
projectId: project.id,
path: options.path,
format: options.format,
});
return document as ConfigDocument<T>;
},
async updateDocument(options) {
const project = await requireProject(options.projectId);
return gateways.pluginConfig.updateDocument({
projectId: project.id,
path: options.path,
format: options.format,
mode: options.mode,
value: options.value,
});
},
};
const terminal: TerminalService = {
async open(options = {}) {
const cwd = options.cwd ?? (await workspace.getProjectRoot());
@ -213,5 +724,5 @@ export function createPluginServices(gateways: PluginServiceGatewaySet): PluginS
},
};
return { workspace, tasks, terminal };
return { workspace, tasks, tooling, events, config, terminal };
}