docs(sdk): fige le contrat command-and-feedback et aligne l'exemple hello-plugin
Documente la règle publique menu -> command handler -> tâche de fond optionnelle -> feedback (docs/commands-and-feedback.md + README), clarifie via JSDoc les invariants de runCommand/recordOnly/ownerAgentId dans runtime.ts, et met à jour l'exemple hello-plugin (feedback structuré launched/skipped, watch non-fatal) pour qu'il illustre fidèlement le contrat documenté. Publie docs/ dans le package npm. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
40
README.md
40
README.md
@ -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
|
||||
|
||||
|
||||
154
docs/commands-and-feedback.md
Normal file
154
docs/commands-and-feedback.md
Normal 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.
|
||||
@ -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.
|
||||
|
||||
@ -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,6 +57,7 @@ export async function useWorkspaceSdk(ctx: ActivateContext): Promise<void> {
|
||||
messages: diagnostic?.messages
|
||||
});
|
||||
|
||||
try {
|
||||
const watch = await workspace.watch(".ideai", (event) => {
|
||||
ctx.logger.info("workspace watch event", {
|
||||
path: event.path,
|
||||
@ -52,6 +66,9 @@ export async function useWorkspaceSdk(ctx: ActivateContext): Promise<void> {
|
||||
});
|
||||
}, 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", {
|
||||
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;
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -13,6 +13,7 @@
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"docs",
|
||||
"README.md"
|
||||
],
|
||||
"scripts": {
|
||||
|
||||
@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user