From 5b559965585a05af9eef7a76602620ec7be3300b Mon Sep 17 00:00:00 2001 From: Blomios Date: Mon, 3 Aug 2026 15:51:30 +0200 Subject: [PATCH] docs(sdk): fige le contrat command-and-feedback et aligne l'exemple hello-plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 40 ++++- docs/commands-and-feedback.md | 154 ++++++++++++++++++++ examples/hello-plugin/README.md | 13 +- examples/hello-plugin/src/core/workspace.ts | 90 +++++++++--- examples/hello-plugin/src/index.ts | 7 +- package.json | 1 + src/runtime.ts | 42 +++++- 7 files changed, 318 insertions(+), 29 deletions(-) create mode 100644 docs/commands-and-feedback.md diff --git a/README.md b/README.md index 50a1949..e239cd7 100644 --- a/README.md +++ b/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 { ``` `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 diff --git a/docs/commands-and-feedback.md b/docs/commands-and-feedback.md new file mode 100644 index 0000000..1959faa --- /dev/null +++ b/docs/commands-and-feedback.md @@ -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("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. diff --git a/examples/hello-plugin/README.md b/examples/hello-plugin/README.md index ca12ee0..00633e0 100644 --- a/examples/hello-plugin/README.md +++ b/examples/hello-plugin/README.md @@ -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. diff --git a/examples/hello-plugin/src/core/workspace.ts b/examples/hello-plugin/src/core/workspace.ts index 937532e..093ed83 100644 --- a/examples/hello-plugin/src/core/workspace.ts +++ b/examples/hello-plugin/src/core/workspace.ts @@ -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 { const workspace = ctx.services?.workspace; if (!workspace) return; @@ -44,14 +57,18 @@ export async function useWorkspaceSdk(ctx: ActivateContext): Promise { 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 { } ); if (events) ctx.subscriptions.push(events); +} + +export async function runHelloCommandTask(ctx: ActivateContext): Promise { + 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(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 { 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; } diff --git a/examples/hello-plugin/src/index.ts b/examples/hello-plugin/src/index.ts index 210bbad..007edea 100644 --- a/examples/hello-plugin/src/index.ts +++ b/examples/hello-plugin/src/index.ts @@ -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) { diff --git a/package.json b/package.json index b6a16b1..de67eac 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ }, "files": [ "dist", + "docs", "README.md" ], "scripts": { diff --git a/src/runtime.ts b/src/runtime.ts index a4240b5..88286fe 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -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; /** 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 | 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; /** Reads one command task directly from the host task store. */ getCommandStatus(taskId: string): Promise;