Files
IdeaSDK/docs/commands-and-feedback.md

8.7 KiB

Commands And Feedback

This document is the normative SDK contract for plugin commands, including commands that open a view or launch background work.

Contract

Every menu click or plugin slash command follows this sequence:

manifest contribution -> command id -> registered command handler -> optional task -> feedback surfaces
  • A manifest menu item declares a command id; it does not run tools directly.
  • A manifest slash command declares a slash name, autocomplete metadata and a command id; it does not run tools directly.
  • Menu items and slash commands may share the same command id, or point to different handlers. The plugin owns that choice.
  • The command handler is the only place that decides whether work should start.
  • The host slash-command registry only lists/filters metadata and returns a callback dispatch effect. The plugin handler decides what the command does.
  • 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 human menu-click UI presents minimal host-owned feedback for skipped and failed results. It intentionally does not expose the raw result payload or assume which tool (Git, ADB, Gradle, and so on) the plugin uses. For successful actions and richer status, open or focus a plugin layout/window from the handler with ctx.services.windows.open({ layoutType, state }).

Preconditions

Check preconditions before calling runCommand(). These are task-launch preconditions, not prerequisites for opening an explanatory view:

  • ctx.services exists. Plugins need the ui or 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 do not resolve package-relative plugin resources implicitly. Build an explicit absolute script path from ctx.pluginRoot when launching read-only files shipped in the installed package.
  • Do not use ctx.pluginRoot as cwd; cwd remains relative to the project root.
  • command and args are separate values. Do not shell-join user input.

A command whose purpose is to open or focus a diagnostic/configuration view should call services.windows.open(...) before probing optional executables. Missing Git, ADB, Gradle, or another tool must not block that view when the view can explain the degraded state and the next step. Probe tools from the view (or from its data loader) and render the degraded state there.

If any required precondition fails, return a skipped result:

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:

type CommandFeedback =
  | { status: "opened"; message?: string; alreadyOpen: boolean }
  | { status: "skipped"; reason: string; message: string }
  | { status: "failed"; reason?: string; message?: string }
  | { status: "launched"; taskId: string; state: string; message: string };

Use the same status vocabulary consistently:

  • opened: the requested view was opened or focused. alreadyOpen: true is a successful focus operation, not a failure or skip.
  • skipped: no background task was created.
  • launched: a task was created; inspect the task state for later progress.
  • failed: the handler caught a failure and returned structured failure feedback. An uncaught/rejected handler is also treated as a failure by the host.

Visible surfaces are intentionally distinct:

  • Command return value: immediate feedback for programmatic callers and agents. The human menu host maps skipped and failed results to concise feedback; plugin-specific fields and messages are not displayed verbatim. opened results, including alreadyOpen: true, succeed without an alert. The host logs structured diagnostics with pluginId, commandId, status, reason and message; raw objects, stack traces and internal ids are not user-facing.
  • Plugin layout/window: the normative visible feedback surface for menu actions that need to show status, results or next steps to a human. The layout must be declared in idea-plugin.json and registered during activation.
  • 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:

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

Visible menu feedback:

import type { IdeAPluginModule, PluginLayoutProps } from "@idea/plugin-sdk";

function HealthView(_props: PluginLayoutProps<{ source?: string }>) {
  return <section>Unity tools are ready.</section>;
}

const plugin: IdeAPluginModule = {
  activate(ctx) {
    ctx.layouts?.register({
      type: "unity-plugin.health",
      component: HealthView
    });

    ctx.commands?.registerCommand("unity-plugin.health", async () => {
      const win = await ctx.services?.windows.open({
        layoutType: "unity-plugin.health",
        state: { source: "menu" }
      });

      return win
        ? { status: "opened", message: "Opened Unity Health.", alreadyOpen: win.alreadyOpen }
        : { status: "skipped", reason: "ui-service-unavailable", message: "UI service unavailable." };
    });
  }
};

export default plugin;

The opened branch above must remain successful even when an optional tool diagnosed by HealthView is absent. If windows.open(...) itself rejects, log the technical cause and either let the promise reject or return failed; the host will show generic, plugin-neutral feedback rather than the raw message.

Background task feedback:

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:

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.

Do not try to reach packaged plugin scripts through workspace-relative paths:

await ctx.services?.tasks.runCommand({
  projectId: project.id,
  ownerAgentId,
  label: "Run packaged tests",
  command: "scripts/run-unity-tests.sh",
  cwd: "."
});

scripts/run-unity-tests.sh above is resolved like any other executable visible from the project task environment; it is not resolved relative to the plugin package. Use a project-owned script or an executable available on PATH until a dedicated packaged-resource API exists.