From 033e9a86d57f350cd121062c0ad6095a3c25b202 Mon Sep 17 00:00:00 2001 From: Blomios Date: Sun, 2 Aug 2026 00:31:15 +0200 Subject: [PATCH] feat(sdk,plugins): ajoute capability tooling et services runtime publics (workspace/terminal/tasks) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - capability manifeste tooling supportée backend + transport runtime catalog - ctx.services publique côté SDK/runtime, gated par tooling - surface honnête: workspace, terminal, et tasks en observation/contrôle uniquement - docs/exemple/tests mis à jour - QA PASS sur feature/sdk-plugin-tooling-build-debug-surface --- crates/app-tauri/tests/dto_plugins.rs | 7 +- crates/application/src/plugin/mod.rs | 57 ++++- crates/backend/src/dto.rs | 3 + crates/domain/src/plugin.rs | 15 ++ frontend/src/domain/index.ts | 1 + .../features/plugins/PluginLayoutCellView.tsx | 2 + .../plugins/PluginRuntimeProvider.tsx | 2 + frontend/src/plugins/runtime/index.ts | 15 ++ frontend/src/plugins/runtime/loader.test.ts | 62 +++++ frontend/src/plugins/runtime/loader.ts | 9 + frontend/src/plugins/runtime/registry.ts | 12 +- frontend/src/plugins/runtime/services.test.ts | 177 ++++++++++++++ frontend/src/plugins/runtime/services.ts | 217 ++++++++++++++++++ sdk/IdeaSDK/README.md | 37 +++ sdk/IdeaSDK/examples/hello-plugin/README.md | 2 + .../examples/hello-plugin/idea-plugin.json | 3 +- .../examples/hello-plugin/src/index.ts | 7 + sdk/IdeaSDK/src/index.ts | 14 +- sdk/IdeaSDK/src/manifest.js | 4 +- sdk/IdeaSDK/src/manifest.ts | 6 +- sdk/IdeaSDK/src/runtime.ts | 106 +++++++++ 21 files changed, 748 insertions(+), 10 deletions(-) create mode 100644 frontend/src/plugins/runtime/services.test.ts create mode 100644 frontend/src/plugins/runtime/services.ts diff --git a/crates/app-tauri/tests/dto_plugins.rs b/crates/app-tauri/tests/dto_plugins.rs index 81e749e..9485584 100644 --- a/crates/app-tauri/tests/dto_plugins.rs +++ b/crates/app-tauri/tests/dto_plugins.rs @@ -2,7 +2,7 @@ use app_tauri_lib::dto::{ PluginAdminDto, PluginContributionSummaryDto, PluginRuntimeContributionCatalogDto, PluginRuntimePluginDto, }; -use domain::{PluginContributionSet, PluginLifecycleState, PluginTrustLevel}; +use domain::{PluginCapability, PluginContributionSet, PluginLifecycleState, PluginTrustLevel}; #[test] fn plugin_admin_dto_serialises_exact_contract_shape() { @@ -48,6 +48,7 @@ fn runtime_catalog_dto_carries_bundle_hash_and_contributions() { bundle_url: "idea-plugin://dev.acme.gitgraph/1.2.3/abc/dist/index.js".to_owned(), icon_url: None, content_hash: "abc".to_owned(), + capabilities: vec![PluginCapability::Ui, PluginCapability::Tooling], contributes: PluginContributionSet::default(), }], }; @@ -58,6 +59,10 @@ fn runtime_catalog_dto_carries_bundle_hash_and_contributions() { "idea-plugin://dev.acme.gitgraph/1.2.3/abc/dist/index.js" ); assert_eq!(value["plugins"][0]["contentHash"], "abc"); + assert_eq!( + value["plugins"][0]["capabilities"], + serde_json::json!(["ui", "tooling"]) + ); assert!(value["plugins"][0]["contributes"]["menus"] .as_array() .unwrap() diff --git a/crates/application/src/plugin/mod.rs b/crates/application/src/plugin/mod.rs index 35cd4bf..7cfce80 100644 --- a/crates/application/src/plugin/mod.rs +++ b/crates/application/src/plugin/mod.rs @@ -146,6 +146,8 @@ pub struct PluginRuntimePlugin { pub icon_url: Option, /// Content hash. pub content_hash: String, + /// Public manifest capabilities. + pub capabilities: Vec, /// Contributions. pub contributes: PluginContributionSet, } @@ -824,6 +826,7 @@ async fn runtime_plugin_from_entry( bundle_url: bundle, icon_url, content_hash: descriptor.registry.content_hash.as_str().to_owned(), + capabilities: descriptor.manifest.capabilities, contributes: descriptor.manifest.contributes, }) } @@ -1170,6 +1173,7 @@ impl PluginManifestValidator for JsonPluginManifestValidator { .map(|c| match c.as_str() { "ui" => Ok(domain::PluginCapability::Ui), "mcp" => Ok(domain::PluginCapability::Mcp), + "tooling" => Ok(domain::PluginCapability::Tooling), _ => Err(PluginManifestError::Invalid(format!( "unknown capability: {c}" ))), @@ -1377,7 +1381,7 @@ mod tests { "engines": {"idea": ">=0.1.0 <1.0.0"}, "main": "dist/index.js", "trustLevel": "full", - "capabilities": ["ui", "mcp"], + "capabilities": ["ui", "mcp", "tooling"], "contributes": { "menus": [{"id":"dev.acme.menu","label":"Graph","topLevel":true}], "menuItems": [{"id":"dev.acme.open","targetMenuId":"panels","label":"Open","command":"dev.acme.open"}], @@ -1630,10 +1634,39 @@ mod tests { ) .unwrap(); assert_eq!(m.id.as_str(), "dev.acme.gitgraph"); + assert_eq!( + m.capabilities, + vec![ + domain::PluginCapability::Ui, + domain::PluginCapability::Mcp, + domain::PluginCapability::Tooling, + ] + ); assert_eq!(m.contributes.layouts.len(), 1); assert_eq!(m.contributes.mcp_servers.len(), 1); } + #[test] + fn rejects_unknown_manifest_capability() { + let mut value: serde_json::Value = serde_json::from_slice(&valid_manifest()).unwrap(); + value["capabilities"] = serde_json::json!(["ui", "android"]); + + let err = validator() + .validate( + &serde_json::to_vec(&value).unwrap(), + &domain::PluginPackageRef { + plugin_id: None, + root: "x".into(), + }, + ) + .unwrap_err(); + + assert_eq!( + err, + PluginManifestError::Invalid("unknown capability: android".to_owned()) + ); + } + #[test] fn rejects_unsafe_main_path_and_non_full_trust() { let mut value: serde_json::Value = serde_json::from_slice(&valid_manifest()).unwrap(); @@ -1695,6 +1728,28 @@ mod tests { } } + #[tokio::test] + async fn runtime_catalog_carries_manifest_capabilities() { + let packages = Arc::new(FakePackages::with_manifest(valid_manifest())); + let registry = Arc::new(FakeRegistry { + registry: Mutex::new(registry_with(PluginLifecycleState::Enabled)), + }); + let usecase = + ListPluginRuntimeContributions::new(packages, registry, Arc::new(validator())); + + let catalog = usecase.execute().await.unwrap(); + + assert_eq!(catalog.plugins.len(), 1); + assert_eq!( + catalog.plugins[0].capabilities, + vec![ + domain::PluginCapability::Ui, + domain::PluginCapability::Mcp, + domain::PluginCapability::Tooling, + ] + ); + } + #[tokio::test] async fn runtime_catalog_marks_invalid_active_plugin_and_keeps_bootstrap_alive() { let packages = Arc::new(FakePackages::with_manifest(br#"{"broken":true}"#.to_vec())); diff --git a/crates/backend/src/dto.rs b/crates/backend/src/dto.rs index 2fb6009..5751677 100644 --- a/crates/backend/src/dto.rs +++ b/crates/backend/src/dto.rs @@ -239,6 +239,8 @@ pub struct PluginRuntimePluginDto { pub icon_url: Option, /// Content hash. pub content_hash: String, + /// Public manifest capabilities. + pub capabilities: Vec, /// Contributions. pub contributes: domain::PluginContributionSet, } @@ -265,6 +267,7 @@ impl From for PluginRuntimePluginDto { bundle_url: value.bundle_url, icon_url: value.icon_url, content_hash: value.content_hash, + capabilities: value.capabilities, contributes: value.contributes, } } diff --git a/crates/domain/src/plugin.rs b/crates/domain/src/plugin.rs index 0887780..558b64f 100644 --- a/crates/domain/src/plugin.rs +++ b/crates/domain/src/plugin.rs @@ -284,6 +284,8 @@ pub enum PluginCapability { Ui, /// External MCP server declarations. Mcp, + /// Public tooling/build/debug services. + Tooling, } /// Plugin command id. @@ -678,4 +680,17 @@ mod tests { assert!(!PluginLifecycleState::PendingUninstall.is_runtime_active()); assert!(PluginLifecycleState::Enabled.is_runtime_active()); } + + #[test] + fn plugin_capabilities_serialize_public_manifest_names() { + assert_eq!( + serde_json::to_value([ + PluginCapability::Ui, + PluginCapability::Mcp, + PluginCapability::Tooling + ]) + .unwrap(), + serde_json::json!(["ui", "mcp", "tooling"]) + ); + } } diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index 6c2dc71..d66c3cb 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -1748,6 +1748,7 @@ export interface PluginRuntimePlugin { displayName: string; publisher?: string; version: string; + capabilities?: string[]; bundleUrl: string; iconUrl?: string; contentHash: string; diff --git a/frontend/src/features/plugins/PluginLayoutCellView.tsx b/frontend/src/features/plugins/PluginLayoutCellView.tsx index c06d7bc..fec0685 100644 --- a/frontend/src/features/plugins/PluginLayoutCellView.tsx +++ b/frontend/src/features/plugins/PluginLayoutCellView.tsx @@ -114,6 +114,8 @@ export function PluginLayoutCellView({ terminal: gateways.terminal, agents: gateways.agent, system: gateways.system, + workState: gateways.workState, + focusedProject: gateways.focusedProject, }} /> diff --git a/frontend/src/features/plugins/PluginRuntimeProvider.tsx b/frontend/src/features/plugins/PluginRuntimeProvider.tsx index 2ff26b5..08a1966 100644 --- a/frontend/src/features/plugins/PluginRuntimeProvider.tsx +++ b/frontend/src/features/plugins/PluginRuntimeProvider.tsx @@ -73,6 +73,8 @@ export function PluginRuntimeProvider({ children, value: injected }: PluginRunti terminal: gateways.terminal, agents: gateways.agent, system: gateways.system, + workState: gateways.workState, + focusedProject: gateways.focusedProject, }), ) .then((result) => { diff --git a/frontend/src/plugins/runtime/index.ts b/frontend/src/plugins/runtime/index.ts index 89f6cd0..a9752dd 100644 --- a/frontend/src/plugins/runtime/index.ts +++ b/frontend/src/plugins/runtime/index.ts @@ -18,4 +18,19 @@ export { type PluginLoadFailure, type PluginLoadResult, } from "./loader"; +export { + createPluginServices, + type BackgroundTaskOutputAttachment, + type BackgroundTaskRetryResult, + type BackgroundTaskService, + type BackgroundTaskStatus, + type PluginServices, + type TerminalOpenOptions, + type TerminalReattachOptions, + type TerminalReattachResult, + type TerminalService, + type TerminalSession, + type WorkspaceProject, + type WorkspaceService, +} from "./services"; export { evaluateWhen, type WhenContext, type WhenEvalResult, type WhenVariable } from "./when"; diff --git a/frontend/src/plugins/runtime/loader.test.ts b/frontend/src/plugins/runtime/loader.test.ts index dc5a3f9..6cd92f5 100644 --- a/frontend/src/plugins/runtime/loader.test.ts +++ b/frontend/src/plugins/runtime/loader.test.ts @@ -178,6 +178,68 @@ describe("loadPlugins", () => { expect(registry.get("com.example.hello-plugin")).toBeUndefined(); }); + it("does not inject the public plugin services facade without the tooling capability", async () => { + const bundle = dataUrl(` + export function activate(ctx) { + globalThis.__servicesWithoutTooling = ctx.services; + } + `); + const { failures } = await loadPlugins( + [entry({ id: "dev.acme.no-services", displayName: "No Services", bundleUrl: bundle })], + gateways, + ); + + expect(failures).toEqual([]); + expect((globalThis as Record).__servicesWithoutTooling).toBeUndefined(); + }); + + it("injects the public plugin services facade for plugins declaring tooling", async () => { + const bundle = dataUrl(` + export function activate(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.__terminalServiceKeys = Object.keys(ctx.services.terminal).sort(); + } + `); + const { failures } = await loadPlugins( + [ + entry({ + id: "dev.acme.services", + displayName: "Services", + capabilities: ["tooling"], + bundleUrl: bundle, + }), + ], + gateways, + ); + + expect(failures).toEqual([]); + expect((globalThis as Record).__serviceKeys).toEqual([ + "tasks", + "terminal", + "workspace", + ]); + expect((globalThis as Record).__workspaceServiceKeys).toEqual([ + "getCurrentProject", + "getProjectRoot", + "readProjectContext", + "updateProjectContext", + ]); + expect((globalThis as Record).__taskServiceKeys).toEqual([ + "attachOutput", + "cancel", + "getStatus", + "list", + "retry", + ]); + expect((globalThis as Record).__terminalServiceKeys).toEqual([ + "close", + "open", + "reattach", + ]); + }); + it("loads the hello-plugin command and layout contribution shape", async () => { const bundle = dataUrl(` export function activate(ctx) { diff --git a/frontend/src/plugins/runtime/loader.ts b/frontend/src/plugins/runtime/loader.ts index 2f7c6ef..2211c88 100644 --- a/frontend/src/plugins/runtime/loader.ts +++ b/frontend/src/plugins/runtime/loader.ts @@ -25,6 +25,7 @@ import { type LoadedPlugin, type PluginGatewaySet, } from "./registry"; +import { createPluginServices, type PluginServices } from "./services"; export type { PluginGatewaySet } from "./registry"; @@ -37,6 +38,7 @@ export interface IdeaPluginContext extends PluginGatewaySet { commands: PluginCommandContext; layouts: PluginLayoutRegistry; menu: PluginMenuRegistry; + services?: PluginServices; } export interface PluginActivation { @@ -177,6 +179,10 @@ function normalizeContributes(entry: PluginRuntimePlugin): PluginContributionDto }; } +function hasCapability(entry: PluginRuntimePlugin, capability: string): boolean { + return arrayOrEmpty(objectOrEmpty(entry).capabilities).includes(capability); +} + async function disposeAll(disposables: Disposable[], activation?: void | PluginActivation): Promise { try { await activation?.dispose?.(); @@ -247,6 +253,9 @@ async function loadOne( menu, ...gateways, }; + if (hasCapability(entry, "tooling")) { + ctx.services = createPluginServices(gateways); + } activation = await withTimeout( Promise.resolve(mod.activate(ctx)), diff --git a/frontend/src/plugins/runtime/registry.ts b/frontend/src/plugins/runtime/registry.ts index 212aff6..1245296 100644 --- a/frontend/src/plugins/runtime/registry.ts +++ b/frontend/src/plugins/runtime/registry.ts @@ -19,7 +19,15 @@ import type { PluginMenuItemContribution, PluginTopLevelMenuContribution, } from "@/domain"; -import type { AgentGateway, GitGateway, ProjectGateway, SystemGateway, TerminalGateway } from "@/ports"; +import type { + AgentGateway, + FocusedProjectGateway, + GitGateway, + ProjectGateway, + SystemGateway, + TerminalGateway, + WorkStateGateway, +} from "@/ports"; /** The stable gateways a plugin's `activate(ctx)` is allowed to reach (carnet §6). */ export interface PluginGatewaySet { @@ -28,6 +36,8 @@ export interface PluginGatewaySet { terminal: TerminalGateway; agents: AgentGateway; system: SystemGateway; + workState: WorkStateGateway; + focusedProject: FocusedProjectGateway; } /** A disposable handle returned by every `register*` call. */ diff --git a/frontend/src/plugins/runtime/services.test.ts b/frontend/src/plugins/runtime/services.test.ts new file mode 100644 index 0000000..8bc232f --- /dev/null +++ b/frontend/src/plugins/runtime/services.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { ProjectWorkState } from "@/domain"; +import type { + BackgroundTaskAttachment, + FocusedProjectGateway, + ProjectGateway, + ReattachResult, + TerminalGateway, + TerminalHandle, + WorkStateGateway, +} from "@/ports"; +import { createPluginServices } from "./services"; + +function terminalHandle(sessionId: string): TerminalHandle { + return { + sessionId, + write: vi.fn(), + resize: vi.fn(), + detach: vi.fn(), + close: vi.fn(), + }; +} + +function gateways(overrides: { + focusedProject?: Partial; + project?: Partial; + workState?: Partial; + terminal?: Partial; +} = {}) { + const focusedProject: FocusedProjectGateway = { + setFocusedProject: vi.fn(), + getFocusedProject: vi.fn(async () => ({ + id: "project-1", + name: "Project One", + root: "/workspace/project-one", + })), + onFocusedProjectChanged: vi.fn(), + ...overrides.focusedProject, + }; + const project: ProjectGateway = { + listProjects: vi.fn(async () => [ + { + id: "project-1", + name: "Project One", + root: "/workspace/project-one", + remote: { kind: "local" as const }, + createdAt: 1, + }, + ]), + createProject: vi.fn(), + openProject: vi.fn(), + closeProject: vi.fn(), + readProjectContext: vi.fn(async () => "project context"), + updateProjectContext: vi.fn(), + ...overrides.project, + }; + const workState: WorkStateGateway = { + getProjectWorkState: vi.fn(async (): Promise => ({ + agents: [], + conversations: [], + })), + attachBackgroundTask: vi.fn(async (taskId): Promise => ({ + taskId, + scrollback: new Uint8Array([65]), + live: false, + detach: vi.fn(), + })), + cancelBackgroundTask: vi.fn(), + retryBackgroundTask: vi.fn(), + ...overrides.workState, + }; + const terminal: TerminalGateway = { + openTerminal: vi.fn(async () => terminalHandle("terminal-1")), + reattach: vi.fn(async (): Promise => ({ + handle: terminalHandle("terminal-2"), + scrollback: new Uint8Array([66]), + })), + closeTerminal: vi.fn(), + ...overrides.terminal, + }; + + return { focusedProject, project, workState, terminal }; +} + +describe("createPluginServices", () => { + it("exposes focused workspace project helpers without leaking project gateway DTOs", async () => { + const g = gateways(); + const services = createPluginServices(g); + + await expect(services.workspace.getCurrentProject()).resolves.toEqual({ + id: "project-1", + name: "Project One", + root: "/workspace/project-one", + }); + await expect(services.workspace.getProjectRoot()).resolves.toBe("/workspace/project-one"); + await expect(services.workspace.readProjectContext()).resolves.toBe("project context"); + + await services.workspace.updateProjectContext("next context"); + expect(g.project.updateProjectContext).toHaveBeenCalledWith("project-1", "next context"); + }); + + it("maps background tasks through the work-state gateway and wraps output attachment", async () => { + const detach = vi.fn(); + const onData = vi.fn(); + const g = gateways({ + workState: { + getProjectWorkState: vi.fn(async (): Promise => ({ + conversations: [], + agents: [ + { + agentId: "agent-1", + name: "Agent", + profileId: "profile-1", + busy: { state: "idle" as const }, + tickets: [], + backgroundTasks: [ + { + taskId: "task-1", + ownerAgentId: "agent-1", + projectId: "project-1", + kind: "command", + status: "running" as const, + exitCode: null, + summary: null, + stdoutTail: "ok", + stderrTail: null, + updatedAtMs: 42, + }, + ], + }, + ], + })), + attachBackgroundTask: vi.fn(async (taskId) => ({ + taskId, + scrollback: new Uint8Array([1, 2, 3]), + live: true, + detach, + })), + }, + }); + const services = createPluginServices(g); + + await expect(services.tasks.getStatus("task-1")).resolves.toMatchObject({ + taskId: "task-1", + status: "running", + stdoutTail: "ok", + }); + await expect(services.tasks.getStatus("missing")).resolves.toBeNull(); + + const attachment = await services.tasks.attachOutput("task-1", onData); + expect(attachment.scrollback).toEqual(new Uint8Array([1, 2, 3])); + expect(attachment.live).toBe(true); + attachment.detach(); + expect(detach).toHaveBeenCalled(); + }); + + it("opens terminal sessions with project-root defaults and delegates controls", async () => { + const g = gateways(); + const services = createPluginServices(g); + + const session = await services.terminal.open(); + expect(g.terminal.openTerminal).toHaveBeenCalledWith( + { cwd: "/workspace/project-one", rows: 24, cols: 80 }, + expect.any(Function), + ); + expect(session.sessionId).toBe("terminal-1"); + + const reattached = await services.terminal.reattach("terminal-1"); + expect(g.terminal.reattach).toHaveBeenCalledWith("terminal-1", expect.any(Function)); + expect(reattached.session.sessionId).toBe("terminal-2"); + expect(reattached.scrollback).toEqual(new Uint8Array([66])); + + await services.terminal.close("terminal-1"); + expect(g.terminal.closeTerminal).toHaveBeenCalledWith("terminal-1"); + }); +}); diff --git a/frontend/src/plugins/runtime/services.ts b/frontend/src/plugins/runtime/services.ts new file mode 100644 index 0000000..7626b2d --- /dev/null +++ b/frontend/src/plugins/runtime/services.ts @@ -0,0 +1,217 @@ +import type { BackgroundCompletion } from "@/domain"; +import type { + BackgroundTaskAttachment, + FocusedProject, + FocusedProjectGateway, + OpenTerminalOptions, + ProjectGateway, + TerminalGateway, + WorkStateGateway, +} from "@/ports"; + +export interface PluginServices { + workspace: WorkspaceService; + tasks: BackgroundTaskService; + terminal: TerminalService; +} + +export interface WorkspaceProject { + id: string; + name: string; + root: string; +} + +export interface WorkspaceService { + getCurrentProject(): Promise; + getProjectRoot(projectId?: string): Promise; + readProjectContext(projectId?: string): Promise; + updateProjectContext(content: string, projectId?: string): Promise; +} + +export interface BackgroundTaskStatus { + taskId: string; + ownerAgentId: string; + projectId: string; + kind: string; + status: "pending" | "running" | "completed" | "failed" | "cancelled" | "delivered"; + exitCode: number | null; + summary: string | null; + stdoutTail: string | null; + stderrTail: string | null; + updatedAtMs: number; +} + +export interface BackgroundTaskOutputAttachment { + taskId: string; + scrollback: Uint8Array; + live: boolean; + detach(): void; +} + +export interface BackgroundTaskRetryResult { + taskId?: string; +} + +export interface BackgroundTaskService { + list(projectId?: string): Promise; + getStatus(taskId: string, projectId?: string): Promise; + attachOutput( + taskId: string, + onData: (bytes: Uint8Array) => void, + ): Promise; + cancel(taskId: string): Promise; + retry(taskId: string): Promise; +} + +export interface TerminalOpenOptions { + cwd?: string; + rows?: number; + cols?: number; + onData?: (bytes: Uint8Array) => void; +} + +export interface TerminalReattachOptions { + onData?: (bytes: Uint8Array) => void; +} + +export interface TerminalSession { + readonly sessionId: string; + write(data: Uint8Array): Promise; + resize(rows: number, cols: number): Promise; + detach(): void; + close(): Promise; +} + +export interface TerminalReattachResult { + session: TerminalSession; + scrollback: Uint8Array; +} + +export interface TerminalService { + open(options?: TerminalOpenOptions): Promise; + reattach(sessionId: string, options?: TerminalReattachOptions): Promise; + close(sessionId: string): Promise; +} + +interface PluginServiceGatewaySet { + project: ProjectGateway; + terminal: TerminalGateway; + workState: WorkStateGateway; + focusedProject: FocusedProjectGateway; +} + +const DEFAULT_ROWS = 24; +const DEFAULT_COLS = 80; + +function noopDataHandler(): void { + // Intentionally empty: plugin code may opt into output bytes per call. +} + +function toWorkspaceProject(project: FocusedProject): WorkspaceProject { + return { id: project.id, name: project.name, root: project.root }; +} + +function toBackgroundTaskStatus(task: BackgroundCompletion): BackgroundTaskStatus { + return { + taskId: task.taskId, + ownerAgentId: task.ownerAgentId, + projectId: task.projectId, + kind: task.kind, + status: task.status, + exitCode: task.exitCode, + summary: task.summary, + stdoutTail: task.stdoutTail, + stderrTail: task.stderrTail, + updatedAtMs: task.updatedAtMs, + }; +} + +export function createPluginServices(gateways: PluginServiceGatewaySet): PluginServices { + async function currentProject(): Promise { + const focused = await gateways.focusedProject.getFocusedProject(); + return focused ? toWorkspaceProject(focused) : null; + } + + async function requireProject(projectId?: string): Promise { + if (projectId) { + const project = (await gateways.project.listProjects()).find((p) => p.id === projectId); + if (!project) throw new Error(`project not found: ${projectId}`); + return { id: project.id, name: project.name, root: project.root }; + } + + const focused = await currentProject(); + if (!focused) throw new Error("no current project is focused"); + return focused; + } + + const workspace: WorkspaceService = { + getCurrentProject: currentProject, + async getProjectRoot(projectId) { + return (await requireProject(projectId)).root; + }, + async readProjectContext(projectId) { + const project = await requireProject(projectId); + return gateways.project.readProjectContext(project.id); + }, + async updateProjectContext(content, projectId) { + const project = await requireProject(projectId); + await gateways.project.updateProjectContext(project.id, content); + }, + }; + + async function listTasks(projectId?: string): Promise { + const project = await requireProject(projectId); + const state = await gateways.workState.getProjectWorkState(project.id); + return state.agents + .flatMap((agent) => agent.backgroundTasks ?? []) + .map(toBackgroundTaskStatus); + } + + const tasks: BackgroundTaskService = { + list: listTasks, + async getStatus(taskId, projectId) { + return (await listTasks(projectId)).find((task) => task.taskId === taskId) ?? null; + }, + async attachOutput(taskId, onData) { + const attachment: BackgroundTaskAttachment = + await gateways.workState.attachBackgroundTask(taskId, onData); + return { + taskId: attachment.taskId, + scrollback: attachment.scrollback, + live: attachment.live, + detach: () => attachment.detach(), + }; + }, + async cancel(taskId) { + await gateways.workState.cancelBackgroundTask(taskId); + }, + async retry(taskId) { + await gateways.workState.retryBackgroundTask(taskId); + return {}; + }, + }; + + const terminal: TerminalService = { + async open(options = {}) { + const cwd = options.cwd ?? (await workspace.getProjectRoot()); + const openOptions: OpenTerminalOptions = { + cwd, + rows: options.rows ?? DEFAULT_ROWS, + cols: options.cols ?? DEFAULT_COLS, + }; + return gateways.terminal.openTerminal(openOptions, options.onData ?? noopDataHandler); + }, + async reattach(sessionId, options = {}) { + const result = await gateways.terminal.reattach( + sessionId, + options.onData ?? noopDataHandler, + ); + return { session: result.handle, scrollback: result.scrollback }; + }, + async close(sessionId) { + await gateways.terminal.closeTerminal(sessionId); + }, + }; + + return { workspace, tasks, terminal }; +} diff --git a/sdk/IdeaSDK/README.md b/sdk/IdeaSDK/README.md index a0dd547..1761e2f 100644 --- a/sdk/IdeaSDK/README.md +++ b/sdk/IdeaSDK/README.md @@ -6,6 +6,7 @@ This first version intentionally stays small: - public manifest types for `idea-plugin.json`; - public runtime types for plugin modules exposing `activate(ctx)`; +- a stable `ctx.services` facade for workspace, background task and terminal operations; - a lightweight manifest validator; - a minimal `examples/hello-plugin` plugin. @@ -69,6 +70,42 @@ export function activate(ctx: ActivateContext): void { } ``` +## Runtime Services + +Plugins declaring the `tooling` capability receive `ctx.services`. Plugins +without that capability do not receive this facade. Prefer `ctx.services` over +IdeA's internal runtime objects when it is available: + +```ts +import type { ActivateContext } from "@idea/plugin-sdk"; + +export async function activate(ctx: ActivateContext): Promise { + const project = await ctx.services?.workspace.getCurrentProject(); + ctx.logger.info("current project", project); + + const task = await ctx.services?.tasks.getStatus("task-id"); + ctx.logger.info("task status", task?.status); + + const terminal = await ctx.services?.terminal.open({ rows: 24, cols: 80 }); + await terminal?.write(new TextEncoder().encode("echo hello\\r")); +} +``` + +Current terminal scope is intentionally minimal: it opens or reattaches a shell +PTY, writes bytes, resizes, detaches and closes. The background task service is +observation/control only in this SDK version: `list`, `getStatus`, `attachOutput`, +`cancel` and `retry` operate on existing tasks visible through IdeA's Work read +model. Starting new background tasks is not part of the public plugin API in this +lot. + +Declare the additive `tooling` capability to receive `ctx.services` at runtime: + +```json +{ + "capabilities": ["ui", "tooling"] +} +``` + ## Manifest Validation ```ts diff --git a/sdk/IdeaSDK/examples/hello-plugin/README.md b/sdk/IdeaSDK/examples/hello-plugin/README.md index 7119a1e..0b53e1a 100644 --- a/sdk/IdeaSDK/examples/hello-plugin/README.md +++ b/sdk/IdeaSDK/examples/hello-plugin/README.md @@ -8,6 +8,7 @@ It exercises the current plugin primitives end to end: - menu entry: `hello-plugin`; - command: `hello-plugin`, returning `hello-world`; - layout contribution: `hello-plugin.hello-world`, rendered as `hello-world`. +- tooling capability: logs the focused workspace project when `ctx.services` is available. ```sh npm run typecheck:examples @@ -25,6 +26,7 @@ During activation the plugin logs: - whether the command and layout runtime registries are available; - successful registration of the `hello-plugin` command; - successful registration of the `hello-plugin.hello-world` layout; +- availability of the workspace service from the `tooling` runtime capability; - the first layout render, including project/node identifiers. These messages are intentionally small and stable so installation, bundle import, activation and diff --git a/sdk/IdeaSDK/examples/hello-plugin/idea-plugin.json b/sdk/IdeaSDK/examples/hello-plugin/idea-plugin.json index 28f7962..cfc7dd7 100644 --- a/sdk/IdeaSDK/examples/hello-plugin/idea-plugin.json +++ b/sdk/IdeaSDK/examples/hello-plugin/idea-plugin.json @@ -11,7 +11,8 @@ }, "trustLevel": "full", "capabilities": [ - "ui" + "ui", + "tooling" ], "contributes": { "menus": [ diff --git a/sdk/IdeaSDK/examples/hello-plugin/src/index.ts b/sdk/IdeaSDK/examples/hello-plugin/src/index.ts index e7b658e..a5b691e 100644 --- a/sdk/IdeaSDK/examples/hello-plugin/src/index.ts +++ b/sdk/IdeaSDK/examples/hello-plugin/src/index.ts @@ -71,6 +71,13 @@ export function activate(ctx: ActivateContext): void { } else { ctx.logger.warn("layout registry unavailable", { layoutType: LAYOUT_TYPE }); } + + void ctx.services?.workspace.getCurrentProject().then((project) => { + ctx.logger.info("workspace service available", { + projectId: project?.id ?? null, + hasProjectRoot: Boolean(project?.root) + }); + }); } const plugin: IdeAPluginModule = { diff --git a/sdk/IdeaSDK/src/index.ts b/sdk/IdeaSDK/src/index.ts index 742efc7..16eba6c 100644 --- a/sdk/IdeaSDK/src/index.ts +++ b/sdk/IdeaSDK/src/index.ts @@ -17,7 +17,19 @@ export type { CommandDisposable, CommandHandler, CommandRegistry, + BackgroundTaskOutputAttachment, + BackgroundTaskRetryResult, + BackgroundTaskService, + BackgroundTaskStatus, IdeAPluginModule, PluginLogger, - PluginStorage + PluginServices, + PluginStorage, + TerminalOpenOptions, + TerminalReattachOptions, + TerminalReattachResult, + TerminalService, + TerminalSession, + WorkspaceProject, + WorkspaceService } from "./runtime.js"; diff --git a/sdk/IdeaSDK/src/manifest.js b/sdk/IdeaSDK/src/manifest.js index a08a477..fc8cd50 100644 --- a/sdk/IdeaSDK/src/manifest.js +++ b/sdk/IdeaSDK/src/manifest.js @@ -59,8 +59,8 @@ function validateCapabilities(value, errors) { return; } value.forEach((capability, index) => { - if (capability !== "ui" && capability !== "mcp") { - errors.push(`capabilities[${index}] must be "ui" or "mcp"`); + if (capability !== "ui" && capability !== "mcp" && capability !== "tooling") { + errors.push(`capabilities[${index}] must be "ui", "mcp" or "tooling"`); } }); } diff --git a/sdk/IdeaSDK/src/manifest.ts b/sdk/IdeaSDK/src/manifest.ts index 68bb55a..3b33509 100644 --- a/sdk/IdeaSDK/src/manifest.ts +++ b/sdk/IdeaSDK/src/manifest.ts @@ -17,7 +17,7 @@ export interface IdeAPluginManifest { }; } -export type IdeAPluginCapability = "ui" | "mcp"; +export type IdeAPluginCapability = "ui" | "mcp" | "tooling"; export interface IdeAPluginEngineConstraints { idea?: string; @@ -145,8 +145,8 @@ function validateCapabilities(value: unknown, errors: string[]): void { } value.forEach((capability, index) => { - if (capability !== "ui" && capability !== "mcp") { - errors.push(`capabilities[${index}] must be "ui" or "mcp"`); + if (capability !== "ui" && capability !== "mcp" && capability !== "tooling") { + errors.push(`capabilities[${index}] must be "ui", "mcp" or "tooling"`); } }); } diff --git a/sdk/IdeaSDK/src/runtime.ts b/sdk/IdeaSDK/src/runtime.ts index e7b68a1..09d98ad 100644 --- a/sdk/IdeaSDK/src/runtime.ts +++ b/sdk/IdeaSDK/src/runtime.ts @@ -4,6 +4,12 @@ export interface ActivateContext { subscriptions: CommandDisposable[]; commands?: CommandRegistry; storage?: PluginStorage; + /** + * Stable public service facade for plugins that need workspace, background + * task, or terminal operations. This intentionally does not expose IdeA's + * internal runtime/gateway objects. + */ + services?: PluginServices; } export interface IdeAPluginModule { @@ -34,3 +40,103 @@ export interface PluginStorage { delete(key: string): Promise; } +export interface PluginServices { + workspace: WorkspaceService; + tasks: BackgroundTaskService; + terminal: TerminalService; +} + +export interface WorkspaceProject { + id: string; + name: string; + root: string; +} + +export interface WorkspaceService { + /** Returns the currently focused project, or null when no project is active. */ + getCurrentProject(): Promise; + /** Returns the root path for the given project or for the current project. */ + getProjectRoot(projectId?: string): Promise; + /** Reads IdeA's shared project context for the given or current project. */ + readProjectContext(projectId?: string): Promise; + /** Updates IdeA's shared project context for the given or current project. */ + updateProjectContext(content: string, projectId?: string): Promise; +} + +export interface BackgroundTaskStatus { + taskId: string; + ownerAgentId: string; + projectId: string; + kind: string; + status: "pending" | "running" | "completed" | "failed" | "cancelled" | "delivered"; + exitCode: number | null; + summary: string | null; + stdoutTail: string | null; + stderrTail: string | null; + updatedAtMs: number; +} + +export interface BackgroundTaskOutputAttachment { + taskId: string; + scrollback: Uint8Array; + live: boolean; + detach(): void; +} + +export interface BackgroundTaskRetryResult { + /** Present when the host reports the replacement task id. */ + taskId?: string; +} + +export interface BackgroundTaskService { + /** Lists background tasks visible in the project work-state read model. */ + list(projectId?: string): Promise; + /** Reads one task status from the project work-state read model. */ + getStatus(taskId: string, projectId?: string): Promise; + /** Attaches to retained/live output for a task. */ + attachOutput( + taskId: string, + onData: (bytes: Uint8Array) => void, + ): Promise; + /** Cancels a pending/running task. */ + cancel(taskId: string): Promise; + /** Retries a failed/cancelled task; future hosts may return the new task id. */ + retry(taskId: string): Promise; +} + +export interface TerminalOpenOptions { + cwd?: string; + rows?: number; + cols?: number; + onData?: (bytes: Uint8Array) => void; +} + +export interface TerminalReattachOptions { + onData?: (bytes: Uint8Array) => void; +} + +export interface TerminalSession { + readonly sessionId: string; + write(data: Uint8Array): Promise; + resize(rows: number, cols: number): Promise; + detach(): void; + close(): Promise; +} + +export interface TerminalReattachResult { + session: TerminalSession; + scrollback: Uint8Array; +} + +export interface TerminalService { + /** + * Opens a shell PTY in the requested/current project directory. This MVP is a + * terminal control surface, not a command runner; use tasks for build/test + * commands that should be tracked in the Work panel. + */ + open(options?: TerminalOpenOptions): Promise; + /** Reattaches to an already-running PTY and returns retained scrollback. */ + reattach(sessionId: string, options?: TerminalReattachOptions): Promise; + /** Kills a PTY by id. */ + close(sessionId: string): Promise; +}