mirror of
https://github.com/cline/cline.git
synced 2026-09-21 05:10:09 +08:00
1199 lines
37 KiB
TypeScript
1199 lines
37 KiB
TypeScript
import { execFileSync, spawn } from "node:child_process";
|
|
import { existsSync, readdirSync, readFileSync, rmSync } from "node:fs";
|
|
import { basename, dirname, extname, join } from "node:path";
|
|
import type {
|
|
ClineAccountActionRequest,
|
|
ProviderCapability,
|
|
ProviderClient,
|
|
ProviderProtocol,
|
|
SaveProviderSettingsActionRequest,
|
|
} from "@cline/core";
|
|
import {
|
|
addLocalProvider,
|
|
ClineAccountService,
|
|
createLocalHubScheduleRuntimeHandlers,
|
|
createUserInstructionConfigService,
|
|
discoverPluginModulePaths,
|
|
ensureCustomProvidersLoaded,
|
|
ensureHubServer,
|
|
executeClineAccountAction,
|
|
getCoreBuiltinToolCatalog,
|
|
getLocalProviderModels,
|
|
HubScheduleCommandService,
|
|
HubScheduleService,
|
|
listHookConfigFiles,
|
|
listLocalProviders,
|
|
listPluginTools,
|
|
loginAndSaveLocalProviderOAuthCredentials,
|
|
normalizeOAuthProvider,
|
|
ProviderSettingsManager,
|
|
readGlobalSettings,
|
|
resolveLocalClineAuthToken,
|
|
resolvePluginConfigSearchPaths,
|
|
resolveSessionBackend,
|
|
resolveAgentConfigSearchPaths as resolveSharedAgentConfigSearchPaths,
|
|
SqliteSessionStore,
|
|
saveLocalProviderSettings,
|
|
sendHubCommand,
|
|
setDisabledPlugin,
|
|
setDisabledTools,
|
|
toggleDisabledTool,
|
|
updateMcpSettingsFileSync,
|
|
} from "@cline/core";
|
|
import { getClineEnvironmentConfig } from "@cline/shared";
|
|
import { broadcastEvent, resolveSidecarAskQuestion } from "./context";
|
|
import {
|
|
findArtifactUnderDir,
|
|
readSessionManifest,
|
|
resolveMcpSettingsPath,
|
|
rootSessionIdFrom,
|
|
sessionLogPath,
|
|
sharedSessionDataDir,
|
|
} from "./paths";
|
|
import { readSessionHooks } from "./session-data/artifacts";
|
|
import { normalizeSessionTitle } from "./session-data/common";
|
|
import { discoverChatSessions } from "./session-data/discovery";
|
|
import { readSessionMessages } from "./session-data/messages";
|
|
import { searchWorkspaceFiles } from "./session-data/search";
|
|
import type {
|
|
ChatSessionCommandRequest,
|
|
JsonRecord,
|
|
SidecarContext,
|
|
} from "./types";
|
|
|
|
function readProviderSettingsUpdate(
|
|
args: Record<string, unknown> | undefined,
|
|
): Partial<Omit<SaveProviderSettingsActionRequest, "action" | "providerId">> {
|
|
return args?.settings && typeof args.settings === "object"
|
|
? (args.settings as Partial<
|
|
Omit<SaveProviderSettingsActionRequest, "action" | "providerId">
|
|
>)
|
|
: {};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// MCP settings helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function readMcpServersResponse(): JsonRecord {
|
|
const settingsPath = resolveMcpSettingsPath();
|
|
if (!existsSync(settingsPath)) {
|
|
return { settingsPath, hasSettingsFile: false, servers: [] };
|
|
}
|
|
const parsed = JSON.parse(readFileSync(settingsPath, "utf8")) as JsonRecord;
|
|
const servers = parsed.mcpServers as JsonRecord | undefined;
|
|
const entries = Object.entries(servers ?? {}).map(([name, body]) => {
|
|
const record = body as JsonRecord;
|
|
const transport =
|
|
record.transport && typeof record.transport === "object"
|
|
? (record.transport as JsonRecord)
|
|
: undefined;
|
|
const transportType = String(
|
|
transport?.type ?? record.transportType ?? record.type ?? "stdio",
|
|
).trim();
|
|
return {
|
|
name,
|
|
transportType,
|
|
disabled: record.disabled === true,
|
|
command:
|
|
typeof transport?.command === "string"
|
|
? transport.command
|
|
: typeof record.command === "string"
|
|
? record.command
|
|
: undefined,
|
|
args: Array.isArray(transport?.args)
|
|
? transport.args
|
|
: Array.isArray(record.args)
|
|
? record.args
|
|
: undefined,
|
|
cwd:
|
|
typeof transport?.cwd === "string"
|
|
? transport.cwd
|
|
: typeof record.cwd === "string"
|
|
? record.cwd
|
|
: undefined,
|
|
env:
|
|
transport?.env && typeof transport.env === "object"
|
|
? transport.env
|
|
: record.env && typeof record.env === "object"
|
|
? record.env
|
|
: undefined,
|
|
url:
|
|
typeof transport?.url === "string"
|
|
? transport.url
|
|
: typeof record.url === "string"
|
|
? record.url
|
|
: undefined,
|
|
headers:
|
|
transport?.headers && typeof transport.headers === "object"
|
|
? transport.headers
|
|
: record.headers && typeof record.headers === "object"
|
|
? record.headers
|
|
: undefined,
|
|
metadata: record.metadata,
|
|
};
|
|
});
|
|
return { settingsPath, hasSettingsFile: true, servers: entries };
|
|
}
|
|
|
|
function writeMcpServersMap(servers: JsonRecord): void {
|
|
updateMcpSettingsFileSync(resolveMcpSettingsPath(), (settings) => {
|
|
settings.mcpServers = servers;
|
|
});
|
|
}
|
|
|
|
function ensureMcpSettingsFile(): string {
|
|
const path = resolveMcpSettingsPath();
|
|
if (!existsSync(path)) {
|
|
writeMcpServersMap({});
|
|
}
|
|
return path;
|
|
}
|
|
|
|
function removePathIfExists(
|
|
path: string,
|
|
options?: { recursive?: boolean },
|
|
): boolean {
|
|
if (!path || !existsSync(path)) {
|
|
return false;
|
|
}
|
|
rmSync(path, {
|
|
force: true,
|
|
recursive: options?.recursive === true,
|
|
});
|
|
return true;
|
|
}
|
|
|
|
async function listSessionsFromSidecarManager(
|
|
ctx: SidecarContext,
|
|
limit: number,
|
|
): Promise<unknown> {
|
|
const max = Math.max(1, Math.floor(limit));
|
|
if (ctx.sessionManager) {
|
|
return await ctx.sessionManager.list(max, { hydrate: false });
|
|
}
|
|
|
|
const byId = new Map<string, JsonRecord>();
|
|
const store = new SqliteSessionStore();
|
|
const mergeSessionRecord = (
|
|
sessionId: string,
|
|
record: JsonRecord,
|
|
): JsonRecord => {
|
|
const persisted = store.get(sessionId) as unknown as JsonRecord | undefined;
|
|
const metadata =
|
|
record.metadata && typeof record.metadata === "object"
|
|
? (record.metadata as JsonRecord)
|
|
: undefined;
|
|
return {
|
|
...(persisted ?? {}),
|
|
...record,
|
|
sessionId,
|
|
provider: record.provider ?? persisted?.provider ?? "",
|
|
model: record.model ?? persisted?.model ?? "",
|
|
cwd: record.cwd ?? persisted?.cwd ?? "",
|
|
workspaceRoot:
|
|
record.workspaceRoot ??
|
|
persisted?.workspaceRoot ??
|
|
persisted?.cwd ??
|
|
"",
|
|
prompt: record.prompt ?? persisted?.prompt ?? metadata?.prompt,
|
|
parentSessionId:
|
|
record.parentSessionId ??
|
|
persisted?.parentSessionId ??
|
|
metadata?.parentSessionId,
|
|
parentAgentId:
|
|
record.parentAgentId ??
|
|
persisted?.parentAgentId ??
|
|
metadata?.parentAgentId,
|
|
agentId: record.agentId ?? persisted?.agentId ?? metadata?.agentId,
|
|
conversationId:
|
|
record.conversationId ??
|
|
persisted?.conversationId ??
|
|
metadata?.conversationId,
|
|
isSubagent: record.isSubagent ?? persisted?.isSubagent ?? false,
|
|
startedAt: record.startedAt ?? persisted?.startedAt ?? record.createdAt,
|
|
updatedAt: record.updatedAt ?? persisted?.updatedAt,
|
|
metadata: {
|
|
...((persisted?.metadata && typeof persisted.metadata === "object"
|
|
? persisted.metadata
|
|
: {}) as JsonRecord),
|
|
...(metadata ?? {}),
|
|
},
|
|
};
|
|
};
|
|
|
|
if (ctx.hubClient) {
|
|
try {
|
|
const reply = await ctx.hubClient.command("session.list", { limit: max });
|
|
const sessions = Array.isArray(reply.payload?.sessions)
|
|
? reply.payload.sessions
|
|
: [];
|
|
for (const item of sessions) {
|
|
if (!item || typeof item !== "object") continue;
|
|
const record = item as JsonRecord;
|
|
const sessionId = String(record.sessionId ?? "").trim();
|
|
if (sessionId)
|
|
byId.set(sessionId, mergeSessionRecord(sessionId, record));
|
|
}
|
|
} catch {
|
|
// Fall through to the local SQLite index.
|
|
}
|
|
}
|
|
|
|
if (byId.size === 0) {
|
|
for (const session of store.list(max)) {
|
|
byId.set(session.sessionId, session as unknown as JsonRecord);
|
|
}
|
|
}
|
|
|
|
for (const [sessionId, session] of ctx.liveSessions.entries()) {
|
|
const existing = byId.get(sessionId);
|
|
byId.set(sessionId, {
|
|
...(existing ?? {}),
|
|
sessionId,
|
|
status: session.status,
|
|
provider: session.config.provider ?? existing?.provider ?? "",
|
|
model: session.config.model ?? existing?.model ?? "",
|
|
cwd: session.config.cwd ?? existing?.cwd ?? "",
|
|
workspaceRoot:
|
|
session.config.workspaceRoot ??
|
|
existing?.workspaceRoot ??
|
|
existing?.cwd ??
|
|
"",
|
|
prompt: session.prompt ?? existing?.prompt,
|
|
startedAt:
|
|
existing?.startedAt ?? new Date(session.startedAt).toISOString(),
|
|
endedAt:
|
|
session.endedAt !== undefined
|
|
? new Date(session.endedAt).toISOString()
|
|
: existing?.endedAt,
|
|
metadata: {
|
|
...((existing?.metadata && typeof existing.metadata === "object"
|
|
? existing.metadata
|
|
: {}) as JsonRecord),
|
|
...(session.title ? { title: session.title } : {}),
|
|
},
|
|
});
|
|
}
|
|
|
|
return Array.from(byId.values())
|
|
.sort((left, right) => {
|
|
const leftTime = Date.parse(
|
|
String(left.updatedAt ?? left.startedAt ?? ""),
|
|
);
|
|
const rightTime = Date.parse(
|
|
String(right.updatedAt ?? right.startedAt ?? ""),
|
|
);
|
|
return (
|
|
(Number.isNaN(rightTime) ? 0 : rightTime) -
|
|
(Number.isNaN(leftTime) ? 0 : leftTime)
|
|
);
|
|
})
|
|
.slice(0, max);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Git helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function listGitBranches(
|
|
ctx: SidecarContext,
|
|
cwd?: string,
|
|
): { current?: string; branches?: string[] } {
|
|
const targetCwd = cwd?.trim() || ctx.workspaceRoot;
|
|
const current = (() => {
|
|
try {
|
|
return execFileSync("git", ["branch", "--show-current"], {
|
|
cwd: targetCwd,
|
|
encoding: "utf8",
|
|
stdio: ["ignore", "pipe", "ignore"],
|
|
}).trim();
|
|
} catch {
|
|
return "";
|
|
}
|
|
})();
|
|
try {
|
|
const stdout = execFileSync(
|
|
"git",
|
|
["for-each-ref", "--format=%(refname:short)", "refs/heads"],
|
|
{
|
|
cwd: targetCwd,
|
|
encoding: "utf8",
|
|
stdio: ["ignore", "pipe", "ignore"],
|
|
},
|
|
);
|
|
const branches = stdout
|
|
.split("\n")
|
|
.map((v) => v.trim())
|
|
.filter(Boolean);
|
|
return { current: current || undefined, branches };
|
|
} catch {
|
|
return { current: current || undefined, branches: [] };
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Routine schedule helpers (in-process via shared hub server)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function toPositiveInt(value: unknown): number | undefined {
|
|
if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
|
|
const rounded = Math.trunc(value);
|
|
return rounded > 0 ? rounded : undefined;
|
|
}
|
|
|
|
function asTrimmedString(value: unknown): string | undefined {
|
|
if (typeof value !== "string") return undefined;
|
|
const trimmed = value.trim();
|
|
return trimmed.length > 0 ? trimmed : undefined;
|
|
}
|
|
|
|
async function handleRoutineScheduleCommand(
|
|
command: string,
|
|
args?: Record<string, unknown>,
|
|
): Promise<unknown> {
|
|
let useLocalScheduleService = false;
|
|
try {
|
|
if (!useLocalScheduleService) {
|
|
await ensureHubServer({
|
|
runtimeHandlers: createLocalHubScheduleRuntimeHandlers(),
|
|
});
|
|
}
|
|
} catch {
|
|
useLocalScheduleService = true;
|
|
}
|
|
const clientCommand = async (
|
|
hubCommand: string,
|
|
payload?: Record<string, unknown>,
|
|
) => {
|
|
const reply = useLocalScheduleService
|
|
? await localRoutineScheduleCommand(hubCommand, payload)
|
|
: await sendHubCommand(
|
|
{},
|
|
{
|
|
clientId: "code-sidecar-routines",
|
|
command: hubCommand as never,
|
|
payload,
|
|
},
|
|
);
|
|
if (!reply.ok) {
|
|
throw new Error(
|
|
reply.error?.message ?? `hub command failed: ${hubCommand}`,
|
|
);
|
|
}
|
|
return (reply.payload ?? {}) as Record<string, unknown>;
|
|
};
|
|
try {
|
|
if (command === "list_routine_schedules") {
|
|
const [schedules, activeExecutions, upcomingRuns] = await Promise.all([
|
|
clientCommand("schedule.list", {
|
|
limit: toPositiveInt(args?.limit) ?? 200,
|
|
}),
|
|
clientCommand("schedule.active"),
|
|
clientCommand("schedule.upcoming", { limit: 30 }),
|
|
]);
|
|
return {
|
|
schedules: schedules.schedules ?? [],
|
|
activeExecutions: activeExecutions.executions ?? [],
|
|
upcomingRuns: upcomingRuns.runs ?? [],
|
|
};
|
|
}
|
|
if (command === "create_routine_schedule") {
|
|
const name = asTrimmedString(args?.name);
|
|
const cronPattern = asTrimmedString(args?.cron_pattern);
|
|
const prompt = asTrimmedString(args?.prompt);
|
|
const workspaceRoot = asTrimmedString(args?.workspace_root);
|
|
if (!name || !cronPattern || !prompt || !workspaceRoot) {
|
|
throw new Error(
|
|
"createSchedule requires name, cron_pattern, prompt, and workspace_root",
|
|
);
|
|
}
|
|
const created = await clientCommand("schedule.create", {
|
|
name,
|
|
cronPattern,
|
|
prompt,
|
|
modelSelection: {
|
|
providerId: asTrimmedString(args?.provider) ?? "cline",
|
|
modelId: asTrimmedString(args?.model) ?? "openai/gpt-5.3-codex",
|
|
},
|
|
mode: args?.mode === "plan" ? "plan" : "act",
|
|
workspaceRoot,
|
|
cwd: asTrimmedString(args?.cwd),
|
|
systemPrompt: asTrimmedString(args?.system_prompt),
|
|
maxIterations: toPositiveInt(args?.max_iterations),
|
|
timeoutSeconds: toPositiveInt(args?.timeout_seconds),
|
|
maxParallel: toPositiveInt(args?.max_parallel) ?? 1,
|
|
enabled: args?.enabled !== false,
|
|
tags:
|
|
Array.isArray(args?.tags) && args.tags.length > 0
|
|
? (args.tags as string[])
|
|
.map((v: string) => v.trim())
|
|
.filter((v: string) => v.length > 0)
|
|
: undefined,
|
|
});
|
|
return { schedule: created.schedule ?? null };
|
|
}
|
|
const scheduleId = asTrimmedString(args?.schedule_id);
|
|
if (!scheduleId) throw new Error(`${command} requires schedule_id`);
|
|
if (command === "pause_routine_schedule") {
|
|
const reply = await clientCommand("schedule.disable", { scheduleId });
|
|
return { schedule: reply.schedule ?? null };
|
|
}
|
|
if (command === "resume_routine_schedule") {
|
|
const reply = await clientCommand("schedule.enable", { scheduleId });
|
|
return { schedule: reply.schedule ?? null };
|
|
}
|
|
if (command === "trigger_routine_schedule") {
|
|
const reply = await clientCommand("schedule.trigger", { scheduleId });
|
|
return { execution: reply.execution ?? null };
|
|
}
|
|
if (command === "delete_routine_schedule") {
|
|
const reply = await clientCommand("schedule.delete", { scheduleId });
|
|
return { deleted: reply.deleted === true };
|
|
}
|
|
throw new Error(`unsupported routine schedule command: ${command}`);
|
|
} finally {
|
|
}
|
|
}
|
|
|
|
let localRoutineScheduleService: HubScheduleService | undefined;
|
|
let localRoutineScheduleCommands: HubScheduleCommandService | undefined;
|
|
|
|
function getLocalRoutineScheduleCommands(): HubScheduleCommandService {
|
|
if (!localRoutineScheduleService || !localRoutineScheduleCommands) {
|
|
localRoutineScheduleService = new HubScheduleService({
|
|
runtimeHandlers: createLocalHubScheduleRuntimeHandlers(),
|
|
});
|
|
localRoutineScheduleCommands = new HubScheduleCommandService(
|
|
localRoutineScheduleService,
|
|
);
|
|
}
|
|
return localRoutineScheduleCommands;
|
|
}
|
|
|
|
async function localRoutineScheduleCommand(
|
|
command: string,
|
|
payload?: Record<string, unknown>,
|
|
) {
|
|
return await getLocalRoutineScheduleCommands().handleCommand({
|
|
version: "v1",
|
|
clientId: "code-sidecar-routines-local",
|
|
command: command as never,
|
|
payload,
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// User instruction config listing through the core config service.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function resolveAgentConfigSearchPaths(workspaceRoot?: string): string[] {
|
|
return resolveSharedAgentConfigSearchPaths(workspaceRoot);
|
|
}
|
|
|
|
async function listUserInstructionConfigs(
|
|
workspaceRoot: string,
|
|
): Promise<JsonRecord> {
|
|
const warnings: string[] = [];
|
|
|
|
const loadUserInstructionSnapshot = async (
|
|
type: "rule" | "skill" | "workflow",
|
|
): Promise<unknown[]> => {
|
|
const items: unknown[] = [];
|
|
const service = createUserInstructionConfigService({
|
|
skills: { workspacePath: workspaceRoot },
|
|
rules: { workspacePath: workspaceRoot },
|
|
workflows: { workspacePath: workspaceRoot },
|
|
});
|
|
try {
|
|
await service.start();
|
|
for (const record of service.listRecords(type)) {
|
|
const item = record.item as unknown as JsonRecord;
|
|
if (item.disabled === true) continue;
|
|
items.push({
|
|
id: record.id,
|
|
name: item.name ?? record.id,
|
|
instructions: item.instructions,
|
|
path: record.filePath,
|
|
});
|
|
}
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
warnings.push(`${type}: ${message}`);
|
|
} finally {
|
|
service.stop();
|
|
}
|
|
return items;
|
|
};
|
|
|
|
const loadAgents = (): unknown[] => {
|
|
const agentsById = new Map<string, { name: string; path: string }>();
|
|
const directories = resolveAgentConfigSearchPaths(workspaceRoot).filter(
|
|
(d) => existsSync(d),
|
|
);
|
|
for (const directory of directories) {
|
|
try {
|
|
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
if (!entry.isFile()) continue;
|
|
const ext = extname(entry.name).toLowerCase();
|
|
if (ext !== ".yml" && ext !== ".yaml") continue;
|
|
const filePath = join(directory, entry.name);
|
|
const raw = readFileSync(filePath, "utf8");
|
|
const fmMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
const fm = fmMatch?.[1] ?? "";
|
|
const nameMatch = fm.match(/^\s*name:\s*(.+?)\s*$/m);
|
|
const parsedName = nameMatch?.[1]?.replace(/^["']|["']$/g, "").trim();
|
|
const name =
|
|
parsedName && parsedName.length > 0
|
|
? parsedName
|
|
: basename(entry.name, ext);
|
|
const id = name.toLowerCase();
|
|
if (!agentsById.has(id)) {
|
|
agentsById.set(id, { name, path: filePath });
|
|
}
|
|
}
|
|
} catch {
|
|
// best-effort
|
|
}
|
|
}
|
|
return [...agentsById.values()].sort((a, b) =>
|
|
a.name.localeCompare(b.name),
|
|
);
|
|
};
|
|
|
|
const loadHooks = (): unknown[] => {
|
|
try {
|
|
return listHookConfigFiles(workspaceRoot);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
warnings.push(`hooks: ${message}`);
|
|
return [];
|
|
}
|
|
};
|
|
|
|
const loadPlugins = (): Array<{
|
|
name: string;
|
|
path: string;
|
|
enabled: boolean;
|
|
}> => {
|
|
const disabledPlugins = new Set(readGlobalSettings().disabledPlugins ?? []);
|
|
const pluginsByPath = new Map<
|
|
string,
|
|
{ name: string; path: string; enabled: boolean }
|
|
>();
|
|
const directories = resolvePluginConfigSearchPaths(workspaceRoot).filter(
|
|
(d) => existsSync(d),
|
|
);
|
|
for (const directory of directories) {
|
|
try {
|
|
for (const filePath of discoverPluginModulePaths(directory)) {
|
|
if (pluginsByPath.has(filePath)) {
|
|
continue;
|
|
}
|
|
pluginsByPath.set(filePath, {
|
|
name: basename(filePath, extname(filePath)),
|
|
path: filePath,
|
|
enabled: !disabledPlugins.has(filePath),
|
|
});
|
|
}
|
|
} catch {
|
|
// best-effort
|
|
}
|
|
}
|
|
return [...pluginsByPath.values()].sort((a, b) =>
|
|
a.name.localeCompare(b.name),
|
|
);
|
|
};
|
|
|
|
const [rules, workflows, skills, pluginTools] = await Promise.all([
|
|
loadUserInstructionSnapshot("rule"),
|
|
loadUserInstructionSnapshot("workflow"),
|
|
loadUserInstructionSnapshot("skill"),
|
|
listPluginTools({
|
|
workspacePath: workspaceRoot,
|
|
cwd: workspaceRoot,
|
|
}),
|
|
]);
|
|
|
|
const disabledTools = new Set(readGlobalSettings().disabledTools ?? []);
|
|
const builtinToolCatalog = getCoreBuiltinToolCatalog({
|
|
disabledToolIds: disabledTools,
|
|
});
|
|
|
|
return {
|
|
workspaceRoot,
|
|
rules,
|
|
workflows,
|
|
skills,
|
|
agents: loadAgents(),
|
|
plugins: loadPlugins(),
|
|
tools: [
|
|
...builtinToolCatalog.map((tool) => ({
|
|
id: tool.id,
|
|
name: tool.id,
|
|
description: tool.description,
|
|
enabled:
|
|
tool.defaultEnabled &&
|
|
!tool.headlessToolNames.some((name) => disabledTools.has(name)),
|
|
source: "builtin",
|
|
headlessToolNames: tool.headlessToolNames,
|
|
})),
|
|
...pluginTools.map((tool) => ({
|
|
id: `${tool.pluginName}:${tool.name}:${tool.path}`,
|
|
name: tool.name,
|
|
description: tool.description,
|
|
enabled: tool.enabled,
|
|
source: tool.source,
|
|
path: tool.path,
|
|
pluginName: tool.pluginName,
|
|
})),
|
|
],
|
|
hooks: loadHooks(),
|
|
mcp: readMcpServersResponse(),
|
|
warnings,
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Native OS commands
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function pickWorkspaceDirectory(): string | null {
|
|
const platform = process.platform;
|
|
if (platform === "darwin") {
|
|
try {
|
|
const result = execFileSync(
|
|
"osascript",
|
|
[
|
|
"-e",
|
|
'set theFolder to choose folder with prompt "Select workspace directory"',
|
|
"-e",
|
|
"return POSIX path of theFolder",
|
|
],
|
|
{ encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] },
|
|
).trim();
|
|
return result || null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
// Linux — try zenity
|
|
try {
|
|
const result = execFileSync(
|
|
"zenity",
|
|
["--file-selection", "--directory", "--title=Select workspace directory"],
|
|
{ encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] },
|
|
).trim();
|
|
return result || null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function openFileInEditor(filePath: string): void {
|
|
const platform = process.platform;
|
|
const cmd =
|
|
platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
|
|
const cmdArgs =
|
|
platform === "win32" ? ["/c", "start", "", filePath] : [filePath];
|
|
const child = spawn(cmd, cmdArgs, { stdio: "ignore", detached: true });
|
|
child.unref();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Main command router
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export async function handleCommand(
|
|
ctx: SidecarContext,
|
|
command: string,
|
|
args?: Record<string, unknown>,
|
|
): Promise<unknown> {
|
|
// ── Chat session commands ──────────────────────────────────────────
|
|
if (command === "chat_session_command") {
|
|
const { handleChatSessionCommand } = await import("./chat-session");
|
|
return await handleChatSessionCommand(
|
|
ctx,
|
|
(args?.request as ChatSessionCommandRequest | undefined) ??
|
|
(args as ChatSessionCommandRequest),
|
|
);
|
|
}
|
|
|
|
// ── Session data reading ──────────────────────────────────────────
|
|
if (command === "read_session_messages") {
|
|
return await readSessionMessages(
|
|
ctx,
|
|
String(args?.sessionId ?? ""),
|
|
typeof args?.maxMessages === "number" ? args.maxMessages : 800,
|
|
);
|
|
}
|
|
if (command === "read_session_hooks") {
|
|
return await readSessionHooks(
|
|
String(args?.sessionId ?? ""),
|
|
typeof args?.limit === "number" ? args.limit : 300,
|
|
);
|
|
}
|
|
|
|
// ── Process context ───────────────────────────────────────────────
|
|
if (command === "get_process_context") {
|
|
return { workspaceRoot: ctx.workspaceRoot, cwd: ctx.workspaceRoot };
|
|
}
|
|
if (command === "get_chat_ws_endpoint") {
|
|
return "";
|
|
}
|
|
|
|
// ── Tool approvals (in-memory) ────────────────────────────────────
|
|
if (command === "poll_tool_approvals") {
|
|
const sessionId = String(args?.sessionId ?? "").trim();
|
|
return Array.from(ctx.pendingApprovals.values())
|
|
.filter((a) => a.item.sessionId === sessionId)
|
|
.map((a) => a.item);
|
|
}
|
|
if (command === "respond_tool_approval") {
|
|
const sessionId = String(args?.sessionId ?? "").trim();
|
|
const requestId = String(args?.requestId ?? "").trim();
|
|
if (!sessionId || !requestId) {
|
|
throw new Error("sessionId and requestId are required");
|
|
}
|
|
const pending = ctx.pendingApprovals.get(requestId);
|
|
if (pending) {
|
|
pending.resolve({
|
|
approved: Boolean(args?.approved),
|
|
...(typeof args?.reason === "string" && args.reason.trim().length > 0
|
|
? { reason: args.reason.trim() }
|
|
: {}),
|
|
});
|
|
}
|
|
ctx.pendingApprovals.delete(requestId);
|
|
const remaining = Array.from(ctx.pendingApprovals.values())
|
|
.filter((a) => a.item.sessionId === sessionId)
|
|
.map((a) => a.item);
|
|
broadcastEvent(ctx, "tool_approval_state", {
|
|
sessionId,
|
|
items: remaining,
|
|
});
|
|
return true;
|
|
}
|
|
if (command === "respond_ask_question") {
|
|
const requestId = String(args?.requestId ?? "").trim();
|
|
if (!requestId) {
|
|
throw new Error("requestId is required");
|
|
}
|
|
const answer = String(args?.answer ?? "").trim();
|
|
const resolved = resolveSidecarAskQuestion(ctx, requestId, answer);
|
|
if (!resolved) {
|
|
throw new Error(`unknown ask question request: ${requestId}`);
|
|
}
|
|
broadcastEvent(ctx, "ask_question_answered", { requestId });
|
|
return true;
|
|
}
|
|
|
|
// ── Session discovery ─────────────────────────────────────────────
|
|
if (command === "list_chat_sessions") {
|
|
return discoverChatSessions(
|
|
ctx,
|
|
typeof args?.limit === "number" ? args.limit : 300,
|
|
);
|
|
}
|
|
if (command === "list_cli_sessions") {
|
|
return await listSessionsFromSidecarManager(
|
|
ctx,
|
|
typeof args?.limit === "number" ? args.limit : 300,
|
|
);
|
|
}
|
|
if (command === "list_discovered_sessions") {
|
|
return await listSessionsFromSidecarManager(
|
|
ctx,
|
|
typeof args?.limit === "number" ? args.limit : 300,
|
|
);
|
|
}
|
|
if (command === "update_chat_session_title") {
|
|
const sessionId = String(args?.sessionId ?? "").trim();
|
|
if (!sessionId) throw new Error("session id is required");
|
|
const title = normalizeSessionTitle(String(args?.title ?? ""));
|
|
const backend = await resolveSessionBackend({ backendMode: "local" });
|
|
const result = await backend.updateSession({ sessionId, title });
|
|
if (!result.updated) throw new Error(`Session ${sessionId} not found`);
|
|
const liveSession = ctx.liveSessions.get(sessionId);
|
|
if (liveSession) liveSession.title = title;
|
|
return true;
|
|
}
|
|
if (command === "delete_chat_session" || command === "delete_cli_session") {
|
|
const sessionId = String(args?.sessionId ?? args?.session_id ?? "").trim();
|
|
if (!sessionId) throw new Error("session id is required");
|
|
console.error(
|
|
`[sidecar:delete] request command=${command} sessionId=${sessionId}`,
|
|
);
|
|
const store = new SqliteSessionStore();
|
|
const row = store.get(sessionId);
|
|
const manifest = readSessionManifest(sessionId);
|
|
let deleted = false;
|
|
let deleteError: Error | null = null;
|
|
try {
|
|
if (ctx.sessionManager) {
|
|
deleted = await ctx.sessionManager.delete(sessionId);
|
|
} else {
|
|
const backend = await resolveSessionBackend({ backendMode: "local" });
|
|
const deleteSession = (
|
|
backend as {
|
|
deleteSession: (
|
|
sessionId: string,
|
|
cascade?: boolean,
|
|
) => Promise<boolean | { deleted: boolean }>;
|
|
}
|
|
).deleteSession.bind(backend);
|
|
const deleteResult = await deleteSession(sessionId, true);
|
|
deleted =
|
|
typeof deleteResult === "boolean"
|
|
? deleteResult
|
|
: deleteResult.deleted;
|
|
}
|
|
} catch (error) {
|
|
deleteError = error instanceof Error ? error : new Error(String(error));
|
|
}
|
|
if (store.delete(sessionId, true)) {
|
|
deleted = true;
|
|
}
|
|
ctx.liveSessions.delete(sessionId);
|
|
const directoryCandidates = new Set<string>([
|
|
join(sharedSessionDataDir(), sessionId),
|
|
]);
|
|
for (const path of [
|
|
row?.messagesPath,
|
|
typeof manifest?.messages_path === "string"
|
|
? manifest.messages_path
|
|
: null,
|
|
]) {
|
|
if (typeof path === "string" && path.trim().length > 0) {
|
|
directoryCandidates.add(dirname(path));
|
|
}
|
|
}
|
|
for (const path of [sessionLogPath(sessionId)]) {
|
|
if (removePathIfExists(path, { recursive: true })) {
|
|
deleted = true;
|
|
}
|
|
}
|
|
for (const dir of directoryCandidates) {
|
|
if (removePathIfExists(dir, { recursive: true })) {
|
|
deleted = true;
|
|
}
|
|
}
|
|
for (const path of [
|
|
row?.messagesPath,
|
|
typeof manifest?.messages_path === "string"
|
|
? manifest.messages_path
|
|
: null,
|
|
join(sharedSessionDataDir(), sessionId, `${sessionId}.json`),
|
|
].filter((v): v is string => typeof v === "string" && v.length > 0)) {
|
|
if (removePathIfExists(path)) {
|
|
deleted = true;
|
|
}
|
|
}
|
|
for (const suffix of ["messages.json"]) {
|
|
const fileName = `${sessionId}.${suffix}`;
|
|
const found = findArtifactUnderDir(
|
|
join(sharedSessionDataDir(), rootSessionIdFrom(sessionId)),
|
|
fileName,
|
|
4,
|
|
);
|
|
if (found && removePathIfExists(found)) {
|
|
deleted = true;
|
|
}
|
|
}
|
|
if (!deleted && deleteError) {
|
|
console.error(
|
|
`[sidecar:delete] failed sessionId=${sessionId} error=${deleteError.message}`,
|
|
);
|
|
throw deleteError;
|
|
}
|
|
console.error(
|
|
`[sidecar:delete] result sessionId=${sessionId} deleted=${deleted}`,
|
|
);
|
|
if (deleted) {
|
|
broadcastEvent(ctx, "session_deleted", {
|
|
sessionId,
|
|
command,
|
|
deleted: true,
|
|
});
|
|
}
|
|
return deleted;
|
|
}
|
|
|
|
// ── Workspace file search ─────────────────────────────────────────
|
|
if (command === "search_workspace_files") {
|
|
return await searchWorkspaceFiles(ctx, args);
|
|
}
|
|
|
|
// ── Cline account ──────────────────────────────────────────────────
|
|
if (command === "cline_account") {
|
|
const operation = String(args?.operation ?? "").trim();
|
|
if (!operation) throw new Error("operation is required");
|
|
const manager = new ProviderSettingsManager();
|
|
const settings = manager.getProviderSettings("cline");
|
|
const accountService = new ClineAccountService({
|
|
apiBaseUrl:
|
|
settings?.baseUrl?.trim() || getClineEnvironmentConfig().apiBaseUrl,
|
|
getAuthToken: async () => resolveLocalClineAuthToken(settings),
|
|
});
|
|
return await executeClineAccountAction(
|
|
args as ClineAccountActionRequest,
|
|
accountService,
|
|
);
|
|
}
|
|
|
|
// ── Provider management ────────────────────────────────────────────
|
|
if (command === "list_provider_catalog") {
|
|
const manager = new ProviderSettingsManager();
|
|
await ensureCustomProvidersLoaded(manager);
|
|
return await listLocalProviders(manager);
|
|
}
|
|
if (command === "list_provider_models") {
|
|
const manager = new ProviderSettingsManager();
|
|
return await getLocalProviderModels(
|
|
String(args?.provider ?? ""),
|
|
manager.getProviderConfig(String(args?.provider ?? "").trim()),
|
|
);
|
|
}
|
|
if (command === "save_provider_settings") {
|
|
const manager = new ProviderSettingsManager();
|
|
return saveLocalProviderSettings(manager, {
|
|
...readProviderSettingsUpdate(args),
|
|
providerId: String(args?.provider ?? ""),
|
|
enabled: typeof args?.enabled === "boolean" ? args.enabled : undefined,
|
|
apiKey: typeof args?.api_key === "string" ? args.api_key : undefined,
|
|
baseUrl: typeof args?.base_url === "string" ? args.base_url : undefined,
|
|
});
|
|
}
|
|
if (command === "add_provider") {
|
|
const manager = new ProviderSettingsManager();
|
|
await ensureCustomProvidersLoaded(manager);
|
|
return await addLocalProvider(manager, {
|
|
providerId: String(args?.provider_id ?? ""),
|
|
name: String(args?.name ?? ""),
|
|
baseUrl: String(args?.base_url ?? ""),
|
|
apiKey: typeof args?.api_key === "string" ? args.api_key : undefined,
|
|
headers:
|
|
args?.headers && typeof args.headers === "object"
|
|
? (args.headers as Record<string, string>)
|
|
: undefined,
|
|
timeoutMs:
|
|
typeof args?.timeout_ms === "number" ? args.timeout_ms : undefined,
|
|
models: Array.isArray(args?.models)
|
|
? (args.models as string[])
|
|
: undefined,
|
|
defaultModelId:
|
|
typeof args?.default_model_id === "string"
|
|
? args.default_model_id
|
|
: undefined,
|
|
modelsSourceUrl:
|
|
typeof args?.models_source_url === "string"
|
|
? args.models_source_url
|
|
: undefined,
|
|
protocol:
|
|
typeof args?.protocol === "string"
|
|
? (args.protocol as ProviderProtocol)
|
|
: undefined,
|
|
client:
|
|
typeof args?.client === "string"
|
|
? (args.client as ProviderClient)
|
|
: undefined,
|
|
capabilities: Array.isArray(args?.capabilities)
|
|
? (args.capabilities as ProviderCapability[])
|
|
: undefined,
|
|
});
|
|
}
|
|
if (command === "run_provider_oauth_login") {
|
|
const providerId = normalizeOAuthProvider(String(args?.provider ?? ""));
|
|
const manager = new ProviderSettingsManager();
|
|
const saved = await loginAndSaveLocalProviderOAuthCredentials(
|
|
manager,
|
|
providerId,
|
|
(url) => {
|
|
const platform = process.platform;
|
|
const spawned =
|
|
platform === "darwin"
|
|
? spawn("open", [url], { stdio: "ignore", detached: true })
|
|
: platform === "win32"
|
|
? spawn("cmd", ["/c", "start", "", url], {
|
|
stdio: "ignore",
|
|
detached: true,
|
|
})
|
|
: spawn("xdg-open", [url], {
|
|
stdio: "ignore",
|
|
detached: true,
|
|
});
|
|
spawned.unref();
|
|
},
|
|
);
|
|
return {
|
|
provider: providerId,
|
|
accessToken: saved.auth?.accessToken ?? saved.apiKey ?? "",
|
|
};
|
|
}
|
|
|
|
// ── MCP server management ─────────────────────────────────────────
|
|
if (command === "list_mcp_servers") {
|
|
return readMcpServersResponse();
|
|
}
|
|
if (command === "set_mcp_server_disabled") {
|
|
const path = ensureMcpSettingsFile();
|
|
updateMcpSettingsFileSync(path, (settings) => {
|
|
const servers = ((settings.mcpServers as JsonRecord | undefined) ??
|
|
{}) as JsonRecord;
|
|
const name = String(args?.name ?? "").trim();
|
|
const current = servers[name];
|
|
if (!current || typeof current !== "object") {
|
|
throw new Error(`unknown MCP server: ${name}`);
|
|
}
|
|
servers[name] = {
|
|
...(current as JsonRecord),
|
|
disabled: Boolean(args?.disabled),
|
|
};
|
|
settings.mcpServers = servers;
|
|
});
|
|
return readMcpServersResponse();
|
|
}
|
|
if (command === "upsert_mcp_server") {
|
|
const input =
|
|
args?.input && typeof args.input === "object"
|
|
? (args.input as JsonRecord)
|
|
: (args as JsonRecord);
|
|
const name = String(input.name ?? "").trim();
|
|
if (!name) throw new Error("server name is required");
|
|
const previousName = String(
|
|
input.previousName ?? input.previous_name ?? "",
|
|
).trim();
|
|
const transportType = String(
|
|
input.transportType ?? input.transport_type ?? "",
|
|
).trim();
|
|
const next: JsonRecord =
|
|
transportType === "stdio"
|
|
? {
|
|
transport: {
|
|
type: "stdio",
|
|
command: input.command,
|
|
args: input.args,
|
|
cwd: input.cwd,
|
|
env: input.env,
|
|
},
|
|
disabled: Boolean(input.disabled),
|
|
metadata: input.metadata,
|
|
}
|
|
: {
|
|
transport: {
|
|
type: transportType,
|
|
url: input.url,
|
|
headers: input.headers,
|
|
},
|
|
disabled: Boolean(input.disabled),
|
|
metadata: input.metadata,
|
|
};
|
|
const path = ensureMcpSettingsFile();
|
|
updateMcpSettingsFileSync(path, (settings) => {
|
|
const servers = ((settings.mcpServers as JsonRecord | undefined) ??
|
|
{}) as JsonRecord;
|
|
if (previousName && previousName !== name) {
|
|
delete servers[previousName];
|
|
}
|
|
servers[name] = next;
|
|
settings.mcpServers = servers;
|
|
});
|
|
return readMcpServersResponse();
|
|
}
|
|
if (command === "delete_mcp_server") {
|
|
const path = ensureMcpSettingsFile();
|
|
updateMcpSettingsFileSync(path, (settings) => {
|
|
const servers = ((settings.mcpServers as JsonRecord | undefined) ??
|
|
{}) as JsonRecord;
|
|
delete servers[String(args?.name ?? "")];
|
|
settings.mcpServers = servers;
|
|
});
|
|
return readMcpServersResponse();
|
|
}
|
|
if (command === "ensure_mcp_settings_file") {
|
|
return ensureMcpSettingsFile();
|
|
}
|
|
|
|
// ── Git operations ─────────────────────────────────────────────────
|
|
if (command === "get_git_branch") {
|
|
const branches = listGitBranches(
|
|
ctx,
|
|
typeof args?.cwd === "string" ? args.cwd : undefined,
|
|
);
|
|
return { branch: branches.current };
|
|
}
|
|
if (command === "list_git_branches") {
|
|
return listGitBranches(
|
|
ctx,
|
|
typeof args?.cwd === "string" ? args.cwd : undefined,
|
|
);
|
|
}
|
|
if (command === "checkout_git_branch") {
|
|
const cwd = typeof args?.cwd === "string" ? args.cwd : undefined;
|
|
const branch = String(args?.branch ?? "").trim();
|
|
if (!branch) throw new Error("branch is required");
|
|
execFileSync("git", ["checkout", branch], {
|
|
cwd: cwd?.trim() || ctx.workspaceRoot,
|
|
encoding: "utf8",
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
return { branch };
|
|
}
|
|
|
|
// ── Routine schedules ─────────────────────────────────────────────
|
|
if (
|
|
command === "list_routine_schedules" ||
|
|
command === "create_routine_schedule" ||
|
|
command === "pause_routine_schedule" ||
|
|
command === "resume_routine_schedule" ||
|
|
command === "trigger_routine_schedule" ||
|
|
command === "delete_routine_schedule"
|
|
) {
|
|
return await handleRoutineScheduleCommand(command, args);
|
|
}
|
|
|
|
// ── User instruction configs ──────────────────────────────────────
|
|
if (command === "list_user_instruction_configs") {
|
|
return await listUserInstructionConfigs(ctx.workspaceRoot);
|
|
}
|
|
if (command === "toggle_disabled_plugin_tool") {
|
|
const toolName = String(args?.name ?? "").trim();
|
|
if (!toolName) {
|
|
throw new Error("tool name is required");
|
|
}
|
|
toggleDisabledTool(toolName);
|
|
return await listUserInstructionConfigs(ctx.workspaceRoot);
|
|
}
|
|
if (command === "set_tool_disabled") {
|
|
const rawNames = Array.isArray(args?.names) ? args.names : [args?.name];
|
|
const toolNames = rawNames
|
|
.map((name) => String(name ?? "").trim())
|
|
.filter(Boolean);
|
|
if (toolNames.length === 0) {
|
|
throw new Error("tool name is required");
|
|
}
|
|
setDisabledTools(toolNames, args?.disabled === true);
|
|
return await listUserInstructionConfigs(ctx.workspaceRoot);
|
|
}
|
|
if (command === "set_plugin_disabled") {
|
|
const pluginPath = String(args?.path ?? "").trim();
|
|
if (!pluginPath) {
|
|
throw new Error("plugin path is required");
|
|
}
|
|
setDisabledPlugin(pluginPath, args?.disabled === true);
|
|
return await listUserInstructionConfigs(ctx.workspaceRoot);
|
|
}
|
|
|
|
// ── Native OS commands ────────────────────────────────────────────
|
|
if (command === "pick_workspace_directory") {
|
|
return pickWorkspaceDirectory();
|
|
}
|
|
if (command === "open_mcp_settings_file") {
|
|
const path = ensureMcpSettingsFile();
|
|
openFileInEditor(path);
|
|
return path;
|
|
}
|
|
|
|
throw new Error(`unsupported desktop command: ${command}`);
|
|
}
|