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

This commit is contained in:
abeatrix
2026-10-19 12:27:50 -07:00
parent 0caa1b10ab
commit ae5b541058
56 changed files with 2627 additions and 866 deletions
+2
View File
@@ -1,3 +1,4 @@
import { hasActiveHubSessions } from "@cline/core/hub";
import { type ChildProcess, spawn } from "node:child_process";
import { realpathSync } from "node:fs";
import {
@@ -381,6 +382,7 @@ async function otherCliClientsAttached(): Promise<boolean> {
undefined,
{ timeoutMs: CLIENT_COUNT_EXIT_TIMEOUT_MS },
);
if (hasActiveHubSessions(sessions.payload)) return true;
const sessionRecords =
(sessions.payload as { sessions?: Array<{ participants?: unknown }> })
.sessions ?? [];
+6 -4
View File
@@ -1071,10 +1071,7 @@ class DiscordConnector extends ConnectorBase<
});
await userInstructionService.start().catch(() => undefined);
const commandCwd = startRequest.cwd || process.cwd();
const { host: chatCommandHost } = await createWorkspaceChatCommandHost({
cwd: commandCwd,
workspaceRoot: startRequest.workspaceRoot || commandCwd,
});
const { url: rpcAddress, authToken: rpcAuthToken } =
await ensureCliHubServer(
startRequest.workspaceRoot || startRequest.cwd || process.cwd(),
@@ -1097,6 +1094,11 @@ class DiscordConnector extends ConnectorBase<
},
});
await client.connect();
const { host: chatCommandHost } = await createWorkspaceChatCommandHost({
commands: client.pluginCommands,
cwd: commandCwd,
workspaceRoot: startRequest.workspaceRoot || commandCwd,
});
this.writeConnectorState(statePath, {
userName: options.userName,
applicationId: options.applicationId,
+6 -4
View File
@@ -577,10 +577,7 @@ class GoogleChatConnector extends ConnectorBase<
});
await userInstructionService.start().catch(() => undefined);
const commandCwd = startRequest.cwd || process.cwd();
const { host: chatCommandHost } = await createWorkspaceChatCommandHost({
cwd: commandCwd,
workspaceRoot: startRequest.workspaceRoot || commandCwd,
});
const { url: rpcAddress, authToken: rpcAuthToken } =
await ensureCliHubServer(
startRequest.workspaceRoot || startRequest.cwd || process.cwd(),
@@ -602,6 +599,11 @@ class GoogleChatConnector extends ConnectorBase<
},
});
await client.connect();
const { host: chatCommandHost } = await createWorkspaceChatCommandHost({
commands: client.pluginCommands,
cwd: commandCwd,
workspaceRoot: startRequest.workspaceRoot || commandCwd,
});
this.writeConnectorState(statePath, {
userName: options.userName,
pid: process.pid,
+6 -4
View File
@@ -600,10 +600,7 @@ class LinearConnector extends ConnectorBase<
});
await userInstructionService.start().catch(() => undefined);
const commandCwd = startRequest.cwd || process.cwd();
const { host: chatCommandHost } = await createWorkspaceChatCommandHost({
cwd: commandCwd,
workspaceRoot: startRequest.workspaceRoot || commandCwd,
});
const { url: rpcAddress, authToken: rpcAuthToken } =
await ensureCliHubServer(
startRequest.workspaceRoot || startRequest.cwd || process.cwd(),
@@ -625,6 +622,11 @@ class LinearConnector extends ConnectorBase<
},
});
await client.connect();
const { host: chatCommandHost } = await createWorkspaceChatCommandHost({
commands: client.pluginCommands,
cwd: commandCwd,
workspaceRoot: startRequest.workspaceRoot || commandCwd,
});
this.writeConnectorState(statePath, {
userName: options.userName,
pid: process.pid,
+6 -4
View File
@@ -874,10 +874,7 @@ class SlackConnector extends ConnectorBase<
});
await userInstructionService.start().catch(() => undefined);
const commandCwd = startRequest.cwd || process.cwd();
const { host: chatCommandHost } = await createWorkspaceChatCommandHost({
cwd: commandCwd,
workspaceRoot: startRequest.workspaceRoot || commandCwd,
});
const { url: rpcAddress, authToken: rpcAuthToken } =
await ensureCliHubServer(
startRequest.workspaceRoot || startRequest.cwd || process.cwd(),
@@ -899,6 +896,11 @@ class SlackConnector extends ConnectorBase<
},
});
await client.connect();
const { host: chatCommandHost } = await createWorkspaceChatCommandHost({
commands: client.pluginCommands,
cwd: commandCwd,
workspaceRoot: startRequest.workspaceRoot || commandCwd,
});
this.writeConnectorState(statePath, {
claimId: claim.claimId,
userName: options.userName,
+6 -4
View File
@@ -804,10 +804,7 @@ class TelegramConnector extends ConnectorBase<
});
await userInstructionService.start().catch(() => undefined);
const commandCwd = startRequest.cwd || process.cwd();
const { host: chatCommandHost } = await createWorkspaceChatCommandHost({
cwd: commandCwd,
workspaceRoot: startRequest.workspaceRoot || commandCwd,
});
const { url: rpcAddress, authToken: rpcAuthToken } =
await ensureCliHubServer(
startRequest.workspaceRoot || startRequest.cwd || process.cwd(),
@@ -829,6 +826,11 @@ class TelegramConnector extends ConnectorBase<
},
});
await client.connect();
const { host: chatCommandHost } = await createWorkspaceChatCommandHost({
commands: client.pluginCommands,
cwd: commandCwd,
workspaceRoot: startRequest.workspaceRoot || commandCwd,
});
this.writeConnectorState(statePath, {
botUsername: options.botUsername,
botId: readTelegramBotId(options.botToken),
+6 -4
View File
@@ -569,10 +569,7 @@ class WhatsAppConnector extends ConnectorBase<
});
await userInstructionService.start().catch(() => undefined);
const commandCwd = startRequest.cwd || process.cwd();
const { host: chatCommandHost } = await createWorkspaceChatCommandHost({
cwd: commandCwd,
workspaceRoot: startRequest.workspaceRoot || commandCwd,
});
const { url: rpcAddress, authToken: rpcAuthToken } =
await ensureCliHubServer(
startRequest.workspaceRoot || startRequest.cwd || process.cwd(),
@@ -597,6 +594,11 @@ class WhatsAppConnector extends ConnectorBase<
},
});
await client.connect();
const { host: chatCommandHost } = await createWorkspaceChatCommandHost({
commands: client.pluginCommands,
cwd: commandCwd,
workspaceRoot: startRequest.workspaceRoot || commandCwd,
});
this.writeConnectorState(statePath, {
instanceKey,
userName: options.userName,
@@ -3,6 +3,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import type { SentMessage } from "chat";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createWorkspaceChatCommandHost } from "../utils/plugin-chat-commands";
import { enqueueThreadTurn } from "./chat-runtime";
import { handleConnectorUserTurn } from "./connector-host";
@@ -178,6 +179,65 @@ describe("handleConnectorUserTurn", () => {
}
});
it.each([
"discord",
"gchat",
"linear",
"slack",
"telegram",
"whatsapp",
])("routes %s plugin commands with each thread's current session and workspace", async (transport) => {
const dir = mkdtempSync(join(tmpdir(), "connector-command-context-"));
tempDirs.push(dir);
const run = vi.fn(async () => ({ reply: "handled" }));
const { host } = await createWorkspaceChatCommandHost({
cwd: dir,
commands: {
run,
list: vi
.fn()
.mockResolvedValue({ status: "ready", commands: [{ name: "echo" }] }),
subscribe: vi.fn(),
},
});
await Promise.all(
["one", "two"].map(async (id) => {
const workspace = join(dir, id);
const { thread, posts } = createThread({
sessionId: id,
cwd: workspace,
workspaceRoot: workspace,
});
await handleConnectorUserTurn({
thread: thread as never,
client: {} as never,
pendingApprovals: new Map(),
baseStartRequest: baseStartRequest() as never,
clientId: "client",
explicitSystemPrompt: undefined,
logger: {
core: { debug: vi.fn(), log: vi.fn(), error: vi.fn() },
} as never,
transport,
botUserName: "cline",
requestStop: vi.fn(),
bindingsPath: join(dir, `${id}.json`),
systemRules: "rules",
errorLabel: transport,
getSessionMetadata: () => ({}),
reusedLogMessage: "reused",
text: "/echo hello",
chatCommandHost: host,
});
expect(run).toHaveBeenCalledWith({
sessionId: id,
workspacePath: workspace,
prompt: "/echo hello",
});
expect(messageText(posts.at(-1))).toContain("handled");
}),
);
});
it("posts no greeting when the adapter configures none", async () => {
// Slack deliberately configures no first-contact message: the greeting is
// gated on per-thread state, so a restart or a cleared history replayed it
@@ -628,6 +628,7 @@ export async function handleConnectorUserTurn<
input.forceDisableTools,
);
return {
sessionId: effectiveCurrent.sessionId,
enableTools:
effectiveCurrent.enableTools ?? input.baseStartRequest.enableTools,
autoApproveTools:
@@ -144,6 +144,7 @@ function makeManager() {
});
return {
start,
pluginCommands: { list: vi.fn(), run: vi.fn(), subscribe: vi.fn() },
stop: vi.fn(async () => {}),
send: vi.fn(),
getAccumulatedUsage: vi.fn(),
@@ -242,6 +243,65 @@ describe("createInteractiveSessionRuntime", () => {
subscribeToPendingPromptEventsMock.mockReturnValue(() => {});
});
it.each([
undefined,
"resumed-session",
])("shares initialization between plugin catalog discovery and session startup (%s)", async (resumeSessionId) => {
const manager = makeManager();
manager.get.mockResolvedValue(undefined);
manager.getAccumulatedUsage.mockResolvedValue(undefined);
const runtime = await makeRuntime(manager, { resumeSessionId });
if (resumeSessionId)
loadInteractiveResumeMessagesMock.mockResolvedValue([
{ role: "user", content: "resume" },
]);
const gate = deferred<typeof manager>();
createCliCoreMock.mockReturnValue(gate.promise);
const commands = runtime.getPluginCommandsApi();
const ready = runtime.ensureReady();
expect(createCliCoreMock).toHaveBeenCalledTimes(1);
gate.resolve(manager);
expect(await commands).toBe(manager.pluginCommands);
await ready;
expect(manager.start).toHaveBeenCalledTimes(1);
expect(subscribeToAgentEventsMock).toHaveBeenCalledTimes(1);
await runtime.cleanup();
expect(manager.dispose).toHaveBeenCalledTimes(1);
});
it("retries failed shared initialization", async () => {
const manager = makeManager();
const runtime = await makeRuntime(manager);
createCliCoreMock.mockRejectedValueOnce(new Error("startup failed"));
await expect(runtime.getPluginCommandsApi()).rejects.toThrow(
"startup failed",
);
expect(await runtime.getPluginCommandsApi()).toBe(manager.pluginCommands);
expect(createCliCoreMock).toHaveBeenCalledTimes(2);
await runtime.cleanup();
});
it("awaits and disposes plugin-only initialization during cleanup", async () => {
const manager = makeManager();
const runtime = await makeRuntime(manager);
const gate = deferred<typeof manager>();
createCliCoreMock.mockReturnValue(gate.promise);
const commands = expect(runtime.getPluginCommandsApi()).rejects.toThrow(
"shutdown requested",
);
const cleanup = runtime.cleanup();
gate.resolve(manager);
await commands;
await cleanup;
expect(manager.dispose).toHaveBeenCalledExactlyOnceWith(
"cli_interactive_startup_cancelled",
);
await expect(runtime.getPluginCommandsApi()).rejects.toThrow(
"shutdown requested",
);
expect(createCliCoreMock).toHaveBeenCalledTimes(1);
});
it("manual compact updates the active session sidecar without restarting", async () => {
const sessionId = "sess-active";
const messages = [
@@ -113,6 +113,7 @@ export function createInteractiveSessionRuntime(input: {
onPendingPromptSubmitted: (event: PendingPromptSubmittedEvent) => void;
}) {
let sessionManager: CliCore | undefined;
let sessionManagerPromise: Promise<CliCore> | undefined;
let runtimeHooks: RuntimeHooks | undefined;
let unsubscribeAgent = () => {};
let unsubscribePendingPrompts = () => {};
@@ -151,10 +152,7 @@ export function createInteractiveSessionRuntime(input: {
}
};
const ensureSessionManager = async (): Promise<CliCore> => {
if (sessionManager) {
return sessionManager;
}
const createSessionManager = async (): Promise<CliCore> => {
const manager = await createCliCore({
// Interactive startup must never wait for a detached hub daemon to boot.
// `auto` uses an already-compatible hub when one is immediately available,
@@ -205,6 +203,20 @@ export function createInteractiveSessionRuntime(input: {
return manager;
};
const ensureSessionManager = (): Promise<CliCore> => {
if (sessionManager) return Promise.resolve(sessionManager);
if (shutdownRequested)
return Promise.reject(
new Error("interactive runtime shutdown requested"),
);
if (!sessionManagerPromise) {
sessionManagerPromise = createSessionManager().finally(() => {
sessionManagerPromise = undefined;
});
}
return sessionManagerPromise;
};
const buildSessionConfig = (): Config => {
if (!runtimeHooks) {
throw new Error("interactive runtime hooks are unavailable");
@@ -885,6 +897,7 @@ export function createInteractiveSessionRuntime(input: {
shutdownRequested = true;
let exitSummary: InteractiveExitSummary | undefined;
try {
await sessionManagerPromise?.catch(() => {});
await startupPromise?.catch(() => {});
await missingSessionRecoveryPromise?.catch(() => {});
} finally {
@@ -899,7 +912,11 @@ export function createInteractiveSessionRuntime(input: {
await stopCurrentSession();
} finally {
if (sessionManager) {
await sessionManager.dispose("cli_interactive_shutdown");
try {
await sessionManager.dispose("cli_interactive_shutdown");
} finally {
sessionManager = undefined;
}
}
}
return exitSummary;
@@ -908,6 +925,8 @@ export function createInteractiveSessionRuntime(input: {
};
return {
getPluginCommandsApi: async () =>
(await ensureSessionManager()).pluginCommands,
ensureReady,
listCoreSettings,
toggleCoreSettings,
+34 -59
View File
@@ -194,45 +194,43 @@ export async function runInteractive(
userInstructionService,
);
let interactiveChatCommandHost = chatCommandHost;
let pluginChatCommandHostLoaded = false;
let pluginChatSlashCommands: InteractiveSlashCommand[] = [];
let pluginChatCommandHostShutdown: (() => Promise<void>) | undefined;
let pluginChatCommandHostPromise:
| Promise<InteractiveSlashCommand[]>
let pluginHostPromise:
| ReturnType<typeof createWorkspaceChatCommandHost>
| undefined;
const ensurePluginChatCommandHost = async (): Promise<
InteractiveSlashCommand[]
> => {
if (pluginChatCommandHostLoaded) {
return pluginChatSlashCommands;
}
pluginChatCommandHostPromise ??= createWorkspaceChatCommandHost({
cwd: config.cwd,
workspaceRoot: config.workspaceRoot,
logger: config.logger,
})
.then(({ host, pluginSlashCommands, shutdown }) => {
interactiveChatCommandHost = host;
pluginChatCommandHostShutdown = shutdown;
pluginChatSlashCommands = pluginSlashCommands.map((cmd) => ({
name: cmd.name,
instructions: "",
description: cmd.description ?? "Plugin command",
}));
return pluginChatSlashCommands;
const ensurePluginHost = () => {
pluginHostPromise ??= sessionRuntime
.getPluginCommandsApi()
.then((commands) =>
createWorkspaceChatCommandHost({
cwd: config.cwd,
workspaceRoot: config.workspaceRoot,
logger: config.logger,
commands,
getSessionId: () => sessionRuntime.getActiveSessionId(),
}),
)
.then((host) => {
interactiveChatCommandHost = host.host;
return host;
})
.finally(() => {
pluginChatCommandHostLoaded = true;
pluginChatCommandHostPromise = undefined;
.catch((error) => {
pluginHostPromise = undefined;
throw error;
});
return await pluginChatCommandHostPromise;
return pluginHostPromise;
};
const loadAdditionalSlashCommands = async (): Promise<
InteractiveSlashCommand[]
> => await ensurePluginChatCommandHost();
const shouldTryPluginChatCommands = (prompt: string): boolean => {
return prompt.trimStart().startsWith("/");
> => {
const host = await ensurePluginHost();
return (await host.listCommands()).map((command) => ({
...command,
description: command.description ?? "Plugin command",
instructions: "",
}));
};
const subscribeAdditionalSlashCommands = async (listener: () => void) =>
(await ensurePluginHost()).subscribe(listener);
const enableChatCommands = true;
const {
@@ -385,11 +383,6 @@ export async function runInteractive(
try {
exitSummary = await sessionRuntime.cleanup();
} finally {
await pluginChatCommandHostPromise?.catch(() => []);
await pluginChatCommandHostShutdown?.().catch(() => {
// Best effort cleanup for plugin command discovery sandbox.
});
pluginChatCommandHostShutdown = undefined;
setActiveRuntimeAbort(undefined);
setActiveRuntimeCleanup(undefined);
}
@@ -525,6 +518,7 @@ export async function runInteractive(
initialRepoStatus,
workflowSlashCommands,
loadAdditionalSlashCommands,
subscribeAdditionalSlashCommands,
loadWelcomeLine: async () =>
await resolveClineWelcomeLine({
config,
@@ -579,7 +573,8 @@ export async function runInteractive(
isRunning = true;
}
let chatCommandResult = await runInteractiveChatCommand({
if (input.trimStart().startsWith("/")) await ensurePluginHost();
const chatCommandResult = await runInteractiveChatCommand({
prompt: input,
enabled: enableChatCommands,
config,
@@ -594,27 +589,7 @@ export async function runInteractive(
if (chatCommandResult.handled) {
return chatCommandResult.turnResult;
}
if (
shouldTryPluginChatCommands(input) &&
!pluginChatCommandHostLoaded
) {
await ensurePluginChatCommandHost();
chatCommandResult = await runInteractiveChatCommand({
prompt: input,
enabled: enableChatCommands,
config,
host: interactiveChatCommandHost,
chatCommandState,
autoApproveAllRef,
setInteractiveAutoApprove,
sessionRuntime,
stop: () => tuiApp?.destroy(),
onCommandOutput,
});
if (chatCommandResult.handled) {
return chatCommandResult.turnResult;
}
}
input = chatCommandResult.input;
commandOutput = chatCommandResult.commandOutput;
zeroTurnCost = await shouldZeroClineFreeModelCost(config);
@@ -206,6 +206,13 @@ export function buildSlashCommandRegistry(input: {
});
}
for (const command of input.additionalSlashCommands ?? []) {
const entry = entryFromRuntimeCommand(command, "plugin");
if (entry) {
addEntry(byName, entry);
}
}
for (const command of input.workflowSlashCommands ?? []) {
const source =
command.kind === "skill" || command.kind === "workflow"
@@ -217,13 +224,6 @@ export function buildSlashCommandRegistry(input: {
}
}
for (const command of input.additionalSlashCommands ?? []) {
const entry = entryFromRuntimeCommand(command, "plugin");
if (entry) {
addEntry(byName, entry);
}
}
return {
entries: [...byName.values()],
byName,
@@ -0,0 +1,175 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useSlashCommands } from "./use-slash-commands";
const reactMock = vi.hoisted(() => ({
cleanups: [] as Array<() => void>,
setCommands: vi.fn(),
}));
vi.mock("react", () => ({
useState: (initial: unknown) => [initial, reactMock.setCommands],
useMemo: (compute: () => unknown) => compute(),
useEffect: (effect: () => (() => void) | undefined) => {
const cleanup = effect();
if (cleanup) reactMock.cleanups.push(cleanup);
},
}));
const first = [{ name: "first", description: "First", instructions: "" }];
const next = [{ name: "next", description: "Next", instructions: "" }];
function useTestSlashCommands(
load: NonNullable<
Parameters<typeof useSlashCommands>[0]["loadAdditionalSlashCommands"]
>,
subscribe?: Parameters<
typeof useSlashCommands
>[0]["subscribeAdditionalSlashCommands"],
) {
useSlashCommands({
workflowSlashCommands: [],
loadAdditionalSlashCommands: load,
subscribeAdditionalSlashCommands: subscribe,
canFork: false,
});
}
function unmount() {
for (const cleanup of reactMock.cleanups.splice(0)) cleanup();
}
beforeEach(() => {
vi.useFakeTimers();
reactMock.setCommands.mockClear();
});
afterEach(() => {
unmount();
vi.useRealTimers();
});
describe("plugin slash command catalog lifecycle", () => {
it("retries a failed subscription and receives updates after recovery", async () => {
let listener = () => {};
const stop = vi.fn();
const load = vi.fn().mockResolvedValue(first);
const subscribe = vi
.fn()
.mockRejectedValueOnce(new Error("startup failed"))
.mockImplementation(async (refresh) => {
listener = refresh;
return stop;
});
useTestSlashCommands(load, subscribe);
await vi.advanceTimersByTimeAsync(0);
expect(subscribe).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1000);
expect(subscribe).toHaveBeenCalledTimes(2);
load.mockResolvedValue(next);
listener();
await vi.advanceTimersByTimeAsync(0);
expect(reactMock.setCommands).toHaveBeenLastCalledWith(next);
unmount();
expect(stop).toHaveBeenCalledTimes(1);
});
it("retains the loaded catalog during a failed refresh and retries without an event", async () => {
let listener = () => {};
const load = vi.fn().mockResolvedValue(first);
useTestSlashCommands(load, async (refresh) => {
listener = refresh;
return () => {};
});
await vi.advanceTimersByTimeAsync(0);
expect(reactMock.setCommands).toHaveBeenLastCalledWith(first);
const successfulUpdates = reactMock.setCommands.mock.calls.length;
load
.mockRejectedValueOnce(new Error("connection lost"))
.mockResolvedValue(next);
listener();
await vi.advanceTimersByTimeAsync(0);
expect(reactMock.setCommands).toHaveBeenCalledTimes(successfulUpdates);
await vi.advanceTimersByTimeAsync(1000);
expect(reactMock.setCommands).toHaveBeenLastCalledWith(next);
});
it("backs off refresh retries and cancels retries on unmount", async () => {
const load = vi.fn().mockRejectedValue(new Error("offline"));
useTestSlashCommands(load);
await vi.advanceTimersByTimeAsync(0);
await vi.advanceTimersByTimeAsync(1000);
expect(load).toHaveBeenCalledTimes(2);
await vi.advanceTimersByTimeAsync(1999);
expect(load).toHaveBeenCalledTimes(2);
await vi.advanceTimersByTimeAsync(1);
expect(load).toHaveBeenCalledTimes(3);
unmount();
expect(vi.getTimerCount()).toBe(0);
await vi.advanceTimersByTimeAsync(60_000);
expect(load).toHaveBeenCalledTimes(3);
});
it("cancels failed subscription retries on unmount", async () => {
const load = vi.fn().mockResolvedValue(first);
const subscribe = vi.fn().mockRejectedValue(new Error("offline"));
useTestSlashCommands(load, subscribe);
await vi.advanceTimersByTimeAsync(0);
unmount();
expect(vi.getTimerCount()).toBe(0);
await vi.advanceTimersByTimeAsync(60_000);
expect(subscribe).toHaveBeenCalledTimes(1);
});
it("stops a subscription that resolves after unmount", async () => {
let resolve!: (stop: () => void) => void;
const pending = new Promise<() => void>((done) => {
resolve = done;
});
const stop = vi.fn();
useTestSlashCommands(vi.fn().mockResolvedValue(first), () => pending);
unmount();
resolve(stop);
await vi.advanceTimersByTimeAsync(0);
expect(stop).toHaveBeenCalledTimes(1);
expect(reactMock.setCommands).not.toHaveBeenCalled();
});
it("ignores an older refresh that resolves after the subscribed catalog", async () => {
let resolve!: (commands: typeof first) => void;
const pending = new Promise<typeof first>((done) => {
resolve = done;
});
const load = vi.fn().mockReturnValueOnce(pending).mockResolvedValue(next);
useTestSlashCommands(load, async () => () => {});
await vi.advanceTimersByTimeAsync(0);
expect(reactMock.setCommands).toHaveBeenLastCalledWith(next);
resolve(first);
await vi.advanceTimersByTimeAsync(0);
expect(reactMock.setCommands).toHaveBeenCalledTimes(1);
});
it("publishes the initial catalog while the subscription refresh stalls", async () => {
let resolve!: (commands: typeof first) => void;
const initial = new Promise<typeof first>((done) => {
resolve = done;
});
const stalled = new Promise<typeof first>(() => {});
const load = vi
.fn()
.mockReturnValueOnce(initial)
.mockReturnValueOnce(stalled);
useTestSlashCommands(load, async () => () => {});
await vi.advanceTimersByTimeAsync(0);
expect(load).toHaveBeenCalledTimes(2);
resolve(first);
await vi.advanceTimersByTimeAsync(0);
expect(reactMock.setCommands).toHaveBeenLastCalledWith(first);
});
it("keeps an initial success when the subscription refresh fails and retries", async () => {
let resolve!: (commands: typeof first) => void;
const initial = new Promise<typeof first>((done) => {
resolve = done;
});
const load = vi
.fn()
.mockReturnValueOnce(initial)
.mockRejectedValueOnce(new Error("refresh failed"))
.mockResolvedValue(next);
useTestSlashCommands(load, async () => () => {});
await vi.advanceTimersByTimeAsync(0);
resolve(first);
await vi.advanceTimersByTimeAsync(0);
expect(reactMock.setCommands).toHaveBeenLastCalledWith(first);
await vi.advanceTimersByTimeAsync(1000);
expect(reactMock.setCommands).toHaveBeenLastCalledWith(next);
});
});
+60 -10
View File
@@ -10,9 +10,15 @@ import type { TuiProps } from "../types";
export function useSlashCommands(input: {
workflowSlashCommands: TuiProps["workflowSlashCommands"];
loadAdditionalSlashCommands: TuiProps["loadAdditionalSlashCommands"];
subscribeAdditionalSlashCommands: TuiProps["subscribeAdditionalSlashCommands"];
canFork: boolean;
}) {
const { workflowSlashCommands, loadAdditionalSlashCommands, canFork } = input;
const {
workflowSlashCommands,
loadAdditionalSlashCommands,
subscribeAdditionalSlashCommands,
canFork,
} = input;
const [additionalSlashCommands, setAdditionalSlashCommands] = useState<
TuiProps["workflowSlashCommands"] | undefined
>(loadAdditionalSlashCommands ? [] : undefined);
@@ -23,21 +29,65 @@ export function useSlashCommands(input: {
return;
}
let cancelled = false;
void loadAdditionalSlashCommands()
.then((commands) => {
if (!cancelled) {
let requestId = 0;
let publishedRequestId = 0;
let refreshRetry: ReturnType<typeof setTimeout> | undefined;
let subscribeRetry: ReturnType<typeof setTimeout> | undefined;
let refreshDelay = 1000;
let subscribeDelay = 1000;
const refresh = async () => {
if (cancelled) return;
clearTimeout(refreshRetry);
const id = ++requestId;
try {
const commands = await loadAdditionalSlashCommands();
if (!cancelled && id > publishedRequestId) {
// A newer pending/failed request must not discard a usable catalog.
publishedRequestId = id;
if (id === requestId) refreshDelay = 1000;
setAdditionalSlashCommands(commands);
}
})
.catch(() => {
if (!cancelled) {
setAdditionalSlashCommands([]);
} catch {
if (!cancelled && id === requestId) {
// Keep the last catalog visible while the runtime reconnects.
refreshRetry = setTimeout(() => {
void refresh();
}, refreshDelay);
refreshDelay = Math.min(refreshDelay * 2, 30_000);
}
});
}
};
void refresh();
let unsubscribe: (() => void) | undefined;
const subscribe = async () => {
if (cancelled || !subscribeAdditionalSlashCommands) return;
try {
const stop = await subscribeAdditionalSlashCommands(() => {
void refresh();
});
if (cancelled) stop();
else {
unsubscribe = stop;
// Catch any catalog change missed before subscription was established.
void refresh();
}
} catch {
if (!cancelled) {
subscribeRetry = setTimeout(() => {
void subscribe();
}, subscribeDelay);
subscribeDelay = Math.min(subscribeDelay * 2, 30_000);
}
}
};
void subscribe();
return () => {
cancelled = true;
clearTimeout(refreshRetry);
clearTimeout(subscribeRetry);
unsubscribe?.();
};
}, [loadAdditionalSlashCommands]);
}, [loadAdditionalSlashCommands, subscribeAdditionalSlashCommands]);
const registry = useMemo(() => {
return buildSlashCommandRegistry({
+1
View File
@@ -146,6 +146,7 @@ function App(props: TuiProps) {
} = useSlashCommands({
workflowSlashCommands,
loadAdditionalSlashCommands: props.loadAdditionalSlashCommands,
subscribeAdditionalSlashCommands: props.subscribeAdditionalSlashCommands,
canFork: canForkSession,
});
+3
View File
@@ -157,6 +157,9 @@ export interface TuiProps {
initialRepoStatus?: RepoStatus;
workflowSlashCommands?: InteractiveSlashCommand[];
loadAdditionalSlashCommands?: () => Promise<InteractiveSlashCommand[]>;
subscribeAdditionalSlashCommands?: (
listener: () => void,
) => Promise<() => void>;
loadWelcomeLine?: () => Promise<string | undefined>;
loadClineAccount: () => Promise<ClineAccountSnapshot>;
loadIndividualSubscriptionPlans?: () => Promise<ClineSubscriptionPlan[]>;
+20 -2
View File
@@ -3,6 +3,7 @@ import { resolve } from "node:path";
import { resolveWorkspaceRoot } from "./helpers";
export type ChatCommandState = {
sessionId?: string;
enableTools: boolean;
autoApproveTools: boolean;
cwd: string;
@@ -69,6 +70,7 @@ type ParsedChatCommand = {
trimmed: string;
command: string;
args: string[];
argumentsText: string;
state: ChatCommandState;
};
@@ -83,6 +85,19 @@ export type ChatCommandDefinition = {
export class ChatCommandHost {
private readonly definitions: ChatCommandDefinition[];
private fallback?: (
parsed: ParsedChatCommand,
context: ChatCommandContext,
) => Promise<boolean>;
setFallback(
handler: (
parsed: ParsedChatCommand,
context: ChatCommandContext,
) => Promise<boolean>,
): this {
this.fallback = handler;
return this;
}
constructor(definitions: ChatCommandDefinition[] = []) {
this.definitions = [...definitions];
@@ -101,7 +116,9 @@ export class ChatCommandHost {
}
clone(): ChatCommandHost {
return new ChatCommandHost(this.definitions);
const host = new ChatCommandHost(this.definitions);
host.fallback = this.fallback;
return host;
}
async handle(input: string, context: ChatCommandContext): Promise<boolean> {
@@ -130,13 +147,14 @@ export class ChatCommandHost {
trimmed,
command,
args,
argumentsText: trimmed.slice(commandRaw.length),
state: await context.getState(),
};
const matched = this.definitions.find((definition) =>
definition.names.includes(parsed.command),
);
if (!matched) {
return false;
return (await this.fallback?.(parsed, context)) ?? false;
}
if (matched.isAvailable && !matched.isAvailable(context)) {
return false;
+108 -11
View File
@@ -1,13 +1,16 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { ClineCore, type PluginCommandsApi } from "@cline/core";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createWorkspaceChatCommandHost } from "./plugin-chat-commands";
describe("plugin chat commands", () => {
const tempRoots: string[] = [];
const cores: ClineCore[] = [];
afterEach(async () => {
await Promise.all(cores.splice(0).map((core) => core.dispose()));
await Promise.all(
tempRoots.map((dir) => rm(dir, { recursive: true, force: true })),
);
@@ -36,20 +39,22 @@ describe("plugin chat commands", () => {
].join("\n"),
);
const { host, pluginSlashCommands, shutdown } =
await createWorkspaceChatCommandHost({
cwd: tempRoot,
workspaceRoot: tempRoot,
});
const core = await ClineCore.create({ backendMode: "local" });
cores.push(core);
const { host, listCommands } = await createWorkspaceChatCommandHost({
commands: core.pluginCommands,
cwd: tempRoot,
workspaceRoot: tempRoot,
});
const reply = vi.fn(async () => undefined);
// Filter to only our test plugin to ignore any discovered system plugins
const testCommands = pluginSlashCommands.filter(
const testCommands = (await listCommands()).filter(
(cmd) => cmd.name === "echo",
);
expect(testCommands).toEqual([{ name: "echo", description: "Echo input" }]);
const handled = await host.handle("/echo hello plugin", {
const handled = await host.handle("/echo hello plugin\nnext line", {
enabled: true,
getState: async () => ({
enableTools: false,
@@ -62,8 +67,7 @@ describe("plugin chat commands", () => {
});
expect(handled).toBe(true);
expect(reply).toHaveBeenCalledWith("echo:hello plugin");
await shutdown?.();
expect(reply).toHaveBeenCalledWith("echo:hello plugin\nnext line");
});
it("bridges plugin command submit prompts onto the chat command context", async () => {
@@ -91,7 +95,10 @@ describe("plugin chat commands", () => {
].join("\n"),
);
const { host, shutdown } = await createWorkspaceChatCommandHost({
const core = await ClineCore.create({ backendMode: "local" });
cores.push(core);
const { host } = await createWorkspaceChatCommandHost({
commands: core.pluginCommands,
cwd: tempRoot,
workspaceRoot: tempRoot,
});
@@ -114,6 +121,96 @@ describe("plugin chat commands", () => {
expect(handled).toBe(true);
expect(reply).toHaveBeenCalledWith("goal:fix tests");
expect(submitPrompt).toHaveBeenCalledWith("fix tests");
await shutdown?.();
});
it.each([
"/skill explain",
"/workflow run",
])("lets %s reach instruction expansion when plugin discovery fails", async (prompt) => {
const commands: PluginCommandsApi = {
list: vi.fn().mockRejectedValue(new Error("hub unavailable")),
run: vi.fn().mockRejectedValue(new Error("must not execute")),
subscribe: vi.fn(),
};
const logger = { log: vi.fn(), debug: vi.fn() };
const { host } = await createWorkspaceChatCommandHost({
commands,
cwd: "/tmp/project",
logger,
});
const handled = await host.handle(prompt, {
enabled: true,
getState: () => ({
cwd: "/tmp/project",
workspaceRoot: "/tmp/project",
enableTools: false,
autoApproveTools: false,
}),
setState: vi.fn(),
reply: vi.fn(),
});
expect(handled).toBe(false);
expect(commands.run).not.toHaveBeenCalled();
expect(logger.log).toHaveBeenCalled();
});
it("does not execute commands absent from a partial catalog", async () => {
const commands: PluginCommandsApi = {
list: vi.fn().mockResolvedValue({
status: "error",
commands: [{ name: "echo" }],
error: "sibling failed",
}),
run: vi.fn(),
subscribe: vi.fn(),
};
const { host } = await createWorkspaceChatCommandHost({
commands,
cwd: "/tmp/project",
});
expect(
await host.handle("/workflow run", {
enabled: true,
getState: () => ({
cwd: "/tmp/project",
workspaceRoot: "/tmp/project",
enableTools: false,
autoApproveTools: false,
}),
setState: vi.fn(),
reply: vi.fn(),
}),
).toBe(false);
expect(commands.run).not.toHaveBeenCalled();
});
it("preserves execution errors for a command owned by a healthy plugin", async () => {
const commands: PluginCommandsApi = {
list: vi.fn().mockResolvedValue({
status: "error",
commands: [{ name: "echo" }],
error: "sibling failed",
}),
run: vi.fn().mockRejectedValue(new Error("handler failed")),
subscribe: vi.fn(),
};
const { host } = await createWorkspaceChatCommandHost({
commands,
cwd: "/tmp/project",
});
await expect(
host.handle("/echo input", {
enabled: true,
getState: () => ({
cwd: "/tmp/project",
workspaceRoot: "/tmp/project",
enableTools: false,
autoApproveTools: false,
}),
setState: vi.fn(),
reply: vi.fn(),
}),
).rejects.toThrow("handler failed");
expect(commands.run).toHaveBeenCalledTimes(1);
});
});
+54 -40
View File
@@ -1,50 +1,64 @@
import {
type BasicLogger,
createPluginCommandService,
type PluginSlashCommand,
import type {
BasicLogger,
PluginCommandCatalog,
PluginCommandsApi,
} from "@cline/core";
import { type ChatCommandHost, chatCommandHost } from "./chat-commands";
export type { PluginSlashCommand };
export interface WorkspaceChatCommandHostResult {
host: ChatCommandHost;
// Plugin-registered commands surfaced as slash commands for TUI autocomplete.
pluginSlashCommands: PluginSlashCommand[];
shutdown?: () => Promise<void>;
}
import { chatCommandHost } from "./chat-commands";
export async function createWorkspaceChatCommandHost(input: {
cwd: string;
workspaceRoot?: string;
logger?: BasicLogger;
}): Promise<WorkspaceChatCommandHostResult> {
const service = createPluginCommandService({
cwd: input.cwd,
commands: PluginCommandsApi;
getSessionId?: () => string | undefined;
}) {
const commands = input.commands;
const target = () => ({
workspacePath: input.workspaceRoot?.trim() || input.cwd,
logger: input.logger,
sessionId: input.getSessionId?.() || undefined,
});
// Load failures are logged by the service and yield no commands.
const pluginSlashCommands = await service.listCommands();
if (pluginSlashCommands.length === 0) {
await service.shutdown();
return { host: chatCommandHost, pluginSlashCommands: [] };
}
const host = chatCommandHost.clone();
for (const command of pluginSlashCommands) {
host.register("command", {
names: [`/${command.name}`],
run: async ({ args }, context) => {
const result = await service.run(command.name, args.join(" "));
if (result?.reply) {
await context.reply(result.reply);
}
if (result?.submitPrompt) {
await context.submitPrompt?.(result.submitPrompt);
}
},
const host = chatCommandHost.clone().setFallback(async (parsed, context) => {
const commandTarget = {
workspacePath: parsed.state.workspaceRoot || parsed.state.cwd,
sessionId: parsed.state.sessionId ?? input.getSessionId?.(),
};
let catalog: PluginCommandCatalog;
try {
catalog = await commands.list(commandTarget);
} catch (error) {
input.logger?.log("Plugin command discovery failed", {
error,
severity: "warn",
});
return false;
}
if (
!catalog.commands.some(
(command) => command.name === parsed.command.slice(1),
)
)
return false;
const result = await commands.run({
...commandTarget,
prompt: `${parsed.command}${parsed.argumentsText}`,
});
}
return { host, pluginSlashCommands, shutdown: service.shutdown };
if (!result) return false;
if (result.reply) await context.reply(result.reply);
if (result.submitPrompt) await context.submitPrompt?.(result.submitPrompt);
return true;
});
return {
host,
async listCommands() {
const catalog = await commands.list(target());
if (catalog.status === "error")
input.logger?.log(`Plugin commands unavailable: ${catalog.error}`);
return catalog.commands;
},
subscribe(listener: () => void) {
return commands.subscribe((catalog) => {
if (catalog.workspacePath === target().workspacePath) listener();
});
},
};
}
@@ -9,7 +9,8 @@ import {
import { tmpdir } from "node:os";
import { join } from "node:path";
import { SessionNotFoundError } from "@cline/core";
import { afterEach, describe, expect, it, vi } from "vitest";
import { resolveClineDir, setClineDir } from "@cline/shared/storage";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { materializeUserFiles } from "./attachments";
import {
buildSessionConnectionUpdate,
@@ -116,7 +117,7 @@ function localRuntimeContext(
workspaceRoot,
sessionManager,
hubClient: {
command: vi.fn(async () => undefined),
command: vi.fn(async () => ({ ok: true, payload: {} })),
},
unsubscribeSessionEvents: () => {},
},
@@ -2233,8 +2234,18 @@ describe("workspace metadata prewarming", () => {
describe("runtime slash command expansion on send", () => {
const tempRoots: string[] = [];
let originalClineDir: string;
beforeEach(() => {
originalClineDir = resolveClineDir();
const settingsRoot = mkdtempSync(
join(tmpdir(), "desktop-command-settings-"),
);
tempRoots.push(settingsRoot);
setClineDir(settingsRoot);
});
afterEach(() => {
setClineDir(originalClineDir);
for (const dir of tempRoots) {
rmSync(dir, { recursive: true, force: true });
}
@@ -2328,26 +2339,49 @@ Follow the desktop send workflow instructions.`,
expect(session.prompt).toBe("/desktop-send-skill write the docs");
});
it("still sends a skill command when a workspace plugin fails to load", async () => {
it.each([
"local",
"ssh",
] as const)("dispatches plugin commands through the owning %s hub", async (kind) => {
const workspace = createWorkspaceWithSkill();
const pluginsDir = join(workspace, ".cline", "plugins");
mkdirSync(pluginsDir, { recursive: true });
writeFileSync(
join(pluginsDir, "broken.js"),
"export default { name: 'broken', manifest: { capabilities: ['bogus'] }, setup() {} };",
);
const { ctx, send, sessionId } = createContext(workspace);
await handleChatSessionCommand(ctx, {
const binding = ctx.runtimeBindings.get("local");
if (!binding) throw new Error("Missing test binding");
binding.kind = kind;
const liveSession = ctx.liveSessions.get(sessionId);
if (liveSession) liveSession.config.apiKey = "test-key";
binding.sessionManager.updateSessionConnection = vi.fn(async () => {});
const command = vi.fn(async () => ({
version: "v1" as const,
ok: true,
payload: {
result: {
reply: "Goal set",
submitPrompt: "expanded goal\nnext line",
},
},
}));
binding.hubClient.command = command;
const result = await handleChatSessionCommand(ctx, {
action: "send",
sessionId,
prompt: "/desktop-send-skill write the docs",
prompt: "/goal original",
});
expect(send).toHaveBeenCalledWith(
expect(result).toMatchObject({
result: { finishReason: "completed", text: "done" },
});
expect(command).toHaveBeenCalledWith(
"plugins.commands.run",
expect.objectContaining({
prompt: "/desktop-send-skill write the docs",
workspacePath: workspace,
sessionId,
prompt: "/goal original",
}),
sessionId,
{ timeoutMs: null },
);
expect(send).toHaveBeenCalledWith(
expect.objectContaining({ prompt: "expanded goal\nnext line" }),
);
});
@@ -1658,8 +1658,8 @@ async function handleSend(
const workspacePath =
readWorkspacePath(session?.config ?? request.config) ??
ctx.localWorkspaceRoot;
// Plugin slash commands (`api.registerCommand`) run here in the sidecar,
// as in the CLI: the handler's reply goes to the webview as a toast and
// Plugin slash commands execute in the session runtime.
// The handler's reply goes to the webview as a toast and
// only its `submitPrompt` (if any) reaches the model.
const commandName = prompt.match(/^\/(\S+)/)?.[1]?.toLowerCase();
if (commandName === "compact") {
@@ -1686,10 +1686,13 @@ async function handleSend(
return { sessionId, ok: true, commandHandled: true };
}
const pluginCommand =
commandName &&
!BUILTIN_SLASH_COMMAND_NAMES.has(commandName) &&
binding.kind !== "ssh"
? await runPluginSlashCommand(ctx, { workspacePath, prompt })
commandName && !BUILTIN_SLASH_COMMAND_NAMES.has(commandName)
? await runPluginSlashCommand(ctx, {
workspacePath,
prompt,
sessionId,
environmentId: binding.environmentId,
})
: undefined;
if (pluginCommand) {
if (pluginCommand.reply) {
@@ -1706,15 +1709,15 @@ async function handleSend(
// Dispatch the expanded or rewritten instructions, but keep the raw
// `/command` token as the session's display prompt.
const runtimePrompt =
binding.kind === "ssh"
pluginCommand?.submitPrompt ??
(binding.kind === "ssh"
? prompt
: (pluginCommand?.submitPrompt ??
(await resolveDesktopRuntimePrompt(
: await resolveDesktopRuntimePrompt(
ctx,
workspacePath,
prompt,
request.config?.mode ?? session?.config?.mode,
)));
));
let delivery = request.delivery;
if (!delivery && session?.busy) {
delivery = "queue";
+7 -15
View File
@@ -161,10 +161,7 @@ import {
sessionLogPath,
sharedSessionDataDir,
} from "./paths";
import {
getPluginCommandService,
warmPluginCommandService,
} from "./plugin-commands";
import { listPluginCommands } from "./plugin-commands";
import { getPullRequestStatus } from "./pull-request";
import { capturePullRequestEvent } from "./pull-request-telemetry";
import { resolveDesktopRemoteHelper } from "./remote-helper";
@@ -3542,22 +3539,17 @@ export async function handleCommand(
String(args?.workspacePath ?? "").trim() || ctx.localWorkspaceRoot,
);
}
if (command === "warm_plugin_commands") {
// The webview reports whichever local workspace it has adopted so the
// plugin sandbox is loaded before the slash menu first needs it.
const binding = getCommandRuntimeBinding(ctx, args);
const workspacePath = String(args?.workspacePath ?? "").trim();
if (binding.kind === "local" && workspacePath) {
warmPluginCommandService(ctx, workspacePath);
}
return { environmentId: binding.environmentId };
}
if (command === "list_plugin_commands") {
// Same workspace the session will execute in (handleSend), so the menu
// only offers commands that can actually run there.
const workspacePath =
String(args?.workspacePath ?? "").trim() || ctx.localWorkspaceRoot;
return await getPluginCommandService(ctx, workspacePath).listCommands();
return await listPluginCommands(
ctx,
workspacePath,
typeof args?.sessionId === "string" ? args.sessionId : undefined,
requestedEnvironmentId(args),
);
}
if (command === "list_marketplace_installed_entries") {
return listMarketplaceInstalledEntries(
+4 -1
View File
@@ -936,7 +936,10 @@ export function handleHubLiveEvent(
});
return;
}
if (event.event === "settings.changed") {
if (
event.event === "settings.changed" ||
event.event === "plugins.commands.changed"
) {
sendEvent(ctx, event.event, event.payload ?? {});
return;
}
@@ -1,178 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { listCommandsMock, runMock, createPluginCommandServiceMock } =
vi.hoisted(() => {
const listCommandsMock = vi.fn(
async (): Promise<Array<{ name: string; description?: string }>> => [],
);
const runMock = vi.fn(
async (
_name: string,
_input: string,
): Promise<{ reply?: string; submitPrompt?: string } | undefined> =>
undefined,
);
return {
listCommandsMock,
runMock,
createPluginCommandServiceMock: vi.fn(() => ({
listCommands: listCommandsMock,
run: runMock,
shutdown: vi.fn(),
})),
};
});
vi.mock("@cline/core", async () => {
const actual =
await vi.importActual<typeof import("@cline/core")>("@cline/core");
return {
...actual,
createPluginCommandService: createPluginCommandServiceMock,
};
});
import { handleCommand } from "./commands";
import {
resetPluginCommandServicesForTests,
runPluginSlashCommand,
WARMUP_MAX_ATTEMPTS,
WARMUP_RETRY_DELAY_MS,
} from "./plugin-commands";
import type { SidecarContext } from "./types";
const WORKSPACE = "/projects/app";
function createContext(kind: "local" | "ssh" = "local"): SidecarContext {
return {
activeEnvironmentId: "env",
localWorkspaceRoot: "/local/launch-root",
runtimeBindings: new Map([
[
"env",
{
kind,
environmentId: "env",
workspaceRoot: "/local/launch-root",
},
],
]),
} as unknown as SidecarContext;
}
async function flushPromises(): Promise<void> {
for (let i = 0; i < 5; i += 1) {
await Promise.resolve();
}
}
describe("plugin command warmup", () => {
beforeEach(() => {
vi.useFakeTimers();
resetPluginCommandServicesForTests();
listCommandsMock.mockReset();
listCommandsMock.mockResolvedValue([]);
runMock.mockReset();
createPluginCommandServiceMock.mockClear();
});
afterEach(() => {
vi.useRealTimers();
});
it("loads the workspace's plugin commands once ahead of the slash menu", async () => {
listCommandsMock.mockResolvedValue([{ name: "goal" }]);
const ctx = createContext();
await expect(
handleCommand(ctx, "warm_plugin_commands", { workspacePath: WORKSPACE }),
).resolves.toEqual({ environmentId: "env" });
await flushPromises();
// The service is keyed on the reported workspace, not the launch root.
expect(createPluginCommandServiceMock).toHaveBeenCalledTimes(1);
expect(createPluginCommandServiceMock).toHaveBeenCalledWith(
expect.objectContaining({ cwd: WORKSPACE, workspacePath: WORKSPACE }),
);
expect(listCommandsMock).toHaveBeenCalledTimes(1);
// Re-reporting the same workspace does not reload a warmed host.
await handleCommand(ctx, "warm_plugin_commands", {
workspacePath: WORKSPACE,
});
await flushPromises();
expect(listCommandsMock).toHaveBeenCalledTimes(1);
// The slash menu and the command runner reuse the warmed service.
await expect(
handleCommand(ctx, "list_plugin_commands", { workspacePath: WORKSPACE }),
).resolves.toEqual([{ name: "goal" }]);
expect(createPluginCommandServiceMock).toHaveBeenCalledTimes(1);
});
it("recovers from a failed cold load and makes the command available", async () => {
// First load times out inside the service (which reports it as an
// empty command set), the retry succeeds.
listCommandsMock
.mockResolvedValueOnce([])
.mockResolvedValue([{ name: "goal", description: "Set a goal" }]);
runMock.mockResolvedValue({ reply: "Goal set: ship it" });
const ctx = createContext();
await handleCommand(ctx, "warm_plugin_commands", {
workspacePath: WORKSPACE,
});
await flushPromises();
expect(listCommandsMock).toHaveBeenCalledTimes(1);
// Re-reporting the workspace while the retry is pending does not stack
// extra loads.
await handleCommand(ctx, "warm_plugin_commands", {
workspacePath: WORKSPACE,
});
await flushPromises();
expect(listCommandsMock).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(WARMUP_RETRY_DELAY_MS);
await flushPromises();
expect(listCommandsMock).toHaveBeenCalledTimes(2);
// Discovery and execution now work without the user reopening the menu.
await expect(
handleCommand(ctx, "list_plugin_commands", { workspacePath: WORKSPACE }),
).resolves.toEqual([{ name: "goal", description: "Set a goal" }]);
await expect(
runPluginSlashCommand(ctx, {
workspacePath: WORKSPACE,
prompt: "/goal ship it",
}),
).resolves.toEqual({ reply: "Goal set: ship it" });
expect(runMock).toHaveBeenCalledWith("goal", " ship it");
// Once warmed, no further retries are scheduled.
await vi.advanceTimersByTimeAsync(WARMUP_RETRY_DELAY_MS * 2);
expect(listCommandsMock).toHaveBeenCalledTimes(3);
});
it("stops retrying after a bounded number of attempts", async () => {
listCommandsMock.mockRejectedValue(new Error("sandbox boom"));
const ctx = createContext();
await handleCommand(ctx, "warm_plugin_commands", {
workspacePath: WORKSPACE,
});
await flushPromises();
for (let i = 0; i < WARMUP_MAX_ATTEMPTS + 2; i += 1) {
await vi.advanceTimersByTimeAsync(WARMUP_RETRY_DELAY_MS);
await flushPromises();
}
expect(listCommandsMock).toHaveBeenCalledTimes(WARMUP_MAX_ATTEMPTS);
});
it("does not spawn a sandbox for remote workspaces", async () => {
const ctx = createContext("ssh");
await handleCommand(ctx, "warm_plugin_commands", {
workspacePath: "/srv/code",
});
await flushPromises();
expect(createPluginCommandServiceMock).not.toHaveBeenCalled();
});
});
@@ -1,120 +1,39 @@
import {
createPluginCommandService,
createHubPluginCommandsApi,
type PluginCommandResult,
type PluginCommandService,
} from "@cline/core";
import { getSessionRuntimeBinding } from "./context";
import type { SidecarContext } from "./types";
// One service per workspace; it keeps the plugin sandboxes alive between
// commands and reloads them itself when the plugin set changes.
const servicesByWorkspace = new Map<string, PluginCommandService>();
export function getPluginCommandService(
export async function listPluginCommands(
ctx: SidecarContext,
workspacePath: string,
): PluginCommandService {
let service = servicesByWorkspace.get(workspacePath);
if (!service) {
service = createPluginCommandService({
cwd: workspacePath,
workspacePath,
logger: ctx.logger,
});
servicesByWorkspace.set(workspacePath, service);
}
return service;
sessionId?: string,
environmentId?: string,
) {
const client = getSessionRuntimeBinding(
ctx,
sessionId,
environmentId,
).hubClient;
return createHubPluginCommandsApi(client).list({ workspacePath, sessionId });
}
// A failed load (sandbox startup timeout, I/O hiccup) yields an empty command
// list and is cached by the service for 30s, so space retries past that
// window. Legitimately empty results make these retries a cheap no-op since
// the service keeps its loaded host.
export const WARMUP_RETRY_DELAY_MS = 35_000;
export const WARMUP_MAX_ATTEMPTS = 4;
type WarmupState = {
attempts: number;
done: boolean;
inFlight: boolean;
retry?: ReturnType<typeof setTimeout>;
};
const warmups = new Map<string, WarmupState>();
/**
* Spawn the plugin sandbox for a workspace ahead of the first slash menu
* open (the CLI does the same at TUI mount), so the menu lists plugin
* commands immediately instead of paying the cold spawn on the first `/`.
* The webview calls this for whichever workspace it has adopted. A load that
* comes back empty is retried in the background a few times so a slow cold
* start recovers without the user reopening the menu.
*/
export function warmPluginCommandService(
ctx: SidecarContext,
workspacePath: string,
): void {
let state = warmups.get(workspacePath);
if (!state) {
state = { attempts: 0, done: false, inFlight: false };
warmups.set(workspacePath, state);
}
if (
state.done ||
state.inFlight ||
state.retry ||
state.attempts >= WARMUP_MAX_ATTEMPTS
) {
return;
}
state.attempts += 1;
state.inFlight = true;
void getPluginCommandService(ctx, workspacePath)
.listCommands()
.then(
(commands) => commands.length > 0,
(error) => {
ctx.logger?.debug?.("plugin command warmup failed", { error });
return false;
},
)
.then((loaded) => {
state.inFlight = false;
if (loaded) {
state.done = true;
return;
}
if (state.attempts >= WARMUP_MAX_ATTEMPTS) {
return;
}
state.retry = setTimeout(() => {
state.retry = undefined;
warmPluginCommandService(ctx, workspacePath);
}, WARMUP_RETRY_DELAY_MS);
state.retry.unref?.();
});
}
/** Test hook: forget warm-up progress and cached services. */
export function resetPluginCommandServicesForTests(): void {
for (const state of warmups.values()) {
clearTimeout(state.retry);
}
warmups.clear();
servicesByWorkspace.clear();
}
/**
* Run a plugin-registered slash command for the leading `/name` token of a
* prompt. Returns undefined when no enabled plugin declares the command so
* the prompt continues through the normal path.
*/
export async function runPluginSlashCommand(
ctx: SidecarContext,
input: { workspacePath: string; prompt: string },
input: {
workspacePath: string;
prompt: string;
sessionId: string;
environmentId?: string;
},
): Promise<PluginCommandResult | undefined> {
const match = input.prompt.match(/^\/(\S+)([\s\S]*)$/);
if (!match?.[1]) return undefined;
return await getPluginCommandService(ctx, input.workspacePath).run(
match[1],
match[2] ?? "",
);
return createHubPluginCommandsApi(
getSessionRuntimeBinding(ctx, input.sessionId, input.environmentId)
.hubClient,
).run({
workspacePath: input.workspacePath,
sessionId: input.sessionId,
prompt: input.prompt,
});
}
+1 -16
View File
@@ -1756,22 +1756,6 @@ function ChatThreadPane({
[environmentId, onPickRemoteWorkspaceDirectory, remoteEnvironment],
);
// Let the sidecar load the workspace's plugin sandbox now so the slash
// menu lists plugin commands without paying the cold spawn on first open.
useEffect(() => {
if (!activeWorkspaceCwd) {
return;
}
void desktopClient
.invoke("warm_plugin_commands", {
workspacePath: activeWorkspaceCwd,
environmentId,
})
.catch(() => {
// Best effort; the menu falls back to loading on open.
});
}, [activeWorkspaceCwd, environmentId]);
useEffect(() => {
void refreshGitBranch();
if (!activeWorkspaceCwd) {
@@ -2521,6 +2505,7 @@ function ChatThreadPane({
const composer = (
<ChatInputBar
sessionId={sessionId}
readOnly={isCloudSessionExpired}
attachments={attachmentList}
environmentId={environmentId}
@@ -7,6 +7,7 @@ import { WorkspaceProvider } from "@/contexts/workspace-context";
import { getInitialChatConfig } from "@/hooks/chat-session/constants";
import { usePromptDraft } from "@/hooks/use-prompt-draft";
import type { ChatSessionStatus } from "@/lib/chat-schema";
import { desktopClient } from "@/lib/desktop-client";
import {
MODEL_SELECTION_STORAGE_KEY,
parseModelSelectionStorage,
@@ -216,6 +217,8 @@ async function renderVoiceComposer({
root.render(
<WorkspaceProvider value={workspaceValue}>
<ChatInputBar
environmentId="local"
workspaceRoot="/workspace/cline"
readOnly={readOnly}
executionTarget={executionTarget}
attachments={attachments}
@@ -376,6 +379,141 @@ describe("ChatInputBar draft navigation", () => {
});
describe("ChatInputBar", () => {
it("retains skills and workflows when plugin discovery rejects", async () => {
vi.spyOn(desktopClient, "invoke").mockImplementation(async (name) => {
if (name === "list_plugin_commands") throw new Error("hub unavailable");
return {
runtimeCommands: [
{
name: "publish-skill",
kind: "skill",
description: "Publish skill",
},
{
name: "publish-workflow",
kind: "workflow",
description: "Publish workflow",
},
],
};
});
await renderVoiceComposer({ prompt: "/publish", executionTarget: "local" });
expect(container.textContent).toContain("Publish skill");
expect(container.textContent).toContain("Publish workflow");
});
it("refreshes instruction files on reopen without resetting plugin or cached suggestions", async () => {
const updated = deferred<{
runtimeCommands: Array<{
name: string;
kind: "workflow";
description: string;
}>;
}>();
let instructionRequests = 0;
const invoke = vi
.spyOn(desktopClient, "invoke")
.mockImplementation(async (name) => {
if (name === "list_plugin_commands")
return {
status: "ready",
commands: [
{ name: "publish-plugin", description: "Plugin suggestion" },
],
};
if (name === "list_user_instruction_configs") {
instructionRequests++;
return instructionRequests === 1
? {
runtimeCommands: [
{
name: "publish-old",
kind: "skill",
description: "Old skill",
},
],
}
: updated.promise;
}
return {};
});
await renderVoiceComposer({
prompt: "/publish",
promptVersion: 1,
executionTarget: "local",
});
expect(container.textContent).toContain("Old skill");
await renderVoiceComposer({
prompt: "ordinary text",
promptVersion: 2,
executionTarget: "local",
});
await renderVoiceComposer({
prompt: "/publish",
promptVersion: 3,
executionTarget: "local",
});
expect(container.textContent).toContain("Old skill");
expect(container.textContent).toContain("Plugin suggestion");
expect(instructionRequests).toBe(2);
expect(
invoke.mock.calls.filter(([name]) => name === "list_plugin_commands"),
).toHaveLength(1);
await act(async () =>
updated.resolve({
runtimeCommands: [
{
name: "publish-new",
kind: "workflow",
description: "New workflow",
},
],
}),
);
expect(container.textContent).not.toContain("Old skill");
expect(container.textContent).toContain("New workflow");
expect(container.textContent).toContain("Plugin suggestion");
});
it("refreshes the open slash menu when the hub catalog recovers", async () => {
let available = false;
let changed: ((payload: unknown) => void) | undefined;
vi.spyOn(desktopClient, "subscribe").mockImplementation(
(name, listener) => {
if (name === "plugins.commands.changed") changed = listener;
return vi.fn();
},
);
vi.spyOn(desktopClient, "invoke").mockImplementation(async (name) => {
if (name === "list_plugin_commands")
return {
workspacePath: "/workspace/cline",
status: available ? "ready" : "error",
commands: available
? [{ name: "upload-history", description: "Upload history" }]
: [],
};
return { runtimeCommands: [] };
});
await renderVoiceComposer({ prompt: "/upload-", executionTarget: "local" });
expect(container.textContent).toContain("Plugin commands unavailable");
available = true;
await act(async () => {
changed?.({
environmentId: "other-host",
catalog: { workspacePath: "/workspace/cline" },
});
});
expect(container.textContent).not.toContain("Upload history");
await act(async () => {
changed?.({
environmentId: "local",
catalog: { workspacePath: "/workspace/cline" },
});
});
expect(container.textContent).toContain("Upload history");
});
it("prevents sending from a read-only session", async () => {
const onSend = vi.fn();
await renderVoiceComposer({ prompt: "Test", readOnly: true, onSend });
@@ -634,7 +772,7 @@ describe("ChatInputBar", () => {
]);
});
it("appends plugin commands after skills and workflows", () => {
it("gives plugin commands the same precedence as execution", () => {
expect(
buildUserInstructionSlashCommands(
{
@@ -647,7 +785,7 @@ describe("ChatInputBar", () => {
],
),
).toEqual([
{ name: "goal", description: "Skill command" },
{ name: "goal", description: "Set or clear a goal" },
{ name: "goal-status", description: "Plugin command" },
]);
});
@@ -1,5 +1,7 @@
"use client";
import type { PluginCommandCatalog } from "@cline/core";
import {
CLINE_DEFAULT_MODEL_ID,
formatDisplayUserInput,
@@ -120,11 +122,6 @@ const BUILTIN_SLASH_COMMANDS: SlashCommand[] = [
{ name: "team", description: "Start the task with an agent team" },
];
// Last known user commands, kept across composer instances so reopening the
// slash menu paints instantly (stale-while-revalidate); the fetch that
// follows still picks up newly installed skills and workflows.
let cachedSlashCommands: SlashCommand[] | null = null;
export function buildUserInstructionSlashCommands(
response: UserInstructionConfigResponse,
// Plugin commands (`api.registerCommand`) as listed by the sidecar's
@@ -135,21 +132,7 @@ export function buildUserInstructionSlashCommands(
? response.runtimeCommands
: [];
const seen = new Set(BUILTIN_SLASH_COMMANDS.map((command) => command.name));
const result = commands.flatMap((command) => {
const name = command.name;
if (!name || seen.has(name)) {
return [];
}
seen.add(name);
return [
{
name,
description:
command.description?.trim() ||
`${command.kind === "skill" ? "Skill" : "Workflow"} command`,
},
];
});
const result: SlashCommand[] = [];
for (const command of pluginCommands) {
if (!command.name || seen.has(command.name)) continue;
seen.add(command.name);
@@ -158,6 +141,16 @@ export function buildUserInstructionSlashCommands(
description: command.description?.trim() || "Plugin command",
});
}
for (const command of commands) {
if (!command.name || seen.has(command.name)) continue;
seen.add(command.name);
result.push({
name: command.name,
description:
command.description?.trim() ||
`${command.kind === "skill" ? "Skill" : "Workflow"} command`,
});
}
return result;
}
@@ -305,6 +298,7 @@ export function buildWorkspaceFileSearchKey(
}
type ChatInputBarProps = {
sessionId?: string | null;
environmentId: string;
variant?: "conversation" | "welcome";
readOnly?: boolean;
@@ -355,6 +349,7 @@ type ChatInputBarProps = {
};
function ChatInputBarImpl({
sessionId,
environmentId,
variant = "conversation",
readOnly = false,
@@ -655,10 +650,31 @@ function ChatInputBarImpl({
executionTarget === "local" &&
slashKey !== null &&
dismissedSlashKey !== slashKey;
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>(
() => cachedSlashCommands ?? BUILTIN_SLASH_COMMANDS,
const [instructionResponse, setInstructionResponse] = useState<{
workspaceRoot: string;
environmentId: string;
response: UserInstructionConfigResponse;
}>();
const [pluginCommands, setPluginCommands] = useState<SlashCommand[]>([]);
const slashCommands = useMemo(
() => [
...BUILTIN_SLASH_COMMANDS,
...buildUserInstructionSlashCommands(
instructionResponse?.workspaceRoot === workspaceRoot &&
instructionResponse.environmentId === environmentId
? instructionResponse.response
: {},
pluginCommands,
),
],
[instructionResponse, pluginCommands, workspaceRoot, environmentId],
);
const [slashLoading, setSlashLoading] = useState(false);
const [pluginLoading, setPluginLoading] = useState(false);
const [instructionLoading, setInstructionLoading] = useState(false);
const [pluginError, setPluginError] = useState<string>();
const [instructionError, setInstructionError] = useState<string>();
const slashLoading = pluginLoading || instructionLoading;
const slashError = pluginError ?? instructionError;
const [slashSelectedIndex, setSlashSelectedIndex] = useState(0);
useEffect(() => {
@@ -1012,45 +1028,118 @@ function ChatInputBarImpl({
?.scrollIntoView({ block: "nearest" });
}, [mentionOpen, mentionSelectedIndex]);
// Reload user commands whenever the slash menu opens so newly installed or
// edited skills and workflows are reflected without remounting the chat UI.
// Runtime-owned plugin catalogs stay loaded across menu toggles.
useEffect(() => {
setPluginCommands([]);
setPluginError(undefined);
if (executionTarget !== "local") return;
let cancelled = false;
let requestId = 0;
setPluginLoading(true);
const refresh = async () => {
const id = ++requestId;
try {
const catalog = await desktopClient.invoke<PluginCommandCatalog>(
"list_plugin_commands",
{
workspacePath: workspaceRoot,
environmentId,
sessionId: sessionId ?? undefined,
},
);
if (cancelled || id !== requestId) return;
setPluginCommands(catalog.commands);
setPluginError(
catalog.status === "error"
? "Plugin commands unavailable"
: undefined,
);
} catch {
if (!cancelled && id === requestId)
setPluginError("Plugin commands unavailable");
} finally {
if (!cancelled && id === requestId) setPluginLoading(false);
}
};
const unsubscribe = desktopClient.subscribe(
"plugins.commands.changed",
(payload) => {
const event = payload as {
environmentId?: string;
catalog?: PluginCommandCatalog;
};
if (
event.environmentId === environmentId &&
event.catalog?.workspacePath === workspaceRoot
)
void refresh();
},
);
const unsubscribeSettings = desktopClient.subscribe(
"settings.changed",
(payload) => {
if (
(payload as { environmentId?: string }).environmentId ===
environmentId
)
void refresh();
},
);
void refresh();
return () => {
cancelled = true;
unsubscribe();
unsubscribeSettings();
};
}, [workspaceRoot, environmentId, sessionId, executionTarget]);
// Instruction files have no catalog-change event. Refresh them on open,
// retaining the last response while fetching so suggestions do not flash away.
useEffect(() => {
if (!slashOpen) {
setInstructionLoading(false);
return;
}
let cancelled = false;
// Only show the loading row when there is nothing cached to show.
setSlashLoading(cachedSlashCommands === null);
Promise.all([
desktopClient.invoke<UserInstructionConfigResponse>(
"list_user_instruction_configs",
{ workspacePath: workspaceRoot },
),
desktopClient
.invoke<SlashCommand[]>("list_plugin_commands", {
workspacePath: workspaceRoot,
})
.catch((): SlashCommand[] => []),
])
.then(([response, pluginCommands]) => {
if (cancelled) return;
const next = [
...BUILTIN_SLASH_COMMANDS,
...buildUserInstructionSlashCommands(response, pluginCommands),
];
cachedSlashCommands = next;
setSlashCommands(next);
})
.catch(() => {
// Keep built-in commands on error.
})
.finally(() => {
if (!cancelled) setSlashLoading(false);
});
let requestId = 0;
const refresh = async () => {
const id = ++requestId;
setInstructionLoading(true);
try {
const response =
await desktopClient.invoke<UserInstructionConfigResponse>(
"list_user_instruction_configs",
{
workspacePath: workspaceRoot,
environmentId,
},
);
if (cancelled || id !== requestId) return;
setInstructionResponse({ workspaceRoot, environmentId, response });
setInstructionError(undefined);
} catch {
if (!cancelled && id === requestId)
setInstructionError("Instruction commands unavailable");
} finally {
if (!cancelled && id === requestId) setInstructionLoading(false);
}
};
const unsubscribe = desktopClient.subscribe(
"settings.changed",
(payload) => {
if (
(payload as { environmentId?: string }).environmentId ===
environmentId
)
void refresh();
},
);
void refresh();
return () => {
cancelled = true;
unsubscribe();
};
}, [slashOpen, workspaceRoot]);
}, [slashOpen, workspaceRoot, environmentId]);
// Filtered slash commands based on the current query.
const filteredSlashCommands = useMemo(() => {
@@ -1128,7 +1217,7 @@ function ChatInputBarImpl({
<div className="px-3 py-2 text-sm text-muted-foreground">
{slashLoading
? "Loading commands..."
: "No matching commands"}
: (slashError ?? "No matching commands")}
</div>
) : (
<>
+31
View File
@@ -790,6 +790,37 @@ today: since #14792 it is the bundled Cline CLI uploaded as a single file
with no bootstrap beside it, so plugins fail on remotes until the remote
runtime carries one.
`LocalRuntimeHost.pluginCommands` owns plugin command discovery and execution.
Hub RPC (`plugins.commands.list` / `plugins.commands.run`) and
`plugins.commands.changed` expose the same service to every client. The desktop
sidecar, CLI, and connectors reuse their existing hub connections. Workspace
catalogs are initialized when a composer first requests them and refreshed by
runtime-owned plugin/settings watchers, including for non-Git folders. Failed
initialization retries in the background and publishes explicit error/recovery
state. Workspace catalogs use their own worker registry so catalog failures do not disable resident session instances. Partial retries reload only failed plugin paths and retain
healthy instances; source/settings changes reload the workspace catalog. Git
queries and individual UI mounts do not own plugin lifetimes.
A workspace catalog owns a discovery sandbox; a resident session instead lists
and executes its own contribution registry, retaining the same plugin instance
and context as its hooks. Existing session registrations remain fixed for that
session, while changed workspace catalogs apply to new sessions. Reload and
disposal wait for command handlers before terminating their workers. Hub status and replacement checks count active command requests even without a chat participant; draining refuses new catalog and command requests.
The shared command executor records `task.tool_used` once per matched handler,
including failures, for both direct runtime and Hub calls. Resident execution
uses session/provider/model and agent identity; workspace execution uses an
action correlation ID without inventing a session. Catalog reads and unmatched
commands do not count as tool use. CLI interactive initialization shares one
pending core across catalog discovery and session startup and waits for it
during cleanup. CLI fallback checks command ownership before execution:
discovery failures leave skills/workflows available, while errors from owned
plugin handlers remain visible to the caller.
Telemetry failures cannot replace a command's result or original error. The
CLI suggestion hook preserves its last successful catalog while refresh or
subscription initialization retries with capped exponential backoff, and
unmount cancels retries and releases subscriptions that finish late.
Sandboxed plugin subprocesses and workers are session-local but lazily recreatable. Core
reclaims a sandbox after 30 minutes without an in-flight RPC call (configurable
through `PluginSandboxOptions.idleTimeoutMs` or
+38
View File
@@ -1,3 +1,41 @@
## Plugin slash commands
Use `core.pluginCommands` on an existing `ClineCore` (local or hub-backed), or
`client.pluginCommands` on an existing `HubSessionClient`. Hosts with a
`NodeHubClient` can use `createHubPluginCommandsApi(client)` from `@cline/core`.
These APIs reuse the runtime and connection; clients do not load plugins or
own a separate command sandbox.
```ts
const target = { workspacePath: workspaceRoot, sessionId };
const stop = core.pluginCommands.subscribe((catalog) => {
if (catalog.workspacePath === workspaceRoot) refreshCommandMenu();
});
const catalog = await core.pluginCommands.list(target);
const result = await core.pluginCommands.run({ ...target, prompt: "/upload-history" });
// Display result?.reply; submit only result?.submitPrompt to the agent.
// undefined means no registered command handled the input.
stop();
```
Omit `sessionId` for workspace discovery before a session exists. The runtime
initializes that workspace catalog on its first request, watches plugin sources
and settings, and publishes changes. A failed load produces `status: "error"`
with diagnostics and retries in the background with bounded backoff. When only
some plugins fail, the catalog still lists and executes healthy commands.
Background retries load only the failed paths, preserving healthy plugin
instances and command state. Source/settings changes still reload the workspace catalog. It never
requires a Git query or a running agent turn. Dispose the owning runtime to
release catalog watchers and sandboxes; unsubscribe when a view closes.
For a resident session, both listing and execution use its initialized
contribution registry and existing plugin sandbox, including session context.
Session plugin registrations retain their existing session lifetime: workspace
file changes refresh discovery for new sessions, not an active session's hooks
or commands. A non-resident session can preview workspace commands; execution
requires restoring the session first. Built-in command precedence and rendering
remain host responsibilities. Preserve the original arguments when dispatching.
## Oversized tool result recovery
+4 -1
View File
@@ -8,6 +8,7 @@ import {
} from "./cline-core/automation";
import {
createClineCorePendingPromptsApi,
createClineCorePluginCommandsApi,
createClineCoreSettingsApi,
type RuntimeHostServiceExtensions,
} from "./cline-core/runtime-services";
@@ -29,7 +30,6 @@ import type {
RestoreResult,
StartSessionBootstrap,
} from "./cline-core/types";
import { CronService } from "./cron/service/cron-service";
import type { RuntimeCapabilities } from "./runtime/capabilities";
import { normalizeRuntimeCapabilities } from "./runtime/capabilities";
@@ -49,6 +49,7 @@ import {
FeatureFlagsService,
NoOpFeatureFlagsProvider,
} from "./services/feature-flags";
import type { PluginCommandsApi } from "./services/plugin-command-api";
import { resolveCoreDistinctId } from "./services/telemetry/distinct-id";
import { compareCheckpointToWorkspace } from "./session/checkpoint-diff";
import {
@@ -101,6 +102,7 @@ export class ClineCore {
readonly runtimeAddress: string | undefined;
readonly automation: ClineCoreAutomationApi;
readonly settings: ClineCoreSettingsApi;
readonly pluginCommands: PluginCommandsApi;
readonly featureFlags: FeatureFlagsService;
readonly pendingPrompts: PendingPromptsServiceApi;
private readonly host: RuntimeHost;
@@ -140,6 +142,7 @@ export class ClineCore {
this.distinctId = distinctId;
this.featureFlags = featureFlags;
this.settings = createClineCoreSettingsApi(host);
this.pluginCommands = createClineCorePluginCommandsApi(host);
this.pendingPrompts = createClineCorePendingPromptsApi(host);
this.automation = new ClineCoreAutomationController(() => {
if (!this.automationService) {
@@ -6,6 +6,10 @@ import type {
SessionModelRuntimeService,
SessionUsageRuntimeService,
} from "../runtime/host/runtime-host";
import type {
PluginCommandsApi,
PluginCommandsRuntimeService,
} from "../services/plugin-command-api";
import {
type ClineCoreSettingsApi,
type CoreSettingsListInput,
@@ -78,3 +82,20 @@ export function createClineCorePendingPromptsApi(
},
};
}
export function createClineCorePluginCommandsApi(
host: RuntimeHost,
): PluginCommandsApi {
const service = () => {
const api = (host as RuntimeHost & Partial<PluginCommandsRuntimeService>)
.pluginCommands;
if (!api)
throw new Error("Plugin commands are unavailable on this runtime");
return api;
};
return {
list: (target) => service().list(target),
run: (input) => service().run(input),
subscribe: (listener) => service().subscribe(listener),
};
}
@@ -336,6 +336,23 @@ export interface ResolveAndLoadAgentPluginsOptions
export async function resolveAndLoadAgentPlugins(
options: ResolveAndLoadAgentPluginsOptions = {},
) {
const resolved = resolveAgentPluginPathsWithDiagnostics(options);
return loadResolvedAgentPlugins({
...options,
pluginPaths: resolved.paths,
disabledPluginPaths: resolved.disabledPaths,
discoveryFailures: resolved.discoveryFailures,
});
}
/** Load only the supplied resolved paths, without rediscovering other plugins. */
export async function loadResolvedAgentPlugins(
options: ResolveAndLoadAgentPluginsOptions & {
pluginPaths: string[];
disabledPluginPaths?: string[];
discoveryFailures?: Array<{ pluginPath: string; error: unknown }>;
},
): Promise<
{
extensions: AgentPlugin[];
@@ -347,7 +364,11 @@ export async function resolveAndLoadAgentPlugins(
> {
const mode = resolvePluginExecutionMode(options.mode);
if (mode === "worker") {
const resolved = resolveAgentPluginPathsWithDiagnostics(options);
const resolved = {
paths: options.pluginPaths,
disabledPaths: options.disabledPluginPaths ?? [],
discoveryFailures: options.discoveryFailures ?? [],
};
if (
resolved.paths.length === 0 &&
resolved.disabledPaths.length === 0 &&
@@ -400,7 +421,7 @@ export async function resolveAndLoadAgentPlugins(
// The subprocess sandbox does not apply the per-session `policy`; it is
// an opt-in escape hatch and loads every discovered plugin.
const paths = resolveAgentPluginPaths(options);
const paths = options.pluginPaths;
if (paths.length === 0) {
return {
extensions: [],
@@ -1521,6 +1521,21 @@ describe("resolveCompatibleLocalHubUrl", () => {
});
describe("hasActiveHubSessions", () => {
it("counts active plugin requests without attached sessions", async () => {
const { hasActiveHubSessions, summarizeHubSessionActivity } = await import(
"."
);
expect(
hasActiveHubSessions({ sessions: [], activePluginRequests: 1 }),
).toBe(true);
expect(
summarizeHubSessionActivity({ sessions: [], activePluginRequests: 2 }),
).toMatchObject({ activeSessionCount: 0, activePluginRequests: 2 });
expect(
hasActiveHubSessions({ sessions: [], activePluginRequests: 0 }),
).toBe(false);
});
const payload = (sessions: unknown[]) => ({ sessions });
it("is idle for an empty or malformed list", async () => {
+20 -3
View File
@@ -1203,6 +1203,8 @@ export interface HubSessionActivity {
activeSessionCount: number;
/** Distinct client ids attached to those sessions. */
participantClientCount: number;
/** Plugin command requests executing even without an attached session. */
activePluginRequests: number;
}
/**
@@ -1249,11 +1251,24 @@ export function summarizeHubSessionActivity(
}
}
}
return { activeSessionCount, participantClientCount: clientIds.size };
const requests =
payload && typeof payload === "object"
? (payload as { activePluginRequests?: unknown }).activePluginRequests
: undefined;
const activePluginRequests =
typeof requests === "number" && Number.isInteger(requests) && requests > 0
? requests
: 0;
return {
activeSessionCount,
participantClientCount: clientIds.size,
activePluginRequests,
};
}
export function hasActiveHubSessions(payload: unknown): boolean {
return summarizeHubSessionActivity(payload).activeSessionCount > 0;
const activity = summarizeHubSessionActivity(payload);
return activity.activeSessionCount > 0 || activity.activePluginRequests > 0;
}
/**
@@ -1297,7 +1312,9 @@ export async function localHubHasNoActiveSessions(
): Promise<boolean> {
try {
const activity = await queryHubSessionActivity(url, authToken, options);
return activity.activeSessionCount === 0;
return (
activity.activeSessionCount === 0 && activity.activePluginRequests === 0
);
} catch {
// Recovery callers must not treat a hub they cannot positively observe
// idle as safe to stop, so an unanswerable hub reports as busy here.
@@ -10,7 +10,11 @@ type DiscoveryMockOptions = {
* counts. Always mocked: the real query opens a socket, which never
* settles under this file's fake timers.
*/
activity?: { activeSessionCount: number; participantClientCount: number };
activity?: {
activeSessionCount: number;
participantClientCount: number;
activePluginRequests: number;
};
/**
* This client's own build identity; omitted keeps the real resolution
* (env-driven buildId/epoch plus the actual core package version).
@@ -200,7 +204,11 @@ describe("checkManagedHubBuildMismatch", () => {
port: 59999,
url: "ws://127.0.0.1:59999/hub",
},
activity: { activeSessionCount: 2, participantClientCount: 1 },
activity: {
activeSessionCount: 2,
participantClientCount: 1,
activePluginRequests: 0,
},
});
const { checkManagedHubBuildMismatch } = await import(
"./managed-hub-build-watcher"
@@ -210,6 +218,7 @@ describe("checkManagedHubBuildMismatch", () => {
reason: "outdated_hub",
activeSessionCount: 2,
participantClientCount: 1,
activePluginRequests: 0,
});
});
@@ -52,6 +52,8 @@ export interface ManagedHubBuildMismatchEvent {
activeSessionCount?: number;
/** Distinct clients attached to those sessions (best-effort, `outdated_hub` only). */
participantClientCount?: number;
/** Active plugin commands or catalog requests on the running Hub. */
activePluginRequests?: number;
}
/**
@@ -70,6 +72,7 @@ async function withHubSessionActivity(
...event,
activeSessionCount: activity.activeSessionCount,
participantClientCount: activity.participantClientCount,
activePluginRequests: activity.activePluginRequests,
};
} catch {
return event;
@@ -0,0 +1,46 @@
import type {
PluginCommandCatalog,
PluginCommandResult,
PluginCommandsApi,
} from "../../services/plugin-command-api";
import type { NodeHubClient } from "./index";
/** Uses the caller's existing connection and subscription lifecycle. */
export function createHubPluginCommandsApi(
client: Pick<NodeHubClient, "command" | "subscribe">,
): PluginCommandsApi {
return {
async list(target) {
const reply = await client.command(
"plugins.commands.list",
{ ...target },
target.sessionId,
{ timeoutMs: null },
);
if (!reply.ok)
throw new Error(
reply.error?.message ?? "Plugin command discovery failed",
);
return reply.payload?.catalog as unknown as PluginCommandCatalog;
},
async run(input) {
const reply = await client.command(
"plugins.commands.run",
{ ...input },
input.sessionId,
{ timeoutMs: null },
);
if (!reply.ok)
throw new Error(
reply.error?.message ?? "Plugin command execution failed",
);
return reply.payload?.result as PluginCommandResult | undefined;
},
subscribe(listener) {
return client.subscribe((event) => {
if (event.event === "plugins.commands.changed")
listener(event.payload?.catalog as unknown as PluginCommandCatalog);
});
},
};
}
@@ -19,7 +19,9 @@ import type {
} from "@cline/shared";
import type { CheckpointEntry } from "../../hooks/checkpoint-hooks";
import { isSessionNotFoundError } from "../../runtime/host/runtime-host";
import type { PluginCommandsApi } from "../../services/plugin-command-api";
import { NodeHubClient } from "../client";
import { createHubPluginCommandsApi } from "./plugin-commands";
type ScheduleClientRecord = Record<string, unknown> & {
metadata?: Record<string, unknown>;
@@ -285,6 +287,7 @@ function mapHubEvent(event: HubEventEnvelope): HubStreamEvent | undefined {
export class HubSessionClient {
private readonly client: NodeHubClient;
readonly pluginCommands: PluginCommandsApi;
constructor(options: HubSessionClientOptions) {
this.client = new NodeHubClient({
@@ -297,6 +300,7 @@ export class HubSessionClient {
cwd: options.cwd,
metadata: options.metadata,
});
this.pluginCommands = createHubPluginCommandsApi(this.client);
}
async connect(): Promise<void> {
+12 -1
View File
@@ -36,6 +36,7 @@ const {
queryHubSessionActivity: vi.fn(async () => ({
activeSessionCount: 0,
participantClientCount: 0,
activePluginRequests: 0,
})),
requestHubDrain: vi.fn(async () => true),
resolveProductionHubOwnerContext: vi.fn(() => ({
@@ -193,6 +194,7 @@ describe("ensureDetachedHubServer", () => {
queryHubSessionActivity.mockResolvedValue({
activeSessionCount: 0,
participantClientCount: 0,
activePluginRequests: 0,
});
clearHubDiscovery.mockReset();
clearHubDiscovery.mockResolvedValue(undefined);
@@ -500,6 +502,7 @@ describe("ensureDetachedHubServer", () => {
queryHubSessionActivity.mockResolvedValue({
activeSessionCount: 1,
participantClientCount: 1,
activePluginRequests: 0,
});
readHubDiscovery.mockResolvedValueOnce({
url: "ws://127.0.0.1:25463/hub",
@@ -935,6 +938,7 @@ describe("upgradeManagedHub", () => {
queryHubSessionActivity.mockResolvedValue({
activeSessionCount: 0,
participantClientCount: 0,
activePluginRequests: 0,
});
clearHubDiscovery.mockReset();
clearHubDiscovery.mockResolvedValue(undefined);
@@ -961,6 +965,7 @@ describe("upgradeManagedHub", () => {
queryHubSessionActivity.mockResolvedValue({
activeSessionCount: 2,
participantClientCount: 1,
activePluginRequests: 0,
});
readHubDiscovery
.mockResolvedValueOnce({
@@ -1022,6 +1027,7 @@ describe("upgradeManagedHub", () => {
queryHubSessionActivity.mockResolvedValue({
activeSessionCount: 3,
participantClientCount: 2,
activePluginRequests: 0,
});
readHubDiscovery.mockResolvedValueOnce({
url: "ws://127.0.0.1:25463/hub",
@@ -1156,7 +1162,11 @@ describe("upgradeManagedHub", () => {
it("treats a failed activity reading as unknown and keeps polling instead of concluding idle", async () => {
queryHubSessionActivity
.mockRejectedValueOnce(new Error("session.list timed out"))
.mockResolvedValue({ activeSessionCount: 2, participantClientCount: 1 });
.mockResolvedValue({
activeSessionCount: 2,
participantClientCount: 1,
activePluginRequests: 0,
});
readHubDiscovery.mockResolvedValueOnce({
url: "ws://127.0.0.1:25463/hub",
authToken: "old-token",
@@ -1262,6 +1272,7 @@ describe("upgradeManagedHub", () => {
queryHubSessionActivity.mockResolvedValue({
activeSessionCount: 1,
participantClientCount: 1,
activePluginRequests: 0,
});
readHubDiscovery.mockResolvedValueOnce({
url: "ws://127.0.0.1:25463/hub",
+14 -6
View File
@@ -299,7 +299,10 @@ export async function hubHasLiveSessions(
record.url,
record.authToken,
);
return activity.activeSessionCount > 0;
return (
activity.activeSessionCount > 0 ||
(activity.activePluginRequests ?? 0) > 0
);
} catch {
return false;
}
@@ -965,12 +968,16 @@ export async function upgradeManagedHub(
// Only an observed-zero reading ends the window before the deadline.
// (Checks at least once so waitForIdleMs: 0 still observes an idle hub.)
let observedSessionCount: number | undefined;
let observedPluginRequests: number | undefined;
for (;;) {
try {
observedSessionCount = (
await queryHubSessionActivity(record.url, record.authToken)
).activeSessionCount;
if (observedSessionCount === 0) {
const activity = await queryHubSessionActivity(
record.url,
record.authToken,
);
observedSessionCount = activity.activeSessionCount;
observedPluginRequests = activity.activePluginRequests ?? 0;
if (observedSessionCount === 0 && observedPluginRequests === 0) {
break;
}
} catch {
@@ -983,7 +990,8 @@ export async function upgradeManagedHub(
setTimeout(resolve, HUB_UPGRADE_IDLE_POLL_MS),
);
}
const confirmedIdle = observedSessionCount === 0;
const confirmedIdle =
observedSessionCount === 0 && observedPluginRequests === 0;
// Without force, only a positively idle hub may be replaced: a busy or
// unanswerable hub is handed back un-drained. With force, the user has
// already consented to interrupting the sessions the prompt showed them,
@@ -47,6 +47,7 @@ import type {
} from "../../runtime/host/runtime-host";
import { isSessionNotFoundError } from "../../runtime/host/runtime-host";
import { RuntimeHostEventBus } from "../../runtime/host/runtime-host-support";
import type { PluginCommandsApi } from "../../services/plugin-command-api";
import { withSessionHistoryOriginMetadata } from "../../session/history-origin";
import {
parseSessionCompactionState,
@@ -82,6 +83,7 @@ import {
NodeHubClient,
restartLocalHubIfIdleAfterStartupTimeout,
} from "../client";
import { createHubPluginCommandsApi } from "../client/plugin-commands";
function toJsonRecord(
value: Record<string, unknown> | undefined,
@@ -765,7 +767,11 @@ function buildManifestFromSnapshot(
export class HubRuntimeHost implements RuntimeHost {
public runtimeAddress: string;
public readonly pendingPrompts: PendingPromptsServiceApi;
public readonly pluginCommands: PluginCommandsApi;
private client: NodeHubClient;
private readonly pluginCommandListeners = new Set<
(event: HubEventEnvelope) => void
>();
private readonly clientOptions: Omit<HubClientOptions, "url">;
private readonly clientContext?: { workspaceRoot?: string; cwd?: string };
private readonly events = new RuntimeHostEventBus();
@@ -808,10 +814,24 @@ export class HubRuntimeHost implements RuntimeHost {
delete: (input) => this.requestPendingPromptDelete(input),
};
this.client = this.createClient(options.url);
this.pluginCommands = createHubPluginCommandsApi({
command: (...args) => this.client.command(...args),
subscribe: (listener) => {
this.pluginCommandListeners.add(listener);
return () => {
this.pluginCommandListeners.delete(listener);
};
},
});
}
private createClient(url: string): NodeHubClient {
return new NodeHubClient({ ...this.clientOptions, url });
const client = new NodeHubClient({ ...this.clientOptions, url });
client.subscribe((event) => {
if (event.event === "plugins.commands.changed")
for (const listener of this.pluginCommandListeners) listener(event);
});
return client;
}
private async replaceClient(url: string): Promise<void> {
@@ -1337,6 +1357,7 @@ export class HubRuntimeHost implements RuntimeHost {
// Best-effort detach during shutdown.
}
}
this.pluginCommandListeners.clear();
this.sessionSubscriptions.clear();
this.sessionCapabilities.clear();
this.agentDoneEmittedForCurrentRunBySession.clear();
@@ -141,6 +141,21 @@ export class BrowserWebSocketHubAdapter {
};
const onEvent = (envelope: HubEventEnvelope): void => {
if (
envelope.event === "plugins.commands.changed" &&
!authority?.crossWorkspace
) {
const catalog = envelope.payload?.catalog as
| { workspacePath?: unknown }
| undefined;
const root = authority?.workspaceContext?.workspaceRoot;
if (
!root ||
typeof catalog?.workspacePath !== "string" ||
resolve(catalog.workspacePath) !== resolve(root)
)
return;
}
sendFrame({ kind: "event", envelope });
};
@@ -33,6 +33,8 @@ const DRAIN_REFUSED_COMMANDS = new Set<string>([
"run.start",
"session.send_input",
"run.enqueue",
"plugins.commands.run",
"plugins.commands.list",
]);
export function isDrainRefusedCommand(command: string): boolean {
@@ -30,6 +30,7 @@ import type {
PendingPromptsRuntimeService,
RuntimeHost,
} from "../../runtime/host/runtime-host";
import type { PluginCommandsRuntimeService } from "../../services/plugin-command-api";
import { SqliteSessionStore } from "../../services/storage/sqlite-session-store";
import { ensureAgentSchedulesWorkspace } from "../../services/workspace/agent-schedules-workspace";
import { withSessionHistoryOriginMetadata } from "../../session/history-origin";
@@ -291,8 +292,10 @@ export class HubServerTransport implements NativeHubTransport {
private runQueue?: HubRunQueue;
private runExecutor?: HubRunExecutor;
private draining = false;
private readonly pendingPluginCommands = new Set<Promise<unknown>>();
private readonly plugins: PluginRegistry = getProcessPluginRegistry();
private unsubscribePluginStatus?: () => void;
private readonly unsubscribePluginCommands?: () => void;
constructor(readonly options: HubWebSocketServerOptions) {
this.plugins.setLogger(hubPluginLogger);
@@ -473,6 +476,11 @@ export class HubServerTransport implements NativeHubTransport {
createTasksPromptExtension({ todoEnabled: AGENDA_TODO_TOOL_ENABLED }),
);
this.settings = options.settingsService ?? new CoreSettingsService();
this.unsubscribePluginCommands = (
this.sessionHost as RuntimeHost & Partial<PluginCommandsRuntimeService>
).pluginCommands?.subscribe((catalog) => {
this.publish(buildHubEvent("plugins.commands.changed", { catalog }));
});
if (options.cronOptions) {
this.cronService = new CronService({
runtimeHandlers: options.runtimeHandlers,
@@ -741,6 +749,8 @@ export class HubServerTransport implements NativeHubTransport {
}
async stop(): Promise<void> {
this.draining = true;
await Promise.allSettled(this.pendingPluginCommands);
this.unsubscribePluginStatus?.();
this.unsubscribePluginStatus = undefined;
if (this.eventLogPruneTimer) {
@@ -760,6 +770,7 @@ export class HubServerTransport implements NativeHubTransport {
);
await this.sessionSearch.dispose();
await this.tasks.dispose();
this.unsubscribePluginCommands?.();
await this.sessionHost.dispose("hub_server_stop");
await this.schedules.dispose();
if (this.cronService) {
@@ -868,8 +879,16 @@ export class HubServerTransport implements NativeHubTransport {
return await handleSessionMessages(this.ctx, envelope);
case "session.compaction.get":
return await handleSessionCompactionGet(this.ctx, envelope);
case "session.list":
return await handleSessionList(this.ctx, envelope);
case "session.list": {
const reply = await handleSessionList(this.ctx, envelope);
return {
...reply,
payload: {
...reply.payload,
activePluginRequests: this.pendingPluginCommands.size,
},
};
}
case "session.search":
return await handleSessionSearch(this.ctx, envelope);
case "session.update":
@@ -946,6 +965,60 @@ export class HubServerTransport implements NativeHubTransport {
case "ui.show_window":
this.publish(buildHubEvent("ui.show_window", envelope.payload ?? {}));
return okReply(envelope);
case "plugins.commands.list":
case "plugins.commands.run": {
const service = (
this.sessionHost as RuntimeHost &
Partial<PluginCommandsRuntimeService>
).pluginCommands;
if (!service)
return {
version: envelope.version,
requestId: envelope.requestId,
ok: false,
error: {
code: "plugin_commands_unavailable",
message: "This runtime does not support plugin commands",
},
};
const workspacePath =
typeof envelope.payload?.workspacePath === "string"
? envelope.payload.workspacePath
: authority?.workspaceContext?.workspaceRoot;
if (!workspacePath) throw new Error("workspacePath is required");
if (
authority &&
!authority.crossWorkspace &&
resolve(workspacePath) !==
resolve(authority.workspaceContext?.workspaceRoot ?? "")
)
throw new Error(
"Plugin workspace is outside the connection's authority",
);
const sessionId = envelope.sessionId;
const target = { workspacePath, ...(sessionId ? { sessionId } : {}) };
const request = Promise.resolve().then(async () =>
envelope.command === "plugins.commands.list"
? { catalog: await service.list(target) }
: {
result: await service.run({
...target,
prompt: String(envelope.payload?.prompt ?? ""),
}),
},
);
this.pendingPluginCommands.add(request);
try {
return {
version: envelope.version,
requestId: envelope.requestId,
ok: true,
payload: await request,
};
} finally {
this.pendingPluginCommands.delete(request);
}
}
case "settings.list":
return await this.handleSettingsList(envelope);
case "settings.toggle":
@@ -1146,12 +1219,16 @@ export class HubServerTransport implements NativeHubTransport {
hubId: this.hubId,
draining: this.draining,
activeRpcTurns,
activePluginRequests: this.pendingPluginCommands.size,
pendingRuns: this.runQueue?.countPending() ?? 0,
eventLog: this.eventLog
? { lastSequence: this.eventLog.lastSequence() }
: undefined,
// Idle = safe to stop: nothing executing and nothing accepted-but-unstarted.
idle: activeRpcTurns === 0 && (this.runQueue?.countPending() ?? 0) === 0,
idle:
activeRpcTurns === 0 &&
this.pendingPluginCommands.size === 0 &&
(this.runQueue?.countPending() ?? 0) === 0,
};
}
@@ -0,0 +1,371 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { HubCommandEnvelope, ITelemetryService } from "@cline/shared";
import { setClineDir, setHomeDir } from "@cline/shared/storage";
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 { createHubPluginCommandsApi } from "../client/plugin-commands";
import { createLocalHubScheduleRuntimeHandlers } from "../daemon/runtime-handlers";
import { HubServerTransport } from "./hub-server-transport";
let root: string;
let runtime: LocalRuntimeHost;
let transport: HubServerTransport;
let transportStopped = false;
const capture = vi.fn();
const telemetry = {
capture,
captureRequired: vi.fn(),
setDistinctId: vi.fn(),
updateCommonProperties: vi.fn(),
} as unknown as ITelemetryService;
const originalClineDir = process.env.CLINE_DIR;
const originalHome = process.env.HOME;
beforeEach(async () => {
transportStopped = false;
capture.mockClear();
root = await mkdtemp(join(tmpdir(), "hub-plugin-command-test-"));
setHomeDir(root);
setClineDir(join(root, ".cline"));
runtime = new LocalRuntimeHost({
sessionService: new FileSessionService(join(root, "sessions")),
telemetry,
});
transport = new HubServerTransport({
sessionHost: runtime,
workspaceRoot: root,
eventLog: false,
runQueue: false,
sessionSearchOptions: { dbPath: ":memory:" },
runtimeHandlers: createLocalHubScheduleRuntimeHandlers(),
});
const directory = join(root, ".cline", "plugins");
await mkdir(directory, { recursive: true });
await writeFile(
join(directory, "counter.js"),
`import {appendFileSync} from 'node:fs';
export default {name:'counter',manifest:{capabilities:['commands']},setup(api,ctx){
appendFileSync(${JSON.stringify(join(root, "setups.txt"))}, (ctx.session?.sessionId ?? 'workspace')+'\\n');
let counter=0;
api.registerCommand({name:'counter',handler:input=>({reply:(ctx.session?.sessionId ?? 'workspace')+':'+(++counter)+':'+input})});
}};`,
);
});
afterEach(async () => {
if (!transportStopped) await transport?.stop();
setHomeDir(originalHome ?? "~");
setClineDir(originalClineDir ?? join(originalHome ?? "~", ".cline"));
await rm(root, { recursive: true, force: true });
});
function command(
command: HubCommandEnvelope["command"],
payload: Record<string, unknown>,
sessionId?: string,
) {
return transport.handleCommand({
version: "v1",
requestId: Math.random().toString(),
clientId: "test",
command,
payload,
sessionId,
});
}
describe("hub-owned plugin commands", () => {
it("records workspace command usage once through direct and RPC execution", async () => {
const target = { workspacePath: root };
await runtime.pluginCommands.run({ ...target, prompt: "/counter direct" });
await command("plugins.commands.run", {
...target,
prompt: "/counter rpc",
});
await runtime.pluginCommands.run({ ...target, prompt: "/unmatched" });
const events = capture.mock.calls
.map(([event]) => event)
.filter((event) => event.event === "task.tool_used");
expect(events).toHaveLength(2);
for (const event of events)
expect(event.properties).toMatchObject({
tool: "plugin_command:counter",
success: true,
ulid: expect.any(String),
});
});
it("records failed plugin handlers without changing their error", async () => {
await writeFile(
join(root, ".cline", "plugins", "failure.js"),
`export default {name:'failure',manifest:{capabilities:['commands']},setup(api){api.registerCommand({name:'fail',handler(){throw new Error('handler failed')}})}}`,
);
await expect(
runtime.pluginCommands.run({ workspacePath: root, prompt: "/fail" }),
).rejects.toThrow("handler failed");
const events = capture.mock.calls
.map(([event]) => event)
.filter((event) => event.event === "task.tool_used");
expect(events).toHaveLength(1);
expect(events[0].properties).toMatchObject({
tool: "plugin_command:fail",
success: false,
});
});
it("protects active commands from drain, replacement, and shutdown without a chat participant", async () => {
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
const run = vi
.spyOn(runtime.pluginCommands, "run")
.mockImplementation(async () => {
await gate;
return { reply: "finished" };
});
const pending = command("plugins.commands.run", {
workspacePath: root,
prompt: "/slow",
});
await vi.waitFor(() => expect(run).toHaveBeenCalled());
expect((await command("hub.status", {})).payload).toMatchObject({
idle: false,
activePluginRequests: 1,
});
expect((await command("session.list", {})).payload).toMatchObject({
activePluginRequests: 1,
});
await command("hub.drain", {});
expect(
await command("plugins.commands.list", { workspacePath: root }),
).toMatchObject({ ok: false, error: { code: "hub_draining" } });
let stopped = false;
const stop = transport.stop().then(() => {
stopped = true;
});
await Promise.resolve();
expect(stopped).toBe(false);
release();
expect(await pending).toMatchObject({
ok: true,
payload: { result: { reply: "finished" } },
});
await stop;
transportStopped = true;
expect(stopped).toBe(true);
});
it("recovers a failed sandbox plugin without restarting its healthy sibling", async () => {
const readyPath = join(root, "allow-recovery");
await writeFile(
join(root, ".cline", "plugins", "retry.js"),
`import {existsSync} from 'node:fs';
export default {name:'retry',manifest:{capabilities:['commands']},setup(api){
if (!existsSync(${JSON.stringify(readyPath)})) throw new Error('not ready');
api.registerCommand({name:'recovered',handler:()=> 'ready'});
}};`,
);
const target = { workspacePath: root };
await runtime.pluginCommands.list(target);
expect(
await runtime.pluginCommands.run({ ...target, prompt: "/counter first" }),
).toMatchObject({ reply: "workspace:1:first" });
const updates = vi.fn();
const stop = runtime.pluginCommands.subscribe(updates);
await vi.waitFor(
() =>
expect(updates).toHaveBeenCalledWith(
expect.objectContaining({ status: "error" }),
),
{ timeout: 5000 },
);
expect(
await runtime.pluginCommands.run({
...target,
prompt: "/counter second",
}),
).toMatchObject({ reply: "workspace:2:second" });
await writeFile(readyPath, "ready");
await vi.waitFor(
() =>
expect(updates).toHaveBeenLastCalledWith(
expect.objectContaining({ status: "ready" }),
),
{ timeout: 7000 },
);
expect(
await runtime.pluginCommands.run({ ...target, prompt: "/recovered" }),
).toMatchObject({ reply: "ready" });
expect(
await runtime.pluginCommands.run({ ...target, prompt: "/counter third" }),
).toMatchObject({ reply: "workspace:3:third" });
expect((await readFile(join(root, "setups.txt"), "utf8")).trim()).toBe(
"workspace",
);
stop();
});
it("keeps healthy commands available while a sibling plugin fails", async () => {
const broken = join(root, ".cline", "plugins", "broken.js");
await writeFile(
broken,
`export default {name:'broken',manifest:{capabilities:['commands']},setup(){throw new Error('broken setup');}};`,
);
const catalog = await runtime.pluginCommands.list({ workspacePath: root });
expect(catalog).toMatchObject({
status: "error",
commands: [{ name: "counter" }],
error: expect.stringContaining("broken setup"),
});
expect(
await runtime.pluginCommands.run({
workspacePath: root,
prompt: "/counter healthy",
}),
).toMatchObject({ reply: "workspace:1:healthy" });
const updates = vi.fn();
const stop = runtime.pluginCommands.subscribe(updates);
await rm(broken);
await vi.waitFor(
() =>
expect(updates).toHaveBeenLastCalledWith(
expect.objectContaining({
status: "ready",
commands: [{ name: "counter" }],
}),
),
{ timeout: 5000 },
);
stop();
});
it("delivers invalidations and current commands through the shared client API", async () => {
const api = createHubPluginCommandsApi({
command: (name, payload, sessionId) =>
command(name, payload ?? {}, sessionId),
subscribe: (listener) => transport.subscribe("observer", listener),
});
const updates = vi.fn();
const stop = api.subscribe(updates);
await api.list({ workspacePath: root });
await writeFile(
join(root, ".cline", "plugins", "extra.js"),
`export default {name:'extra',manifest:{capabilities:['commands']},setup(api){api.registerCommand({name:'extra',handler:()=> 'new command'});}};`,
);
await vi.waitFor(
() =>
expect(updates).toHaveBeenLastCalledWith(
expect.objectContaining({
commands: expect.arrayContaining([{ name: "extra" }]),
}),
),
{ timeout: 5000 },
);
expect(await api.run({ workspacePath: root, prompt: "/extra" })).toEqual({
reply: "new command",
submitPrompt: undefined,
});
stop();
});
it("shares a workspace sandbox across client requests and preserves arguments", async () => {
const first = await command("plugins.commands.list", {
workspacePath: root,
});
const second = await command("plugins.commands.list", {
workspacePath: root,
});
expect(first.payload).toEqual(second.payload);
expect(first.payload?.catalog).toMatchObject({
status: "ready",
commands: [{ name: "counter" }],
});
expect(
(
await command("plugins.commands.run", {
workspacePath: root,
prompt: "/counter a b\nc",
})
).payload?.result,
).toMatchObject({ reply: "workspace:1:a b\nc" });
expect(
(
await command("plugins.commands.run", {
workspacePath: root,
prompt: "/counter again",
})
).payload?.result,
).toMatchObject({ reply: "workspace:2:again" });
expect((await readFile(join(root, "setups.txt"), "utf8")).trim()).toBe(
"workspace",
);
});
it("executes against the existing session plugin instance and retains its setup context", async () => {
const session = await runtime.startSession({
...splitCoreSessionConfig({
cwd: root,
workspaceRoot: root,
providerId: "openai-native",
modelId: "gpt-4o-mini",
apiKey: "test",
systemPrompt: "test",
enableTools: false,
enableSpawnAgent: false,
enableAgentTeams: false,
}),
interactive: true,
});
const target = { workspacePath: root };
const catalog = await command(
"plugins.commands.list",
target,
session.sessionId,
);
expect(catalog.payload?.catalog).toMatchObject({
sessionId: session.sessionId,
commands: [{ name: "counter" }],
});
const result = await command(
"plugins.commands.run",
{ ...target, prompt: "/counter test" },
session.sessionId,
);
expect(result.payload?.result).toMatchObject({
reply: `${session.sessionId}:1:test`,
});
await runtime.pluginCommands.run({
...target,
sessionId: session.sessionId,
prompt: "/counter direct",
});
const events = capture.mock.calls
.map(([event]) => event)
.filter((event) => event.event === "task.tool_used");
expect(events).toHaveLength(2);
for (const event of events)
expect(event.properties).toMatchObject({
ulid: session.sessionId,
tool: "plugin_command:counter",
success: true,
provider: "openai-native",
modelId: "gpt-4o-mini",
agentId: expect.any(String),
});
expect((await readFile(join(root, "setups.txt"), "utf8")).trim()).toBe(
session.sessionId,
);
});
it("rejects a workspace outside the authenticated connection scope", async () => {
await expect(
transport.handleCommand(
{
version: "v1",
clientId: "limited",
command: "plugins.commands.list",
payload: { workspacePath: root },
},
{
clientId: "limited",
workspaceContext: { workspaceRoot: join(root, "other") },
},
),
).rejects.toThrow("authority");
});
});
+7 -4
View File
@@ -670,14 +670,16 @@ export {
uninstallMcpServer,
} from "./services/mcp-install";
export type {
PluginCommandCatalog,
PluginCommandResult,
PluginCommandService,
PluginCommandsApi,
PluginCommandTarget,
PluginSlashCommand,
} from "./services/plugin-commands";
} from "./services/plugin-command-api";
export {
createPluginCommandService,
normalizePluginCommandName,
} from "./services/plugin-commands";
parsePluginCommand,
} from "./services/plugin-command-api";
export type {
ParsedPluginSource,
PluginInstallOptions,
@@ -1034,6 +1036,7 @@ export {
ToolPresets,
truncateCommandOutput,
} from "./extensions/tools";
export { createHubPluginCommandsApi } from "./hub/client/plugin-commands";
export * from "./remote/remote-environments";
export { ensureLoginShellPath } from "./remote/shell-path";
export { isClineAccountFeatureEnabled } from "./services/feature-flags/cline-account-feature-flags";
@@ -33,6 +33,12 @@ import type { HookEventPayload } from "../../hooks";
import { buildTelemetryAgentIdentity } from "../../services/agent-events";
import { resolveWorkspacePath } from "../../services/config";
import { prepareLocalRuntimeBootstrap } from "../../services/local-runtime-bootstrap";
import {
executePluginCommand,
listPluginCommands,
type PluginCommandsApi,
} from "../../services/plugin-command-api";
import { PluginCommandManager } from "../../services/plugin-commands";
import { nowIso } from "../../services/session-artifacts";
import {
toSessionRecord,
@@ -273,6 +279,8 @@ function isSessionDirectedPluginEvent(name: string): boolean {
export class LocalRuntimeHost implements RuntimeHost {
public readonly runtimeAddress = undefined;
public readonly pendingPrompts: PendingPromptsServiceApi;
public readonly pluginCommands: PluginCommandsApi;
private readonly pluginCommandManager: PluginCommandManager;
private readonly sessionService: SessionBackend;
private readonly runtimeBuilder: RuntimeBuilder;
private readonly createAgentInstance: (config: AgentConfig) => SessionRuntime;
@@ -326,6 +334,60 @@ export class LocalRuntimeHost implements RuntimeHost {
});
this.defaultTelemetry = options.telemetry;
this.defaultLogger = options.logger;
this.pluginCommandManager = new PluginCommandManager({
logger: options.logger,
telemetry: options.telemetry,
});
this.pluginCommands = {
list: async (target) => {
if (!target.sessionId) return this.pluginCommandManager.list(target);
const session = this.sessions.get(target.sessionId);
if (!session) return this.pluginCommandManager.list(target);
if (
resolve(target.workspacePath) !==
resolve(resolveWorkspacePath(session.config))
)
throw new Error(
"Plugin command workspace does not match the session",
);
return this.withSessionPluginCommands(session, () => ({
workspacePath: resolveWorkspacePath(session.config),
sessionId: target.sessionId,
status: session.pluginCommandError
? ("error" as const)
: ("ready" as const),
error: session.pluginCommandError,
commands: listPluginCommands(
session.agent.getExtensionRegistry().commands,
),
}));
},
run: async (input) => {
if (!input.sessionId) return this.pluginCommandManager.run(input);
const session = this.getSessionOrThrow(input.sessionId);
if (
resolve(input.workspacePath) !==
resolve(resolveWorkspacePath(session.config))
)
throw new Error(
"Plugin command workspace does not match the session",
);
return this.withSessionPluginCommands(session, () =>
executePluginCommand(
session.agent.getExtensionRegistry().commands,
input.prompt,
{
telemetry: session.config.telemetry ?? this.defaultTelemetry,
sessionId: session.sessionId,
modelId: session.config.modelId,
provider: session.config.providerId,
agentIdentity: this.getSessionAgentTelemetryIdentity(session),
},
),
);
},
subscribe: this.pluginCommandManager.subscribe,
};
// A caller-owned telemetry service may already be identified to an
// authenticated account (the long-lived Hub daemon is one example).
// Only replace that identity when the caller explicitly supplied the
@@ -976,6 +1038,7 @@ export class LocalRuntimeHost implements RuntimeHost {
pendingPrompts: [],
drainingPendingPrompts: false,
pluginSandboxShutdown: bootstrap.pluginSandboxShutdown,
pluginCommandError: bootstrap.pluginCommandError,
submitAndExitObserved: false,
taskCompletedEmitted: false,
lastInteractiveTurnFinishReason: undefined,
@@ -1296,6 +1359,7 @@ export class LocalRuntimeHost implements RuntimeHost {
async dispose(reason = "session_manager_dispose"): Promise<void> {
const sessions = [...this.sessions.values()];
await this.pluginCommandManager.dispose();
if (sessions.length === 0) return;
await Promise.allSettled(
sessions.map((session) =>
@@ -2393,6 +2457,7 @@ export class LocalRuntimeHost implements RuntimeHost {
// not observe an explicit `submit_and_exit` tool call, routed through
// the shared teardown choke point so it can neither double-fire nor
// be skipped by teardown routing.
session.pluginCommandsClosing = true;
this.emitTaskCompletedOnTeardown(session, input.status);
notifyTeamRunWaiters(session);
@@ -2443,6 +2508,7 @@ export class LocalRuntimeHost implements RuntimeHost {
}
}
try {
await session.pluginCommandTail;
await session.agent.shutdown(input.shutdownReason);
} catch (error) {
recordCleanupError("agent_shutdown", error);
@@ -2480,6 +2546,7 @@ export class LocalRuntimeHost implements RuntimeHost {
// this branch. The completion emission must happen here as well —
// this is the branch that silently dropped `task.completed` when
// truthful status reporting re-routed interactive stops onto it.
session.pluginCommandsClosing = true;
this.emitTaskCompletedOnTeardown(session);
const cleanupErrors: unknown[] = [];
const recordCleanupError = (stage: string, error: unknown) => {
@@ -2520,6 +2587,7 @@ export class LocalRuntimeHost implements RuntimeHost {
session.agent.abort(new Error(reason));
}
try {
await session.pluginCommandTail;
await session.agent.shutdown(reason);
} catch (error) {
recordCleanupError("agent_shutdown", error);
@@ -2627,6 +2695,24 @@ export class LocalRuntimeHost implements RuntimeHost {
// ── Utility methods ─────────────────────────────────────────────────
private withSessionPluginCommands<T>(
session: ActiveSession,
operation: () => T | Promise<T>,
): Promise<T> {
if (session.pluginCommandsClosing)
return Promise.reject(
new Error("Session plugin commands are shutting down"),
);
const execution = (session.pluginCommandTail ?? Promise.resolve()).then(
async () => {
await session.agent.initializeExtensions();
return operation();
},
);
session.pluginCommandTail = execution.catch(() => {});
return execution;
}
private getSessionOrThrow(sessionId: string): ActiveSession {
const session = this.sessions.get(sessionId);
if (!session) {
@@ -909,7 +909,7 @@ export class SessionRuntime {
.slice(2, 8)}`;
// Lazily initialize contribution-registry extensions on the
// first run, before runtime construction.
await this.ensureExtensionsInitialized();
await this.initializeExtensions();
this.eventAdapter.reset();
this.currentRunToolCalls = [];
this.currentRunUsage = { inputTokens: 0, outputTokens: 0 };
@@ -1127,6 +1127,16 @@ export class SessionRuntime {
* Idempotent: subsequent calls are no-ops once the registry has
* been activated.
*/
private extensionInitialization?: Promise<void>;
async initializeExtensions(): Promise<void> {
this.extensionInitialization ??= this.ensureExtensionsInitialized().catch(
(error) => {
this.extensionInitialization = undefined;
throw error;
},
);
return this.extensionInitialization;
}
private async ensureExtensionsInitialized(): Promise<void> {
if (this.extensionsInitialized) {
return;
@@ -321,6 +321,7 @@ export interface LocalRuntimeBootstrap {
pluginSandboxShutdown?: () => Promise<void>;
/** Plugins the session asked for but does not have (failed or disabled). */
pluginIssues: SessionPluginIssue[];
pluginCommandError?: string;
runtimeBuilderInput: RuntimeBuilderInput;
}
@@ -453,6 +454,7 @@ export async function prepareLocalRuntimeBootstrap(
});
const baseHooks = mergeAgentHooks([localConfig?.hooks, auditHooks]);
let pluginCommandError: string | undefined;
let loadedPlugins:
| Awaited<ReturnType<typeof resolveAndLoadAgentPlugins>>
| undefined;
@@ -478,6 +480,9 @@ export async function prepareLocalRuntimeBootstrap(
onIssue: onPluginIssue,
});
pluginIssues.push(...(loadedPlugins.issues ?? []));
pluginCommandError = loadedPlugins.failures.length
? loadedPlugins.failures.map((f) => f.message).join("; ")
: undefined;
logPluginDiagnostics(
loadedPlugins.failures,
loadedPlugins.warnings,
@@ -492,6 +497,7 @@ export async function prepareLocalRuntimeBootstrap(
`Plugin loading failed; the session has no plugins: ${message}`,
{ severity: "error", sessionId },
);
pluginCommandError = message;
pluginIssues.push({
name: "plugins",
pluginPath: workspacePath,
@@ -627,6 +633,7 @@ export async function prepareLocalRuntimeBootstrap(
requestToolApproval,
pluginSandboxShutdown: loadedPlugins?.shutdown,
pluginIssues,
pluginCommandError,
runtimeBuilderInput: {
config,
hooks,
@@ -0,0 +1,113 @@
import type {
AgentExtensionCommand,
AgentExtensionCommandResult,
ITelemetryService,
} from "@cline/shared";
import { createSessionId } from "@cline/shared";
import {
captureToolUsage,
type TelemetryAgentIdentityProperties,
} from "./telemetry/core-events";
export interface PluginSlashCommand {
name: string;
description?: string;
}
export interface PluginCommandResult {
reply?: string;
submitPrompt?: string;
}
export interface PluginCommandTarget {
workspacePath: string;
/** Use the resident session's plugins, including their session-local state. */
sessionId?: string;
}
export interface PluginCommandCatalog extends PluginCommandTarget {
status: "ready" | "error";
commands: PluginSlashCommand[];
error?: string;
}
/** Owned by the runtime. Clients neither load plugins nor own their sandboxes. */
export interface PluginCommandsApi {
list(target: PluginCommandTarget): Promise<PluginCommandCatalog>;
run(
input: PluginCommandTarget & { prompt: string },
): Promise<PluginCommandResult | undefined>;
subscribe(listener: (catalog: PluginCommandCatalog) => void): () => void;
}
export interface PluginCommandsRuntimeService {
readonly pluginCommands: PluginCommandsApi;
}
export function normalizePluginCommandName(name: string): string {
return name.trim().replace(/^\/+/, "").toLowerCase();
}
export function parsePluginCommand(
input: string,
): { name: string; input: string } | undefined {
const match = input.trimStart().match(/^\/(\S+)([\s\S]*)$/);
return match
? { name: normalizePluginCommandName(match[1]), input: match[2].trim() }
: undefined;
}
export function normalizePluginCommandResult(
result: AgentExtensionCommandResult | undefined,
): PluginCommandResult {
const value = typeof result === "string" ? { reply: result } : result;
return {
reply: value?.reply?.trim() || undefined,
submitPrompt: value?.submitPrompt?.trim() || undefined,
};
}
export function listPluginCommands(
commands: readonly AgentExtensionCommand[],
): PluginSlashCommand[] {
const result = new Map<string, PluginSlashCommand>();
for (const command of commands) {
const name = normalizePluginCommandName(command.name);
if (!name || /\s/.test(name) || !command.handler || result.has(name))
continue;
result.set(name, { name, description: command.description });
}
return [...result.values()];
}
export async function executePluginCommand(
commands: readonly AgentExtensionCommand[],
prompt: string,
context: {
telemetry?: ITelemetryService;
sessionId?: string;
modelId?: string;
provider?: string;
agentIdentity?: TelemetryAgentIdentityProperties;
} = {},
): Promise<PluginCommandResult | undefined> {
const parsed = parsePluginCommand(prompt);
if (!parsed) return undefined;
const command = commands.find(
(command) =>
normalizePluginCommandName(command.name) === parsed.name &&
command.handler,
);
if (!command?.handler) return undefined;
let success = false;
try {
const result = normalizePluginCommandResult(
await command.handler(parsed.input),
);
success = true;
return result;
} finally {
try {
captureToolUsage(context.telemetry, {
ulid: context.sessionId ?? createSessionId(),
tool: `plugin_command:${parsed.name}`,
modelId: context.modelId,
provider: context.provider,
success,
...context.agentIdentity,
});
} catch {
// Telemetry must never replace a completed action's result or original error.
}
}
}
@@ -1,156 +1,325 @@
import { mkdir, mkdtemp, rm, utimes, writeFile } from "node:fs/promises";
import { EventEmitter } from "node:events";
import type { watch } from "node:fs";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createPluginCommandService } from "./plugin-commands";
import type {
AgentExtensionApi,
AgentTool,
ITelemetryService,
Message,
} from "@cline/shared";
import { setClineDir, setHomeDir } from "@cline/shared/storage";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { executePluginCommand, parsePluginCommand } from "./plugin-command-api";
import { PluginCommandManager } from "./plugin-commands";
describe("createPluginCommandService", () => {
const tempRoots: string[] = [];
const shutdowns: Array<() => Promise<void>> = [];
afterEach(async () => {
await Promise.all(shutdowns.map((shutdown) => shutdown()));
shutdowns.length = 0;
await Promise.all(
tempRoots.map((dir) => rm(dir, { recursive: true, force: true })),
);
tempRoots.length = 0;
});
async function createWorkspace(): Promise<{
workspacePath: string;
pluginPath: string;
}> {
const workspacePath = await mkdtemp(join(tmpdir(), "plugin-commands-"));
tempRoots.push(workspacePath);
const pluginsDir = join(workspacePath, ".cline", "plugins");
await mkdir(pluginsDir, { recursive: true });
const pluginPath = join(pluginsDir, "goalish.js");
await writeFile(
pluginPath,
[
"export default {",
" name: 'goalish-plugin',",
" manifest: { capabilities: ['commands'] },",
" setup(api) {",
" api.registerCommand({",
" name: 'Goalish',",
" description: 'Set a goal',",
" handler: async (input) => {",
" const trimmed = input.trim();",
" if (!trimmed || trimmed === 'status') return 'No goal is active.';",
" return { reply: 'Goal set: ' + trimmed, submitPrompt: trimmed };",
" },",
" });",
" },",
"};",
].join("\n"),
);
return { workspacePath, pluginPath };
}
it("lists normalized commands and runs handlers", async () => {
const { workspacePath } = await createWorkspace();
const service = createPluginCommandService({ cwd: workspacePath });
shutdowns.push(service.shutdown);
const commands = await service.listCommands();
expect(commands).toContainEqual({
name: "goalish",
description: "Set a goal",
});
await expect(service.run("/Goalish", " status ")).resolves.toEqual({
reply: "No goal is active.",
submitPrompt: undefined,
});
await expect(service.run("goalish", "fix the tests")).resolves.toEqual({
reply: "Goal set: fix the tests",
submitPrompt: "fix the tests",
});
await expect(service.run("not-a-command", "")).resolves.toBeUndefined();
});
it("continues without commands when a plugin fails to load, retrying only when plugins change", async () => {
const { workspacePath, pluginPath } = await createWorkspace();
await writeFile(
pluginPath,
"export default { name: 'broken', manifest: { capabilities: ['bogus'] }, setup() {} };",
);
const errors: string[] = [];
const service = createPluginCommandService({
cwd: workspacePath,
logger: {
debug: () => {},
log: () => {},
error: (message) => errors.push(message),
const originalHome = process.env.HOME;
const originalClineDir = process.env.CLINE_DIR;
const roots: string[] = [];
beforeEach(async () => {
const home = await workspace();
setHomeDir(home);
setClineDir(join(home, ".cline"));
});
const managers: PluginCommandManager[] = [];
afterEach(async () => {
await Promise.all(managers.splice(0).map((m) => m.dispose()));
setHomeDir(originalHome ?? "~");
setClineDir(originalClineDir ?? join(originalHome ?? "~", ".cline"));
await Promise.all(
roots.splice(0).map((r) => rm(r, { recursive: true, force: true })),
);
});
async function workspace() {
const p = await mkdtemp(join(tmpdir(), "plugin-catalog-"));
roots.push(p);
return p;
}
function loaded(
handler: (input: string) => Promise<string> = async (input) =>
`echo:${input}`,
) {
return {
extensions: [
{
name: "echo-plugin",
manifest: { capabilities: ["commands" as const] },
setup(api: AgentExtensionApi<AgentTool, Message[]>) {
api.registerCommand({ name: "Echo", description: "Echo", handler });
},
},
],
pluginPaths: [],
failures: [],
warnings: [],
issues: [],
shutdown: vi.fn(async () => {}),
};
}
describe("runtime plugin catalogs", () => {
it("shares initialization across callers and preserves arguments", async () => {
const workspacePath = await workspace();
const load = vi.fn(async () => loaded());
const manager = new PluginCommandManager({ load });
managers.push(manager);
const [a, b] = await Promise.all([
manager.list({ workspacePath }),
manager.list({ workspacePath }),
]);
expect(a).toEqual(b);
expect(load).toHaveBeenCalledTimes(1);
expect(a.commands).toEqual([{ name: "echo", description: "Echo" }]);
await expect(
manager.run({
workspacePath,
prompt: "/Echo first line\n second line",
}),
).resolves.toMatchObject({ reply: "echo:first line\n second line" });
await expect(
manager.run({ workspacePath, prompt: "/unknown" }),
).resolves.toBeUndefined();
});
it("retries only failed paths while retaining healthy command state", async () => {
const workspacePath = await workspace();
const brokenPath = join(workspacePath, "broken.js");
const failures = [
{ pluginPath: brokenPath, phase: "setup" as const, message: "broken" },
];
let counter = 0;
let recover = false;
const healthy = loaded(async () => String(++counter));
const recovered = loaded();
recovered.extensions[0].setup = (api) =>
api.registerCommand({ name: "fixed", handler: () => "recovered" });
const load = vi.fn(async (_options: { pluginPaths: string[] }) => {
if (load.mock.calls.length === 1) return { ...healthy, failures };
return recover ? recovered : { ...loaded(), extensions: [], failures };
});
shutdowns.push(service.shutdown);
await expect(service.listCommands()).resolves.toEqual([]);
await expect(service.run("goalish", "status")).resolves.toBeUndefined();
expect(errors).toHaveLength(1);
// The failure may have been transient, so it is retried after a delay.
const realNow = Date.now;
const nowSpy = vi
.spyOn(Date, "now")
.mockImplementation(() => realNow() + 60_000);
try {
await expect(service.run("goalish", "status")).resolves.toBeUndefined();
expect(errors).toHaveLength(2);
} finally {
nowSpy.mockRestore();
}
await writeFile(
pluginPath,
[
"export default {",
" name: 'goalish-plugin',",
" manifest: { capabilities: ['commands'] },",
" setup(api) {",
" api.registerCommand({ name: 'goalish', handler: () => { throw new Error('handler boom'); } });",
" },",
"};",
].join("\n"),
const manager = new PluginCommandManager({ load, retryDelayMs: 10 });
managers.push(manager);
const updates = vi.fn();
manager.subscribe(updates);
await manager.list({ workspacePath });
expect(await manager.run({ workspacePath, prompt: "/echo" })).toMatchObject(
{ reply: "1" },
);
const later = new Date(Date.now() + 5_000);
await utimes(pluginPath, later, later);
// Handler failures are the plugin's own errors and still surface.
await expect(service.run("goalish", "status")).rejects.toThrow(
"handler boom",
await vi.waitFor(
() => expect(load.mock.calls.length).toBeGreaterThanOrEqual(3),
{ timeout: 5000 },
);
expect(healthy.shutdown).not.toHaveBeenCalled();
expect(await manager.run({ workspacePath, prompt: "/echo" })).toMatchObject(
{ reply: "2" },
);
for (const [options] of load.mock.calls.slice(1))
expect(options.pluginPaths).toEqual([brokenPath]);
recover = true;
await vi.waitFor(
() =>
expect(updates).toHaveBeenLastCalledWith(
expect.objectContaining({ status: "ready" }),
),
{ timeout: 5000 },
);
expect(
await manager.run({ workspacePath, prompt: "/fixed" }),
).toMatchObject({ reply: "recovered" });
expect(await manager.run({ workspacePath, prompt: "/echo" })).toMatchObject(
{ reply: "3" },
);
expect(healthy.shutdown).not.toHaveBeenCalled();
});
it("recovers from initialization timeout without another client request", async () => {
const workspacePath = await workspace();
const load = vi
.fn()
.mockRejectedValueOnce(new Error("plugin-sandbox initialize timed out"))
.mockResolvedValue(loaded());
const manager = new PluginCommandManager({ load, retryDelayMs: 10 });
managers.push(manager);
const updates = vi.fn();
manager.subscribe(updates);
expect(await manager.list({ workspacePath })).toMatchObject({
status: "error",
commands: [],
error: expect.stringContaining("timed out"),
});
await vi.waitFor(
() =>
expect(updates).toHaveBeenLastCalledWith(
expect.objectContaining({
status: "ready",
commands: [{ name: "echo", description: "Echo" }],
}),
),
{ timeout: 5000 },
);
await expect(
manager.run({ workspacePath, prompt: "/echo recovered" }),
).resolves.toMatchObject({ reply: "echo:recovered" });
});
it("watches newly created plugin directories in non-Git workspaces", async () => {
const workspacePath = await workspace();
const load = vi.fn(async () => loaded());
const manager = new PluginCommandManager({ load });
managers.push(manager);
await manager.list({ workspacePath });
const directory = join(workspacePath, ".cline", "plugins");
await mkdir(directory, { recursive: true });
await writeFile(join(directory, "echo.js"), "export default {};");
await vi.waitFor(() => expect(load.mock.calls.length).toBeGreaterThan(1), {
timeout: 3000,
});
const calls = load.mock.calls.length;
await writeFile(
join(directory, "echo.js"),
"export default {name:'changed'};",
);
await vi.waitFor(
() => expect(load.mock.calls.length).toBeGreaterThan(calls),
{ timeout: 3000 },
);
});
it("reloads plugins when the plugin set changes", async () => {
const { workspacePath, pluginPath } = await createWorkspace();
const service = createPluginCommandService({ cwd: workspacePath });
shutdowns.push(service.shutdown);
await expect(service.run("goalish", "status")).resolves.toEqual({
reply: "No goal is active.",
submitPrompt: undefined,
it.each([
"add",
"edit",
"remove",
"unchanged",
"watch-throw",
])("reconciles %s during watcher outages without resetting unchanged plugins", async (change) => {
const workspacePath = await workspace();
const directory = join(workspacePath, ".cline", "plugins");
await mkdir(directory, { recursive: true });
const file = join(directory, "echo.js");
if (change !== "add") await writeFile(file, "before");
const watchers: EventEmitter[] = [];
let unavailable = change === "watch-throw";
const watchFiles = vi.fn(() => {
if (unavailable) throw new Error("watch unavailable");
const watcher = Object.assign(new EventEmitter(), { close: vi.fn() });
watchers.push(watcher);
return watcher;
});
await writeFile(
pluginPath,
[
"export default {",
" name: 'goalish-plugin',",
" manifest: { capabilities: ['commands'] },",
" setup(api) {",
" api.registerCommand({ name: 'goalish', handler: () => 'reloaded' });",
" },",
"};",
].join("\n"),
const initial = loaded();
const load = vi.fn(async () => initial);
const manager = new PluginCommandManager({
load,
watch: watchFiles as unknown as typeof watch,
});
managers.push(manager);
const updates = vi.fn();
manager.subscribe(updates);
await manager.list({ workspacePath });
const attempts = watchFiles.mock.calls.length;
watchers[0]?.emit("error", new Error("watch lost"));
if (change === "remove") await rm(file);
else if (change !== "unchanged") await writeFile(file, "after");
unavailable = false;
await vi.waitFor(
() => expect(watchFiles.mock.calls.length).toBeGreaterThan(attempts),
{ timeout: 3000 },
);
const later = new Date(Date.now() + 5_000);
await utimes(pluginPath, later, later);
await expect(service.run("goalish", "status")).resolves.toEqual({
reply: "reloaded",
submitPrompt: undefined,
if (change === "unchanged") {
expect(load).toHaveBeenCalledTimes(1);
expect(initial.shutdown).not.toHaveBeenCalled();
} else {
await vi.waitFor(() => expect(updates).toHaveBeenCalledTimes(2));
expect(load).toHaveBeenCalledTimes(2);
expect(initial.shutdown).toHaveBeenCalledTimes(1);
}
});
it("waits for active handlers before disposing the sandbox", async () => {
const workspacePath = await workspace();
let finish!: (result: string) => void;
const handler = vi.fn(
() =>
new Promise<string>((resolve) => {
finish = resolve;
}),
);
const plugins = loaded(handler);
const manager = new PluginCommandManager({ load: async () => plugins });
managers.push(manager);
await manager.list({ workspacePath });
const run = manager.run({ workspacePath, prompt: "/echo" });
await vi.waitFor(() => expect(handler).toHaveBeenCalled());
const dispose = manager.dispose();
expect(plugins.shutdown).not.toHaveBeenCalled();
finish("done");
await run;
await dispose;
expect(plugins.shutdown).toHaveBeenCalledTimes(1);
await expect(manager.list({ workspacePath })).rejects.toThrow("disposed");
});
it("isolates workspaces and propagates handler errors", async () => {
const a = await workspace();
const b = await workspace();
const load = vi.fn(async (options?: { cwd?: string }) =>
loaded(async () => options?.cwd ?? ""),
);
const manager = new PluginCommandManager({ load });
managers.push(manager);
await manager.list({ workspacePath: a });
await manager.list({ workspacePath: b });
expect(load).toHaveBeenCalledTimes(2);
await expect(
manager.run({ workspacePath: a, prompt: "/echo" }),
).resolves.toMatchObject({ reply: a });
await expect(
executePluginCommand(
[
{
name: "bad",
handler: () => {
throw new Error("handler boom");
},
},
],
"/bad",
),
).rejects.toThrow("handler boom");
});
it("parses slash input without changing internal whitespace", () => {
expect(parsePluginCommand(" \n/GOAL a b\nc ")).toEqual({
name: "goal",
input: "a b\nc",
});
expect(parsePluginCommand("ordinary text")).toBeUndefined();
});
it("preserves a successful command result when telemetry throws", async () => {
const handler = vi.fn(() => "completed action");
const telemetry = {
capture: vi.fn(() => {
throw new Error("telemetry failed");
}),
} as unknown as ITelemetryService;
await expect(
executePluginCommand([{ name: "action", handler }], "/action", {
telemetry,
}),
).resolves.toEqual({ reply: "completed action", submitPrompt: undefined });
expect(handler).toHaveBeenCalledTimes(1);
expect(telemetry.capture).toHaveBeenCalledTimes(1);
});
it("preserves the original handler failure when telemetry also throws", async () => {
const failure = new Error("action failed");
const handler = vi.fn(() => {
throw failure;
});
const telemetry = {
capture: vi.fn(() => {
throw new Error("telemetry failed");
}),
} as unknown as ITelemetryService;
await expect(
executePluginCommand([{ name: "action", handler }], "/action", {
telemetry,
}),
).rejects.toBe(failure);
expect(handler).toHaveBeenCalledTimes(1);
expect(telemetry.capture).toHaveBeenCalledTimes(1);
});
});
+323 -142
View File
@@ -1,160 +1,341 @@
import { createHash } from "node:crypto";
import { existsSync, type FSWatcher, statSync, watch } from "node:fs";
import { readdir, readFile, realpath, stat } from "node:fs/promises";
import { dirname, resolve, sep } from "node:path";
import {
type AgentExtensionCommand,
type AgentExtensionCommandResult,
type AgentTool,
type BasicLogger,
createContributionRegistry,
type ITelemetryService,
type Message,
} from "@cline/shared";
import { resolveGlobalSettingsPath } from "@cline/shared/storage";
import {
loadResolvedAgentPlugins,
resolveAgentPluginPaths,
resolveAndLoadAgentPlugins,
resolvePluginConfigSearchPaths,
} from "../extensions/plugin/plugin-config-loader";
import { fingerprintPluginSources } from "../extensions/plugin/plugin-module-import";
import { PluginRegistry } from "../extensions/plugin/plugin-registry";
import {
executePluginCommand,
listPluginCommands,
type PluginCommandCatalog,
type PluginCommandsApi,
type PluginCommandTarget,
} from "./plugin-command-api";
export interface PluginSlashCommand {
/** Normalized token: lowercase, no leading slash. */
name: string;
description?: string;
}
export interface PluginCommandResult {
reply?: string;
submitPrompt?: string;
}
/**
* Executes plugin-registered slash commands (`api.registerCommand`) for one
* workspace. Plugins are loaded on first use and kept alive so repeated
* commands answer instantly; the loaded set is refreshed when plugin module
* paths or mtimes change (installs, updates, toggles).
*/
export interface PluginCommandService {
listCommands(): Promise<PluginSlashCommand[]>;
/** Runs `/name input`. Resolves undefined when no plugin declares `name`. */
run(name: string, input: string): Promise<PluginCommandResult | undefined>;
shutdown(): Promise<void>;
}
type LoadedHost = {
key: string;
type LoadedPlugins = Awaited<ReturnType<typeof loadResolvedAgentPlugins>>;
type Entry = {
workspacePath: string;
registry: PluginRegistry;
catalog: PluginCommandCatalog;
commands: AgentExtensionCommand[];
shutdown?: () => Promise<void>;
/** Set when this entry records a failed load; retried after a short delay. */
failedAt?: number;
loaded: LoadedPlugins[];
pluginPaths: string[];
failedPaths?: string[];
retryDue: boolean;
pending?: Promise<void>;
dirty: boolean;
retryCount: number;
timer?: ReturnType<typeof setTimeout>;
watchers: FSWatcher[];
watchRetry?: ReturnType<typeof setTimeout>;
fingerprint?: string;
/** Serializes execution with reload/disposal so a handler cannot lose its sandbox. */
tail: Promise<unknown>;
};
// A failed load may be transient (sandbox startup timeout, I/O hiccup), so
// remember it only briefly instead of until the plugin set changes.
const FAILED_LOAD_RETRY_MS = 30_000;
/** Runtime-owned workspace catalogs. Session execution uses the session registry instead. */
export class PluginCommandManager implements PluginCommandsApi {
private readonly entries = new Map<string, Entry>();
private readonly listeners = new Set<
(catalog: PluginCommandCatalog) => void
>();
private disposed = false;
constructor(
private readonly options: {
logger?: BasicLogger;
telemetry?: ITelemetryService;
load?: typeof loadResolvedAgentPlugins;
retryDelayMs?: number;
watch?: typeof watch;
} = {},
) {}
export function normalizePluginCommandName(name: string): string {
return name.trim().replace(/^\/+/, "").toLowerCase();
}
function normalizePluginCommandResult(
result: AgentExtensionCommandResult | undefined,
): PluginCommandResult {
const { reply, submitPrompt }: PluginCommandResult =
typeof result === "string" ? { reply: result } : (result ?? {});
return {
reply: reply?.trim() || undefined,
submitPrompt: submitPrompt?.trim() || undefined,
};
}
async function loadHost(
options: { cwd: string; workspacePath?: string },
key: string,
): Promise<LoadedHost> {
const loaded = await resolveAndLoadAgentPlugins(options);
try {
const registry = createContributionRegistry<
(typeof loaded.extensions)[number],
AgentTool,
Message[]
>({ extensions: loaded.extensions });
await registry.initialize();
return {
key,
commands: registry
.getRegistrySnapshot()
.commands.filter((command) => typeof command.handler === "function"),
shutdown: loaded.shutdown,
subscribe = (
listener: (catalog: PluginCommandCatalog) => void,
): (() => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
} catch (error) {
await loaded.shutdown?.().catch(() => {});
throw error;
};
private entry(target: PluginCommandTarget): Entry {
if (this.disposed) throw new Error("Plugin command manager is disposed");
const workspacePath = resolve(target.workspacePath);
let entry = this.entries.get(workspacePath);
if (!entry) {
entry = {
workspacePath,
registry: new PluginRegistry({ logger: this.options.logger }),
catalog: { workspacePath, status: "ready", commands: [] },
commands: [],
loaded: [],
pluginPaths: [],
retryDue: false,
dirty: true,
retryCount: 0,
watchers: [],
tail: Promise.resolve(),
};
this.entries.set(workspacePath, entry);
}
return entry;
}
private schedule(entry: Entry, delay: number, retryOnly = false): void {
if (this.disposed) return;
clearTimeout(entry.timer);
entry.timer = setTimeout(() => {
if (retryOnly) entry.retryDue = true;
else entry.dirty = true;
void this.refresh(entry);
}, delay);
entry.timer.unref?.();
}
private watchRoots(entry: Entry): Set<string> {
return new Set([
...resolvePluginConfigSearchPaths(entry.workspacePath),
...entry.pluginPaths.map(dirname),
resolveGlobalSettingsPath(),
]);
}
/**
* Compare contents across watcher outages without replacing unchanged plugin instances.
* Unreadable entries contribute their error code instead of failing the scan.
*/
private async fingerprint(entry: Entry): Promise<string> {
const hash = createHash("sha256");
const visited = new Set<string>();
const visit = async (path: string): Promise<void> => {
hash.update(JSON.stringify(path));
try {
const real = await realpath(path);
if (visited.has(real)) return;
visited.add(real);
const info = await stat(path);
if (info.isDirectory()) {
for (const name of (await readdir(path)).sort()) {
if (name !== "node_modules") await visit(resolve(path, name));
}
} else if (info.isFile()) hash.update(await readFile(path));
} catch (error) {
hash.update(
`error:${(error as NodeJS.ErrnoException).code ?? "unknown"}`,
);
}
};
for (const root of [...this.watchRoots(entry)].sort()) await visit(root);
return hash.digest("hex");
}
private reconnect(entry: Entry): void {
if (this.disposed) return;
clearTimeout(entry.watchRetry);
entry.watchRetry = setTimeout(() => {
if (this.disposed) return;
// Install watchers first, then reconcile changes missed during the outage.
this.watch(entry);
void this.fingerprint(entry).then((fingerprint) => {
if (this.disposed || fingerprint === entry.fingerprint) return;
entry.dirty = true;
void this.refresh(entry);
});
}, 1000);
entry.watchRetry.unref?.();
}
private watch(entry: Entry): void {
clearTimeout(entry.watchRetry);
if (this.disposed) return;
for (const watcher of entry.watchers) watcher.close();
entry.watchers = [];
const settings = resolveGlobalSettingsPath();
const roots = this.watchRoots(entry);
for (const root of roots) {
let directory = root === settings ? dirname(root) : root;
while (!existsSync(directory) && dirname(directory) !== directory)
directory = dirname(directory);
try {
const recursive =
directory === root && statSync(directory).isDirectory();
const watcher = (this.options.watch ?? watch)(
directory,
{ recursive, persistent: false },
(_event, file) => {
const changed = file ? resolve(directory, String(file)) : directory;
if (String(file).split(/[\\/]/).includes("node_modules")) return;
if (
changed !== root &&
!changed.startsWith(`${root}${sep}`) &&
!root.startsWith(`${changed}${sep}`)
)
return;
entry.dirty = true;
this.schedule(entry, 100);
},
);
watcher.on("error", (error) => {
this.options.logger?.debug?.(
"Plugin command watcher failed; reconnecting",
{ directory, error },
);
this.reconnect(entry);
});
entry.watchers.push(watcher);
} catch (error) {
this.options.logger?.debug?.("Plugin command watcher unavailable", {
directory,
error,
});
this.reconnect(entry);
}
}
}
private refresh(entry: Entry): Promise<void> {
if (entry.pending) return entry.pending;
if ((!entry.dirty && !entry.retryDue) || this.disposed)
return Promise.resolve();
const retryOnly = !entry.dirty;
entry.dirty = false;
entry.retryDue = false;
clearTimeout(entry.timer);
entry.pending = entry.tail
.then(async () => {
if (this.disposed) return;
if (!retryOnly) {
await Promise.all(
entry.loaded.map((loaded) => loaded.shutdown?.().catch(() => {})),
);
entry.loaded = [];
entry.commands = [];
entry.failedPaths = undefined;
}
let loaded: LoadedPlugins | undefined;
let error: string | undefined;
try {
if (!entry.failedPaths) {
entry.pluginPaths = resolveAgentPluginPaths({
cwd: entry.workspacePath,
workspacePath: entry.workspacePath,
});
entry.failedPaths = entry.pluginPaths;
entry.fingerprint = await this.fingerprint(entry);
}
if (retryOnly && !this.options.load) {
for (const path of entry.failedPaths) {
if (
entry.registry
.get(path)
.some((status) => status.state === "failed")
)
await entry.registry.reload(path);
}
}
loaded = await (this.options.load ?? loadResolvedAgentPlugins)({
cwd: entry.workspacePath,
workspacePath: entry.workspacePath,
pluginPaths: entry.failedPaths,
registry: entry.registry,
});
const registry = createContributionRegistry<
(typeof loaded.extensions)[number],
AgentTool,
Message[]
>({ extensions: loaded.extensions });
await registry.initialize();
entry.commands.push(...registry.getRegistrySnapshot().commands);
entry.failedPaths = [
...new Set(loaded.failures.map((failure) => failure.pluginPath)),
];
error =
loaded.failures.map((failure) => failure.message).join("; ") ||
undefined;
if (loaded.extensions.length) entry.loaded.push(loaded);
else await loaded.shutdown?.();
if (!error) entry.retryCount = 0;
} catch (cause) {
await loaded?.shutdown?.().catch(() => {});
error = cause instanceof Error ? cause.message : String(cause);
this.options.logger?.error?.(
"Plugin command discovery failed; retrying",
{ error: cause },
);
}
entry.catalog = {
workspacePath: entry.workspacePath,
status: error ? "error" : "ready",
error,
commands: listPluginCommands(entry.commands),
};
if (error)
this.schedule(
entry,
Math.min(
(this.options.retryDelayMs ?? 1000) * 2 ** entry.retryCount++,
30_000,
),
true,
);
if (!this.disposed) {
this.watch(entry);
for (const listener of this.listeners) {
try {
listener(structuredClone(entry.catalog));
} catch (error) {
this.options.logger?.error?.("Plugin command subscriber failed", {
error,
});
}
}
}
})
.finally(() => {
entry.pending = undefined;
if (entry.dirty || entry.retryDue)
this.schedule(entry, 0, !entry.dirty);
});
entry.tail = entry.pending.catch(() => {});
return entry.pending;
}
async list(target: PluginCommandTarget): Promise<PluginCommandCatalog> {
const entry = this.entry(target);
await this.refresh(entry);
return structuredClone(entry.catalog);
}
async run(input: PluginCommandTarget & { prompt: string }) {
const entry = this.entry(input);
await this.refresh(entry);
if (this.disposed) throw new Error("Plugin command manager is disposed");
const run = entry.tail.then(() =>
executePluginCommand(entry.commands, input.prompt, {
telemetry: this.options.telemetry,
}),
);
entry.tail = run.catch(() => {});
return await run;
}
async dispose(): Promise<void> {
this.disposed = true;
this.listeners.clear();
await Promise.all(
[...this.entries.values()].map(async (entry) => {
clearTimeout(entry.timer);
clearTimeout(entry.watchRetry);
for (const watcher of entry.watchers) watcher.close();
await entry.tail;
await Promise.all(entry.loaded.map((loaded) => loaded.shutdown?.()));
}),
);
this.entries.clear();
}
}
export function createPluginCommandService(options: {
cwd: string;
workspacePath?: string;
logger?: BasicLogger;
}): PluginCommandService {
const loadOptions = {
cwd: options.cwd,
workspacePath: options.workspacePath ?? options.cwd,
};
let current: Promise<LoadedHost | undefined> = Promise.resolve(undefined);
const ensureHost = (): Promise<LoadedHost | undefined> => {
const pluginPaths = resolveAgentPluginPaths(loadOptions);
const key = pluginPaths
.map((path) => `${path}:${fingerprintPluginSources(path)}`)
.join("\n");
// Chain onto the previous host promise so concurrent callers serialize:
// a stale host is shut down and replaced exactly once.
current = current
.catch(() => undefined)
.then(async (host) => {
if (
host?.key === key &&
(host.failedAt === undefined ||
Date.now() - host.failedAt < FAILED_LOAD_RETRY_MS)
) {
return host;
}
await host?.shutdown?.().catch(() => {});
if (pluginPaths.length === 0) return undefined;
try {
return await loadHost(loadOptions, key);
} catch (error) {
// A broken plugin must not block slash commands (the prompt may
// be a skill or workflow). Remember the failure so a broken
// plugin does not cost a sandbox spawn on every prompt.
options.logger?.error?.(
"plugin command loading failed; continuing without plugin commands",
{ error },
);
return { key, commands: [], failedAt: Date.now() };
}
});
return current;
};
return {
async listCommands() {
const host = await ensureHost();
return (host?.commands ?? []).map((command) => ({
name: normalizePluginCommandName(command.name),
description: command.description,
}));
},
async run(name, input) {
const normalized = normalizePluginCommandName(name);
const command = (await ensureHost())?.commands.find(
(candidate) =>
normalizePluginCommandName(candidate.name) === normalized,
);
if (!command?.handler) return undefined;
return normalizePluginCommandResult(await command.handler(input.trim()));
},
async shutdown() {
const host = await current.catch(() => undefined);
current = Promise.resolve(undefined);
await host?.shutdown?.().catch(() => {});
},
};
}
+3
View File
@@ -35,6 +35,9 @@ export type ActiveSession = {
pendingPrompts: PendingPrompt[];
drainingPendingPrompts: boolean;
pluginSandboxShutdown?: () => Promise<void>;
pluginCommandError?: string;
pluginCommandTail?: Promise<unknown>;
pluginCommandsClosing?: boolean;
turnUsageBaseline?: SessionAccumulatedUsage;
turnAggregateUsageBaseline?: SessionAccumulatedUsage;
turnPrimaryUsage?: SessionAccumulatedUsage;
+4
View File
@@ -691,6 +691,8 @@ export type HubCommandName =
| "task.run"
| "task.automation.get"
| "task.automation.set"
| "plugins.commands.list"
| "plugins.commands.run"
| "settings.list"
| "settings.get"
| "settings.patch"
@@ -716,6 +718,7 @@ export function getDefaultHubCommandTimeoutMs(
switch (command) {
case "run.start":
case "session.send_input":
case "plugins.commands.run":
return null;
default:
return HUB_DEFAULT_COMMAND_TIMEOUT_MS;
@@ -810,6 +813,7 @@ export type HubEventName =
| "task.run.completed"
| "task.run.failed"
| "task.automation.updated"
| "plugins.commands.changed"
| "settings.changed"
| "plugin.status_changed"
| "ui.notify"