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:
@ -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()
|
||||||
|
|||||||
@ -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()));
|
||||||
|
|||||||
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"])
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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) => {
|
||||||
|
|||||||
@ -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";
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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)),
|
||||||
|
|||||||
@ -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. */
|
||||||
|
|||||||
177
frontend/src/plugins/runtime/services.test.ts
Normal file
177
frontend/src/plugins/runtime/services.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
217
frontend/src/plugins/runtime/services.ts
Normal file
217
frontend/src/plugins/runtime/services.ts
Normal 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 };
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -11,7 +11,8 @@
|
|||||||
},
|
},
|
||||||
"trustLevel": "full",
|
"trustLevel": "full",
|
||||||
"capabilities": [
|
"capabilities": [
|
||||||
"ui"
|
"ui",
|
||||||
|
"tooling"
|
||||||
],
|
],
|
||||||
"contributes": {
|
"contributes": {
|
||||||
"menus": [
|
"menus": [
|
||||||
|
|||||||
@ -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 = {
|
||||||
|
|||||||
@ -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";
|
||||||
|
|||||||
@ -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"`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user