mirror of
https://github.com/cline/cline.git
synced 2026-10-24 02:02:27 +08:00
fix(core): own plugin commands in the runtime and hub
This commit is contained in:
@@ -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 ?? [];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -146,6 +146,7 @@ function App(props: TuiProps) {
|
||||
} = useSlashCommands({
|
||||
workflowSlashCommands,
|
||||
loadAdditionalSlashCommands: props.loadAdditionalSlashCommands,
|
||||
subscribeAdditionalSlashCommands: props.subscribeAdditionalSlashCommands,
|
||||
canFork: canForkSession,
|
||||
});
|
||||
|
||||
|
||||
@@ -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[]>;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(() => {});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user