merge(sdk): intègre feature/sdk-package-doc — contrat command-and-feedback documenté (vert QA)

This commit is contained in:
2026-08-03 15:51:38 +02:00
7 changed files with 318 additions and 29 deletions

View File

@ -17,6 +17,40 @@ This first version intentionally stays small:
- a lightweight manifest validator; - a lightweight manifest validator;
- a minimal `examples/hello-plugin` plugin. - a minimal `examples/hello-plugin` plugin.
## Command And Feedback Contract
Plugin menu actions follow one public contract:
```text
menu click -> registered command handler -> optional background task -> feedback surfaces
```
A menu item only names a command. The registered command handler owns all
precondition checks, task launch decisions and feedback. If a command
cannot or should not start work, return a small structured result such as
`{ status: "skipped", reason, message }` and log the same reason. That return
value is useful for programmatic callers, agents and future host surfaces; the
current human menu-click UI does not guarantee that handler return values are
shown to the user. Do not create a background task just to represent a skipped
command.
Use `ctx.services.tasks.runCommand()` only after required preconditions are
true: a focused project exists, the plugin has the `tooling` capability, required
executables/files/env are present, and a real `ownerAgentId` is available when
the result belongs to an agent workflow. `ownerAgentId` controls Work ownership,
cancellation and completion delivery; it must not be a placeholder in production
plugin code.
`recordOnly: true` records completion without waking the owner agent. It is not a
silent mode and it does not hide the task from surfaces that show Work state or
background-task events. If no task is launched, the baseline feedback surfaces
are the command result for programmatic callers and plugin logs; human-visible UI
feedback requires a host-supported surface, a real background task, or a
plugin-owned UI/file surface.
Canonical rules and examples live in
[`docs/commands-and-feedback.md`](docs/commands-and-feedback.md).
## Install ## Install
```sh ```sh
@ -236,8 +270,10 @@ export async function activate(ctx: ActivateContext): Promise<void> {
``` ```
`watch(path, handler, projectId?)` subscribes to public workspace file-change `watch(path, handler, projectId?)` subscribes to public workspace file-change
events for the given relative path. It is best-effort and bounded: plugins should events for the given relative path. It is best-effort and bounded: hosts may
handle missed events by refreshing their own derived state when needed. reject it until workspace watching is implemented, and plugins must treat setup
failure as non-fatal. Plugins should also handle missed events by refreshing
their own derived state when needed.
### Project Structure ### Project Structure

View File

@ -0,0 +1,154 @@
# Commands And Feedback
This document is the normative SDK contract for plugin commands that may launch
background work.
## Contract
Every human menu click follows this sequence:
```text
menu item -> command id -> registered command handler -> optional task -> feedback surfaces
```
- A manifest menu item declares a `command` id; it does not run tools directly.
- The command handler is the only place that decides whether work should start.
- A launched process is represented by a background task returned from
`ctx.services.tasks.runCommand()`.
- A skipped command is represented by the command handler return value and logs,
not by a fake task.
- Feedback objects must be stable enough for agents and programmatic callers to
parse, and their messages must be readable by humans.
- The current human menu-click UI does not guarantee display of a command
handler return value. Use logs, Work/task state or plugin-owned UI/files when
a human needs visible feedback today.
## Preconditions
Check preconditions before calling `runCommand()`:
- `ctx.services` exists. Plugins need the `tooling` capability for the service
facade.
- `ctx.services.workspace.getCurrentProject()` returned a project, or the caller
supplied a valid `projectId`.
- Required executables, environment variables and workspace files were validated,
preferably with `ctx.services.tooling.diagnose()`.
- `ownerAgentId` is a real agent id when the work is owned by an agent workflow.
Do not use all-zero placeholders in production.
- `cwd` is relative to the project root.
- `command` and `args` are separate values. Do not shell-join user input.
If any required precondition fails, return a skipped result:
```ts
return {
status: "skipped",
reason: "missing-owner-agent",
message: "Configure an owner agent before launching the hello-plugin task."
};
```
## Feedback Surfaces
Command handlers should return one of these shapes, or a plugin-specific object
with equivalent fields:
```ts
type CommandFeedback =
| { status: "skipped"; reason: string; message: string }
| { status: "launched"; taskId: string; state: string; message: string };
```
Use the same `status` vocabulary consistently:
- `skipped`: no background task was created.
- `launched`: a task was created; inspect the task state for later progress.
- `failed`: the handler itself failed before it could return normally.
Visible surfaces are intentionally distinct:
- Command return value: immediate feedback for programmatic callers, agents and
future host surfaces. It is not a guaranteed visible UI surface for current
human menu clicks.
- Plugin logs: diagnostics for developers and operators.
- Work/background-task surfaces: only for tasks actually launched through
`runCommand()`.
- Plugin layouts or files: optional plugin-owned user feedback.
`recordOnly: true` affects completion delivery to the owning agent. It does not
mean hidden, skipped or UI-silent. A record-only command task can still appear in
Work and can still emit background-task events.
## Best-Effort Watches
`ctx.services.workspace.watch()` is not a baseline precondition for commands.
The host may reject it until workspace watch support is delivered. Treat watch
setup as optional and non-fatal:
```ts
try {
const watch = await ctx.services.workspace.watch(".ideai", refresh, project.id);
ctx.subscriptions.push(watch);
} catch (error) {
ctx.logger.info("workspace watch unavailable", { error });
}
```
Commands that depend on fresh workspace state should refresh or re-read that
state when invoked instead of assuming a watch was installed at activation.
## Example
```ts
const project = await ctx.services?.workspace.getCurrentProject();
if (!project) {
return {
status: "skipped",
reason: "no-focused-project",
message: "Open a project before running this command."
};
}
const ownerAgentId = await ctx.storage?.get<string>("myPlugin.ownerAgentId");
if (!ownerAgentId) {
return {
status: "skipped",
reason: "missing-owner-agent",
message: "Configure an owner agent before launching this task."
};
}
const task = await ctx.services.tasks.runCommand({
projectId: project.id,
ownerAgentId,
label: "Run my tool",
command: "npm",
args: ["--version"],
cwd: ".",
recordOnly: true
});
return {
status: "launched",
taskId: task.taskId,
state: task.state,
message: "Started Run my tool."
};
```
## Counterexample
Do not launch a shell command just to produce feedback:
```ts
await ctx.services?.tasks.runCommand({
ownerAgentId: "00000000-0000-0000-0000-000000000000",
label: "Skipped: missing config",
command: "echo",
args: ["missing config"],
recordOnly: true
});
```
This creates misleading Work history, uses a placeholder owner and turns a
precondition failure into a fake task. Return `status: "skipped"` instead.

View File

@ -6,7 +6,10 @@ It exercises the current plugin primitives end to end:
- top-level menu: `Hello Plugin`; - top-level menu: `Hello Plugin`;
- menu entry: `hello-plugin`; - menu entry: `hello-plugin`;
- command: `hello-plugin`, returning `hello-world`; - command: `hello-plugin`, returning readable feedback:
`{ status: "launched", taskId, state, message }` when it starts a background
task, or `{ status: "skipped", reason, message }` when a precondition is not
met;
- layout contribution: `hello-plugin.hello-world`, rendered as `hello-world`. - layout contribution: `hello-plugin.hello-world`, rendered as `hello-world`.
- plugin-owned storage: activation count, command run count and initialization flag; - plugin-owned storage: activation count, command run count and initialization flag;
- tooling capability: logs the focused workspace project when `ctx.services` is available. - tooling capability: logs the focused workspace project when `ctx.services` is available.
@ -42,7 +45,15 @@ During activation the plugin logs:
- 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; - availability of the workspace service from the `tooling` runtime capability;
- best-effort workspace watch setup, including a non-fatal log when unavailable;
- the first layout render, including project/node identifiers. - the first layout render, including project/node identifiers.
During command invocation the plugin logs and returns structured feedback for
programmatic callers. The current human menu-click UI does not guarantee display
of that return value.
- skipped command feedback when no project or no `helloPlugin.ownerAgentId` is available;
- launched background command feedback when `helloPlugin.ownerAgentId` is configured;
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
layout rendering failures can be separated quickly in IdeA logs/devtools. layout rendering failures can be separated quickly in IdeA logs/devtools.

View File

@ -1,6 +1,19 @@
import type { ActivateContext } from "@idea/plugin-sdk"; import type { ActivateContext, CommandTaskStatus } from "@idea/plugin-sdk";
import { STORAGE_KEYS } from "../constants.js"; import { STORAGE_KEYS } from "../constants.js";
export type HelloCommandFeedback =
| {
status: "skipped";
reason: "services-unavailable" | "no-focused-project" | "missing-owner-agent";
message: string;
}
| {
status: "launched";
taskId: string;
state: CommandTaskStatus["state"];
message: string;
};
export async function useWorkspaceSdk(ctx: ActivateContext): Promise<void> { export async function useWorkspaceSdk(ctx: ActivateContext): Promise<void> {
const workspace = ctx.services?.workspace; const workspace = ctx.services?.workspace;
if (!workspace) return; if (!workspace) return;
@ -44,14 +57,18 @@ export async function useWorkspaceSdk(ctx: ActivateContext): Promise<void> {
messages: diagnostic?.messages messages: diagnostic?.messages
}); });
const watch = await workspace.watch(".ideai", (event) => { try {
ctx.logger.info("workspace watch event", { const watch = await workspace.watch(".ideai", (event) => {
path: event.path, ctx.logger.info("workspace watch event", {
kind: event.kind, path: event.path,
operation: event.operation kind: event.kind,
}); operation: event.operation
}, project.id); });
ctx.subscriptions.push(watch); }, project.id);
ctx.subscriptions.push(watch);
} catch (error) {
ctx.logger.info("workspace watch unavailable", { error });
}
const events = await ctx.services?.events.subscribe( const events = await ctx.services?.events.subscribe(
{ {
@ -70,14 +87,42 @@ export async function useWorkspaceSdk(ctx: ActivateContext): Promise<void> {
} }
); );
if (events) ctx.subscriptions.push(events); if (events) ctx.subscriptions.push(events);
}
export async function runHelloCommandTask(ctx: ActivateContext): Promise<HelloCommandFeedback> {
if (!ctx.services) {
const feedback = {
status: "skipped",
reason: "services-unavailable",
message: "The tooling service facade is unavailable for hello-plugin."
} as const;
ctx.logger.info("hello command skipped", feedback);
return feedback;
}
const project = await ctx.services.workspace.getCurrentProject();
if (!project) {
const feedback = {
status: "skipped",
reason: "no-focused-project",
message: "Open a project before launching the hello-plugin task."
} as const;
ctx.logger.info("hello command skipped", feedback);
return feedback;
}
const ownerAgentId = await ctx.storage?.get<string>(STORAGE_KEYS.ownerAgentId); const ownerAgentId = await ctx.storage?.get<string>(STORAGE_KEYS.ownerAgentId);
if (!ownerAgentId) { if (!ownerAgentId) {
ctx.logger.info("command task example skipped: no owner agent configured"); const feedback = {
return; status: "skipped",
reason: "missing-owner-agent",
message: "Configure helloPlugin.ownerAgentId in plugin storage before launching the task."
} as const;
ctx.logger.info("hello command skipped", feedback);
return feedback;
} }
const task = await ctx.services?.tasks.runCommand({ const task = await ctx.services.tasks.runCommand({
projectId: project.id, projectId: project.id,
ownerAgentId, ownerAgentId,
label: "Hello plugin command", label: "Hello plugin command",
@ -87,12 +132,17 @@ export async function useWorkspaceSdk(ctx: ActivateContext): Promise<void> {
recordOnly: true recordOnly: true
}); });
if (task) { const status = await ctx.services.tasks.getCommandStatus(task.taskId);
const status = await ctx.services?.tasks.getCommandStatus(task.taskId); const feedback = {
ctx.logger.info("command task launched", { status: "launched",
taskId: task.taskId, taskId: task.taskId,
state: status?.state ?? task.state, state: status?.state ?? task.state,
exitCode: status?.exitCode ?? task.exitCode message: "Started the hello-plugin background command."
}); } as const;
}
ctx.logger.info("hello command task launched", {
...feedback,
exitCode: status?.exitCode ?? task.exitCode
});
return feedback;
} }

