fix(core): treat hubs without plugin command RPCs as having no commands (#15188)

* fix(core): own plugin commands in the runtime and hub

* fix(core): treat hubs without plugin command RPCs as having no commands

A running Hub that predates plugins.commands.* replies unsupported_command, and
replacement defers while it has active work. The client threw on that reply, so
every non-built-in slash command, skills included, failed in Desktop until the
old Hub exited. Return an empty catalog and no command result instead.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

* fix(core): catch the unsupported_command error the hub client throws

NodeHubClient.command() throws HubCommandError for a non-ok reply, so checking
the reply never saw an older Hub's unsupported_command. Catch the thrown error
instead, and make the regression test reject the way the real client does.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

---------

Co-authored-by: abeatrix <beatrix@cline.bot>
Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
This commit is contained in:
Saoud Rizwan
2026-10-19 17:14:36 -07:00
committed by GitHub
co-authored by Saoud Rizwan abeatrix
parent ec6a906101
commit ee1c2b7724
2 changed files with 52 additions and 3 deletions
@@ -1,9 +1,33 @@
import type { HubCommandEnvelope, HubReplyEnvelope } from "@cline/shared";
import type {
PluginCommandCatalog,
PluginCommandResult,
PluginCommandsApi,
} from "../../services/plugin-command-api";
import type { NodeHubClient } from "./index";
import { HubCommandError, type NodeHubClient } from "./index";
/**
* A Hub that predates plugin command RPCs, kept alive while it has active
* work, answers unsupported_command. Resolves undefined for that Hub.
*/
async function commandIfSupported(
client: Pick<NodeHubClient, "command">,
command: HubCommandEnvelope["command"],
payload: Record<string, unknown>,
sessionId: string | undefined,
options: Parameters<NodeHubClient["command"]>[3],
): Promise<HubReplyEnvelope | undefined> {
try {
return await client.command(command, payload, sessionId, options);
} catch (error) {
if (
error instanceof HubCommandError &&
error.code === "unsupported_command"
)
return undefined;
throw error;
}
}
/** Uses the caller's existing connection and subscription lifecycle. */
export function createHubPluginCommandsApi(
@@ -11,12 +35,14 @@ export function createHubPluginCommandsApi(
): PluginCommandsApi {
return {
async list(target) {
const reply = await client.command(
const reply = await commandIfSupported(
client,
"plugins.commands.list",
{ ...target },
target.sessionId,
{ timeoutMs: null },
);
if (!reply) return { ...target, status: "ready", commands: [] };
if (!reply.ok)
throw new Error(
reply.error?.message ?? "Plugin command discovery failed",
@@ -24,12 +50,14 @@ export function createHubPluginCommandsApi(
return reply.payload?.catalog as unknown as PluginCommandCatalog;
},
async run(input) {
const reply = await client.command(
const reply = await commandIfSupported(
client,
"plugins.commands.run",
{ ...input },
input.sessionId,
{ timeoutMs: null, retryAfterDispatch: false },
);
if (!reply) return undefined;
if (!reply.ok)
throw new Error(
reply.error?.message ?? "Plugin command execution failed",
@@ -7,6 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { LocalRuntimeHost } from "../../runtime/host/local-runtime-host";
import { splitCoreSessionConfig } from "../../runtime/host/runtime-host";
import { FileSessionService } from "../../session/services/file-session-service";
import { HubCommandError } from "../client";
import { createHubPluginCommandsApi } from "../client/plugin-commands";
import { createLocalHubScheduleRuntimeHandlers } from "../daemon/runtime-handlers";
import { HubServerTransport } from "./hub-server-transport";
@@ -265,6 +266,26 @@ api.registerCommand({name:'recovered',handler:()=> 'ready'});
});
stop();
});
it("treats a Hub without plugin command RPCs as having no plugin commands", async () => {
const api = createHubPluginCommandsApi({
command: async (name) => {
throw new HubCommandError(
name,
"unsupported_command",
`Unsupported hub schedule command: ${name}`,
);
},
subscribe: () => () => {},
});
expect(await api.list({ workspacePath: root })).toEqual({
workspacePath: root,
status: "ready",
commands: [],
});
expect(
await api.run({ workspacePath: root, prompt: "/counter" }),
).toBeUndefined();
});
it("shares a workspace sandbox across client requests and preserves arguments", async () => {
const first = await command("plugins.commands.list", {
workspacePath: root,