merge feature/sdk-plugin-tooling-build-debug-surface dans develop

feat(sdk,plugins): ajoute capability tooling et services runtime publics (workspace/terminal/tasks)
- 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
This commit is contained in:
2026-08-02 00:31:40 +02:00
21 changed files with 748 additions and 10 deletions

View File

@ -2,7 +2,7 @@ use app_tauri_lib::dto::{
PluginAdminDto, PluginContributionSummaryDto, PluginRuntimeContributionCatalogDto, PluginAdminDto, PluginContributionSummaryDto, PluginRuntimeContributionCatalogDto,
PluginRuntimePluginDto, PluginRuntimePluginDto,
}; };
use domain::{PluginContributionSet, PluginLifecycleState, PluginTrustLevel}; use domain::{PluginCapability, PluginContributionSet, PluginLifecycleState, PluginTrustLevel};
#[test] #[test]
fn plugin_admin_dto_serialises_exact_contract_shape() { 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(), bundle_url: "idea-plugin://dev.acme.gitgraph/1.2.3/abc/dist/index.js".to_owned(),
icon_url: None, icon_url: None,
content_hash: "abc".to_owned(), content_hash: "abc".to_owned(),
capabilities: vec![PluginCapability::Ui, PluginCapability::Tooling],
contributes: PluginContributionSet::default(), 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" "idea-plugin://dev.acme.gitgraph/1.2.3/abc/dist/index.js"
); );
assert_eq!(value["plugins"][0]["contentHash"], "abc"); assert_eq!(value["plugins"][0]["contentHash"], "abc");
assert_eq!(
value["plugins"][0]["capabilities"],
serde_json::json!(["ui", "tooling"])
);
assert!(value["plugins"][0]["contributes"]["menus"] assert!(value["plugins"][0]["contributes"]["menus"]
.as_array() .as_array()
.unwrap() .unwrap()

View File

@ -146,6 +146,8 @@ pub struct PluginRuntimePlugin {
pub icon_url: Option<String>, pub icon_url: Option<String>,
/// Content hash. /// Content hash.
pub content_hash: String, pub content_hash: String,
/// Public manifest capabilities.
pub capabilities: Vec<domain::PluginCapability>,
/// Contributions. /// Contributions.
pub contributes: PluginContributionSet, pub contributes: PluginContributionSet,
} }
@ -824,6 +826,7 @@ async fn runtime_plugin_from_entry(
bundle_url: bundle, bundle_url: bundle,
icon_url, icon_url,
content_hash: descriptor.registry.content_hash.as_str().to_owned(), content_hash: descriptor.registry.content_hash.as_str().to_owned(),
capabilities: descriptor.manifest.capabilities,
contributes: descriptor.manifest.contributes, contributes: descriptor.manifest.contributes,
}) })
} }
@ -1170,6 +1173,7 @@ impl PluginManifestValidator for JsonPluginManifestValidator {
.map(|c| match c.as_str() { .map(|c| match c.as_str() {
"ui" => Ok(domain::PluginCapability::Ui), "ui" => Ok(domain::PluginCapability::Ui),
"mcp" => Ok(domain::PluginCapability::Mcp), "mcp" => Ok(domain::PluginCapability::Mcp),
"tooling" => Ok(domain::PluginCapability::Tooling),
_ => Err(PluginManifestError::Invalid(format!( _ => Err(PluginManifestError::Invalid(format!(
"unknown capability: {c}" "unknown capability: {c}"
))), ))),
@ -1377,7 +1381,7 @@ mod tests {
"engines": {"idea": ">=0.1.0 <1.0.0"}, "engines": {"idea": ">=0.1.0 <1.0.0"},
"main": "dist/index.js", "main": "dist/index.js",
"trustLevel": "full", "trustLevel": "full",
"capabilities": ["ui", "mcp"], "capabilities": ["ui", "mcp", "tooling"],
"contributes": { "contributes": {
"menus": [{"id":"dev.acme.menu","label":"Graph","topLevel":true}], "menus": [{"id":"dev.acme.menu","label":"Graph","topLevel":true}],
"menuItems": [{"id":"dev.acme.open","targetMenuId":"panels","label":"Open","command":"dev.acme.open"}], "menuItems": [{"id":"dev.acme.open","targetMenuId":"panels","label":"Open","command":"dev.acme.open"}],
@ -1630,10 +1634,39 @@ mod tests {
) )
.unwrap(); .unwrap();
assert_eq!(m.id.as_str(), "dev.acme.gitgraph"); 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.layouts.len(), 1);
assert_eq!(m.contributes.mcp_servers.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] #[test]
fn rejects_unsafe_main_path_and_non_full_trust() { fn rejects_unsafe_main_path_and_non_full_trust() {
let mut value: serde_json::Value = serde_json::from_slice(&valid_manifest()).unwrap(); 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] #[tokio::test]
async fn runtime_catalog_marks_invalid_active_plugin_and_keeps_bootstrap_alive() { async fn runtime_catalog_marks_invalid_active_plugin_and_keeps_bootstrap_alive() {
let packages = Arc::new(FakePackages::with_manifest(br#"{"broken":true}"#.to_vec())); let packages = Arc::new(FakePackages::with_manifest(br#"{"broken":true}"#.to_vec()));

View File

@ -239,6 +239,8 @@ pub struct PluginRuntimePluginDto {
pub icon_url: Option<String>, pub icon_url: Option<String>,
/// Content hash. /// Content hash.
pub content_hash: String, pub content_hash: String,
/// Public manifest capabilities.
pub capabilities: Vec<domain::PluginCapability>,
/// Contributions. /// Contributions.
pub contributes: domain::PluginContributionSet, pub contributes: domain::PluginContributionSet,
} }
@ -265,6 +267,7 @@ impl From<application::PluginRuntimePlugin> for PluginRuntimePluginDto {
bundle_url: value.bundle_url, bundle_url: value.bundle_url,
icon_url: value.icon_url, icon_url: value.icon_url,
content_hash: value.content_hash, content_hash: value.content_hash,
capabilities: value.capabilities,
contributes: value.contributes, contributes: value.contributes,
} }
} }

View File

@ -284,6 +284,8 @@ pub enum PluginCapability {
Ui, Ui,
/// External MCP server declarations. /// External MCP server declarations.
Mcp, Mcp,
/// Public tooling/build/debug services.
Tooling,
} }
/// Plugin command id. /// Plugin command id.
@ -678,4 +680,17 @@ mod tests {
assert!(!PluginLifecycleState::PendingUninstall.is_runtime_active()); assert!(!PluginLifecycleState::PendingUninstall.is_runtime_active());
assert!(PluginLifecycleState::Enabled.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"])
);
}
} }

View File

@ -1748,6 +1748,7 @@ export interface PluginRuntimePlugin {
displayName: string; displayName: string;
publisher?: string; publisher?: string;
version: string; version: string;
capabilities?: string[];
bundleUrl: string; bundleUrl: string;
iconUrl?: string; iconUrl?: string;
contentHash: string; contentHash: string;

View File

@ -114,6 +114,8 @@ export function PluginLayoutCellView({
terminal: gateways.terminal, terminal: gateways.terminal,
agents: gateways.agent, agents: gateways.agent,
system: gateways.system, system: gateways.system,
workState: gateways.workState,
focusedProject: gateways.focusedProject,
}} }}
/> />
</PluginLayoutErrorBoundary> </PluginLayoutErrorBoundary>

View File

@ -73,6 +73,8 @@ export function PluginRuntimeProvider({ children, value: injected }: PluginRunti
terminal: gateways.terminal, terminal: gateways.terminal,
agents: gateways.agent, agents: gateways.agent,
system: gateways.system, system: gateways.system,
workState: gateways.workState,
focusedProject: gateways.focusedProject,
}), }),
) )
.then((result) => { .then((result) => {

View File

@ -18,4 +18,19 @@ export {
type PluginLoadFailure, type PluginLoadFailure,
type PluginLoadResult, type PluginLoadResult,
} from "./loader"; } 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"; export { evaluateWhen, type WhenContext, type WhenEvalResult, type WhenVariable } from "./when";

View File

@ -178,6 +178,68 @@ describe("loadPlugins", () => {
expect(registry.get("com.example.hello-plugin")).toBeUndefined(); 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<string, unknown>).__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<string, unknown>).__serviceKeys).toEqual([
"tasks",
"terminal",
"workspace",
]);
expect((globalThis as Record<string, unknown>).__workspaceServiceKeys).toEqual([
"getCurrentProject",
"getProjectRoot",
"readProjectContext",
"updateProjectContext",
]);
expect((globalThis as Record<string, unknown>).__taskServiceKeys).toEqual([
"attachOutput",
"cancel",
"getStatus",
"list",
"retry",
]);
expect((globalThis as Record<string, unknown>).__terminalServiceKeys).toEqual([
"close",
"open",
"reattach",
]);
});
it("loads the hello-plugin command and layout contribution shape", async () => { it("loads the hello-plugin command and layout contribution shape", async () => {
const bundle = dataUrl(` const bundle = dataUrl(`
export function activate(ctx) { export function activate(ctx) {

View File

@ -25,6 +25,7 @@ import {
type LoadedPlugin, type LoadedPlugin,
type PluginGatewaySet, type PluginGatewaySet,
} from "./registry"; } from "./registry";
import { createPluginServices, type PluginServices } from "./services";
export type { PluginGatewaySet } from "./registry"; export type { PluginGatewaySet } from "./registry";
@ -37,6 +38,7 @@ export interface IdeaPluginContext extends PluginGatewaySet {
commands: PluginCommandContext; commands: PluginCommandContext;
layouts: PluginLayoutRegistry; layouts: PluginLayoutRegistry;
menu: PluginMenuRegistry; menu: PluginMenuRegistry;
services?: PluginServices;
} }
export interface PluginActivation { export interface PluginActivation {
@ -177,6 +179,10 @@ function normalizeContributes(entry: PluginRuntimePlugin): PluginContributionDto
}; };
} }
function hasCapability(entry: PluginRuntimePlugin, capability: string): boolean {
return arrayOrEmpty<string>(objectOrEmpty(entry).capabilities).includes(capability);
}
async function disposeAll(disposables: Disposable[], activation?: void | PluginActivation): Promise<void> { async function disposeAll(disposables: Disposable[], activation?: void | PluginActivation): Promise<void> {
try { try {
await activation?.dispose?.(); await activation?.dispose?.();
@ -247,6 +253,9 @@ async function loadOne(
menu, menu,
...gateways, ...gateways,
}; };
if (hasCapability(entry, "tooling")) {
ctx.services = createPluginServices(gateways);
}
activation = await withTimeout( activation = await withTimeout(
Promise.resolve(mod.activate(ctx)), Promise.resolve(mod.activate(ctx)),

View File

@ -19,7 +19,15 @@ import type {
PluginMenuItemContribution, PluginMenuItemContribution,
PluginTopLevelMenuContribution, PluginTopLevelMenuContribution,
} from "@/domain"; } 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). */ /** The stable gateways a plugin's `activate(ctx)` is allowed to reach (carnet §6). */
export interface PluginGatewaySet { export interface PluginGatewaySet {
@ -28,6 +36,8 @@ export interface PluginGatewaySet {
terminal: TerminalGateway; terminal: TerminalGateway;
agents: AgentGateway; agents: AgentGateway;
system: SystemGateway; system: SystemGateway;
workState: WorkStateGateway;
focusedProject: FocusedProjectGateway;
} }
/** A disposable handle returned by every `register*` call. */ /** A disposable handle returned by every `register*` call. */

View File

@ -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<FocusedProjectGateway>;
project?: Partial<ProjectGateway>;
workState?: Partial<WorkStateGateway>;
terminal?: Partial<TerminalGateway>;
} = {}) {
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<ProjectWorkState> => ({
agents: [],
conversations: [],
})),
attachBackgroundTask: vi.fn(async (taskId): Promise<BackgroundTaskAttachment> => ({
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<ReattachResult> => ({
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<ProjectWorkState> => ({
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");
});
});

View File

@ -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<WorkspaceProject | null>;
getProjectRoot(projectId?: string): Promise<string>;
readProjectContext(projectId?: string): Promise<string>;
updateProjectContext(content: string, projectId?: string): Promise<void>;
}
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<BackgroundTaskStatus[]>;
getStatus(taskId: string, projectId?: string): Promise<BackgroundTaskStatus | null>;
attachOutput(
taskId: string,
onData: (bytes: Uint8Array) => void,
): Promise<BackgroundTaskOutputAttachment>;
cancel(taskId: string): Promise<void>;
retry(taskId: string): Promise<BackgroundTaskRetryResult>;
}
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<void>;
resize(rows: number, cols: number): Promise<void>;
detach(): void;
close(): Promise<void>;
}
export interface TerminalReattachResult {
session: TerminalSession;
scrollback: Uint8Array;
}
export interface TerminalService {
open(options?: TerminalOpenOptions): Promise<TerminalSession>;
reattach(sessionId: string, options?: TerminalReattachOptions): Promise<TerminalReattachResult>;
close(sessionId: string): Promise<void>;
}
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<WorkspaceProject | null> {
const focused = await gateways.focusedProject.getFocusedProject();
return focused ? toWorkspaceProject(focused) : null;
}
async function requireProject(projectId?: string): Promise<WorkspaceProject> {
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<BackgroundTaskStatus[]> {
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 };
}

View File

@ -6,6 +6,7 @@ This first version intentionally stays small:
- public manifest types for `idea-plugin.json`; - public manifest types for `idea-plugin.json`;
- public runtime types for plugin modules exposing `activate(ctx)`; - 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 lightweight manifest validator;
- a minimal `examples/hello-plugin` plugin. - 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<void> {
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 ## Manifest Validation
```ts ```ts

View File

@ -8,6 +8,7 @@ It exercises the current plugin primitives end to end:
- menu entry: `hello-plugin`; - menu entry: `hello-plugin`;
- command: `hello-plugin`, returning `hello-world`; - command: `hello-plugin`, returning `hello-world`;
- layout contribution: `hello-plugin.hello-world`, rendered as `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 ```sh
npm run typecheck:examples npm run typecheck:examples
@ -25,6 +26,7 @@ During activation the plugin logs:
- whether the command and layout runtime registries are available; - whether the command and layout runtime registries are available;
- successful registration of the `hello-plugin` command; - successful registration of the `hello-plugin` command;
- successful registration of the `hello-plugin.hello-world` layout; - 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. - the first layout render, including project/node identifiers.
These messages are intentionally small and stable so installation, bundle import, activation and These messages are intentionally small and stable so installation, bundle import, activation and

View File

@ -11,7 +11,8 @@
}, },
"trustLevel": "full", "trustLevel": "full",
"capabilities": [ "capabilities": [
"ui" "ui",
"tooling"
], ],
"contributes": { "contributes": {
"menus": [ "menus": [

View File

@ -71,6 +71,13 @@ export function activate(ctx: ActivateContext): void {
} else { } else {
ctx.logger.warn("layout registry unavailable", { layoutType: LAYOUT_TYPE }); 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 = { const plugin: IdeAPluginModule = {

View File

@ -17,7 +17,19 @@ export type {
CommandDisposable, CommandDisposable,
CommandHandler, CommandHandler,
CommandRegistry, CommandRegistry,
BackgroundTaskOutputAttachment,
BackgroundTaskRetryResult,
BackgroundTaskService,
BackgroundTaskStatus,
IdeAPluginModule, IdeAPluginModule,
PluginLogger, PluginLogger,
PluginStorage PluginServices,
PluginStorage,
TerminalOpenOptions,
TerminalReattachOptions,
TerminalReattachResult,
TerminalService,
TerminalSession,
WorkspaceProject,
WorkspaceService
} from "./runtime.js"; } from "./runtime.js";

View File

@ -59,8 +59,8 @@ function validateCapabilities(value, errors) {
return; return;
} }
value.forEach((capability, index) => { value.forEach((capability, index) => {
if (capability !== "ui" && capability !== "mcp") { if (capability !== "ui" && capability !== "mcp" && capability !== "tooling") {
errors.push(`capabilities[${index}] must be "ui" or "mcp"`); errors.push(`capabilities[${index}] must be "ui", "mcp" or "tooling"`);
} }
}); });
} }

View File

@ -17,7 +17,7 @@ export interface IdeAPluginManifest {
}; };
} }
export type IdeAPluginCapability = "ui" | "mcp"; export type IdeAPluginCapability = "ui" | "mcp" | "tooling";
export interface IdeAPluginEngineConstraints { export interface IdeAPluginEngineConstraints {
idea?: string; idea?: string;
@ -145,8 +145,8 @@ function validateCapabilities(value: unknown, errors: string[]): void {
} }
value.forEach((capability, index) => { value.forEach((capability, index) => {
if (capability !== "ui" && capability !== "mcp") { if (capability !== "ui" && capability !== "mcp" && capability !== "tooling") {
errors.push(`capabilities[${index}] must be "ui" or "mcp"`); errors.push(`capabilities[${index}] must be "ui", "mcp" or "tooling"`);
} }
}); });
} }

View File

@ -4,6 +4,12 @@ export interface ActivateContext {
subscriptions: CommandDisposable[]; subscriptions: CommandDisposable[];
commands?: CommandRegistry; commands?: CommandRegistry;
storage?: PluginStorage; 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 { export interface IdeAPluginModule {
@ -34,3 +40,103 @@ export interface PluginStorage {
delete(key: string): Promise<void>; delete(key: string): Promise<void>;
} }
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<WorkspaceProject | null>;
/** Returns the root path for the given project or for the current project. */
getProjectRoot(projectId?: string): Promise<string>;
/** Reads IdeA's shared project context for the given or current project. */
readProjectContext(projectId?: string): Promise<string>;
/** Updates IdeA's shared project context for the given or current project. */
updateProjectContext(content: string, projectId?: string): Promise<void>;
}
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<BackgroundTaskStatus[]>;
/** Reads one task status from the project work-state read model. */
getStatus(taskId: string, projectId?: string): Promise<BackgroundTaskStatus | null>;
/** Attaches to retained/live output for a task. */
attachOutput(
taskId: string,
onData: (bytes: Uint8Array) => void,
): Promise<BackgroundTaskOutputAttachment>;
/** Cancels a pending/running task. */
cancel(taskId: string): Promise<void>;
/** Retries a failed/cancelled task; future hosts may return the new task id. */
retry(taskId: string): Promise<BackgroundTaskRetryResult>;
}
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<void>;
resize(rows: number, cols: number): Promise<void>;
detach(): void;
close(): Promise<void>;
}
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<TerminalSession>;
/** Reattaches to an already-running PTY and returns retained scrollback. */
reattach(sessionId: string, options?: TerminalReattachOptions): Promise<TerminalReattachResult>;
/** Kills a PTY by id. */
close(sessionId: string): Promise<void>;
}