View File

@ -2,7 +2,7 @@ import type { ActivateContext, IdeAPluginModule } from "@idea/plugin-sdk";
import { COMMAND_ID, LAYOUT_TYPE } from "./constants.js"; import { COMMAND_ID, LAYOUT_TYPE } from "./constants.js";
import { HelloWorldLayout } from "./core/layout.js"; import { HelloWorldLayout } from "./core/layout.js";
import { initializePluginStorage, recordCommandRun } from "./core/storage.js"; import { initializePluginStorage, recordCommandRun } from "./core/storage.js";
import { useWorkspaceSdk } from "./core/workspace.js"; import { runHelloCommandTask, useWorkspaceSdk } from "./core/workspace.js";
export function activate(ctx: ActivateContext): void { export function activate(ctx: ActivateContext): void {
ctx.logger.info("activating hello-plugin", { ctx.logger.info("activating hello-plugin", {
@ -14,8 +14,9 @@ export function activate(ctx: ActivateContext): void {
const commandDisposable = ctx.commands?.registerCommand(COMMAND_ID, async () => { const commandDisposable = ctx.commands?.registerCommand(COMMAND_ID, async () => {
const commandRunCount = await recordCommandRun(ctx); const commandRunCount = await recordCommandRun(ctx);
ctx.logger.info("command executed", { commandId: COMMAND_ID, commandRunCount }); const feedback = await runHelloCommandTask(ctx);
return "hello-world"; ctx.logger.info("command executed", { commandId: COMMAND_ID, commandRunCount, feedback });
return feedback;
}); });
if (commandDisposable) { if (commandDisposable) {

View File

@ -13,6 +13,7 @@
}, },
"files": [ "files": [
"dist", "dist",
"docs",
"README.md" "README.md"
], ],
"scripts": { "scripts": {

View File

@ -128,6 +128,7 @@ export interface WorkspaceService {
/** /**
* Extension point for host file watching. The MVP SDK reserves the public * Extension point for host file watching. The MVP SDK reserves the public
* shape; hosts may reject with a clear not-implemented error until #127 lands. * shape; hosts may reject with a clear not-implemented error until #127 lands.
* Plugins must treat watch setup as best-effort and non-fatal.
*/ */
watch(path: string, handler: WorkspaceWatchHandler, projectId?: string): Promise<WorkspaceWatch>; watch(path: string, handler: WorkspaceWatchHandler, projectId?: string): Promise<WorkspaceWatch>;
/** Queries a bounded, generic project structure read model. */ /** Queries a bounded, generic project structure read model. */
@ -219,9 +220,19 @@ export interface ProjectStructure {
export interface BackgroundTaskStatus { export interface BackgroundTaskStatus {
taskId: string; taskId: string;
/**
* Agent that owns completion delivery and Work attribution for this task.
* This is host-assigned for existing tasks and should be treated as an opaque
* agent id by plugins.
*/
ownerAgentId: string; ownerAgentId: string;
projectId: string; projectId: string;
kind: string; kind: string;
/**
* Work read-model status. A skipped plugin command is not a background task
* and therefore never appears here; skipped commands should be reported by
* the command handler return value and plugin logs.
*/
status: "pending" | "running" | "completed" | "failed" | "cancelled" | "delivered"; status: "pending" | "running" | "completed" | "failed" | "cancelled" | "delivered";
exitCode: number | null; exitCode: number | null;
summary: string | null; summary: string | null;
@ -245,7 +256,11 @@ export interface BackgroundTaskRetryResult {
export interface RunCommandTaskOptions { export interface RunCommandTaskOptions {
/** Project that owns the command workspace. Defaults to the focused project. */ /** Project that owns the command workspace. Defaults to the focused project. */
projectId?: string; projectId?: string;
/** Agent id used by IdeA Work for ownership, cancellation and completion delivery. */ /**
* Real agent id used by IdeA Work for ownership, cancellation and completion
* delivery. Plugins must obtain this from host/plugin state for the workflow
* they are serving; placeholder ids are only acceptable in isolated examples.
*/
ownerAgentId: string; ownerAgentId: string;
/** Human-facing label shown in Work. Defaults to the command line. */ /** Human-facing label shown in Work. Defaults to the command line. */
label?: string; label?: string;
@ -257,7 +272,12 @@ export interface RunCommandTaskOptions {
cwd?: string; cwd?: string;
/** Extra environment variables for the command. */ /** Extra environment variables for the command. */
env?: Record<string, string> | Array<[string, string]>; env?: Record<string, string> | Array<[string, string]>;
/** When true, completion is recorded without waking the owner agent. */ /**
* When true, completion is recorded without waking the owner agent. This does
* not hide the task from Work/background-task surfaces and does not represent
* a skipped command. If preconditions fail, return readable command feedback
* instead of launching a record-only task.
*/
recordOnly?: boolean; recordOnly?: boolean;
/** Optional absolute deadline, epoch milliseconds. */ /** Optional absolute deadline, epoch milliseconds. */
deadlineMs?: number; deadlineMs?: number;
@ -265,9 +285,17 @@ export interface RunCommandTaskOptions {
export interface CommandTaskStatus { export interface CommandTaskStatus {
taskId: string; taskId: string;
/**
* Agent that owns this command task. The host uses it for correlation,
* cancellation and completion delivery.
*/
ownerAgentId: string; ownerAgentId: string;
projectId: string; projectId: string;
kind: string; kind: string;
/**
* Lifecycle state of a command task that was actually launched. There is no
* `skipped` state: skipped commands are command-handler feedback, not tasks.
*/
state: "queued" | "running" | "waiting" | "completed" | "failed" | "cancelled" | "expired"; state: "queued" | "running" | "waiting" | "completed" | "failed" | "cancelled" | "expired";
exitCode: number | null; exitCode: number | null;
summary: string | null; summary: string | null;
@ -479,7 +507,15 @@ export interface ConfigDocumentService {
} }
export interface BackgroundTaskService { export interface BackgroundTaskService {
/** Launches a non-interactive command as a first-class IdeA background task. */ /**
* Launches a non-interactive command as a first-class IdeA background task.
*
* Call this only after command preconditions are satisfied. The returned
* `CommandTaskStatus` means a task exists and can be inspected through command
* status APIs and Work/background-task surfaces. A plugin command that decides
* not to launch work should return readable command feedback, for example
* `{ status: "skipped", reason, message }`, and should not call `runCommand()`.
*/
runCommand(options: RunCommandTaskOptions): Promise<CommandTaskStatus>; runCommand(options: RunCommandTaskOptions): Promise<CommandTaskStatus>;
/** Reads one command task directly from the host task store. */ /** Reads one command task directly from the host task store. */
getCommandStatus(taskId: string): Promise<CommandTaskStatus | null>; getCommandStatus(taskId: string): Promise<CommandTaskStatus | null>;