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 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
```sh
@ -236,8 +270,10 @@ export async function activate(ctx: ActivateContext): Promise<void> {
```
`watch(path, handler, projectId?)` subscribes to public workspace file-change
events for the given relative path. It is best-effort and bounded: plugins should
handle missed events by refreshing their own derived state when needed.
events for the given relative path. It is best-effort and bounded: hosts may
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

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`;
- 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`.
- plugin-owned storage: activation count, command run count and initialization flag;
- 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.hello-world` layout;
- 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.
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
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";
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> {
const workspace = ctx.services?.workspace;
if (!workspace) return;
@ -44,14 +57,18 @@ export async function useWorkspaceSdk(ctx: ActivateContext): Promise<void> {
messages: diagnostic?.messages
});
const watch = await workspace.watch(".ideai", (event) => {
ctx.logger.info("workspace watch event", {
path: event.path,
kind: event.kind,
operation: event.operation
});
}, project.id);
ctx.subscriptions.push(watch);
try {
const watch = await workspace.watch(".ideai", (event) => {
ctx.logger.info("workspace watch event", {
path: event.path,
kind: event.kind,
operation: event.operation
});
}, project.id);
ctx.subscriptions.push(watch);
} catch (error) {
ctx.logger.info("workspace watch unavailable", { error });
}
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);
}
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);
if (!ownerAgentId) {
ctx.logger.info("command task example skipped: no owner agent configured");
return;
const feedback = {
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,
ownerAgentId,
label: "Hello plugin command",
@ -87,12 +132,17 @@ export async function useWorkspaceSdk(ctx: ActivateContext): Promise<void> {
recordOnly: true
});
if (task) {
const status = await ctx.services?.tasks.getCommandStatus(task.taskId);
ctx.logger.info("command task launched", {
taskId: task.taskId,
state: status?.state ?? task.state,
exitCode: status?.exitCode ?? task.exitCode
});
}
const status = await ctx.services.tasks.getCommandStatus(task.taskId);
const feedback = {
status: "launched",
taskId: task.taskId,
state: status?.state ?? task.state,
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 { HelloWorldLayout } from "./core/layout.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 {
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 commandRunCount = await recordCommandRun(ctx);
ctx.logger.info("command executed", { commandId: COMMAND_ID, commandRunCount });
return "hello-world";
const feedback = await runHelloCommandTask(ctx);
ctx.logger.info("command executed", { commandId: COMMAND_ID, commandRunCount, feedback });
return feedback;
});
if (commandDisposable) {

View File

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

View File

@ -128,6 +128,7 @@ export interface WorkspaceService {
/**
* 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.
* Plugins must treat watch setup as best-effort and non-fatal.
*/
watch(path: string, handler: WorkspaceWatchHandler, projectId?: string): Promise<WorkspaceWatch>;
/** Queries a bounded, generic project structure read model. */
@ -219,9 +220,19 @@ export interface ProjectStructure {
export interface BackgroundTaskStatus {
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;
projectId: 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";
exitCode: number | null;
summary: string | null;
@ -245,7 +256,11 @@ export interface BackgroundTaskRetryResult {
export interface RunCommandTaskOptions {
/** Project that owns the command workspace. Defaults to the focused project. */
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;
/** Human-facing label shown in Work. Defaults to the command line. */
label?: string;
@ -257,7 +272,12 @@ export interface RunCommandTaskOptions {
cwd?: string;
/** Extra environment variables for the command. */
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;
/** Optional absolute deadline, epoch milliseconds. */
deadlineMs?: number;
@ -265,9 +285,17 @@ export interface RunCommandTaskOptions {
export interface CommandTaskStatus {
taskId: string;
/**
* Agent that owns this command task. The host uses it for correlation,
* cancellation and completion delivery.
*/
ownerAgentId: string;
projectId: 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";
exitCode: number | null;
summary: string | null;
@ -479,7 +507,15 @@ export interface ConfigDocumentService {
}
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>;
/** Reads one command task directly from the host task store. */
getCommandStatus(taskId: string): Promise<CommandTaskStatus | null>;