mirror of
https://github.com/cline/cline.git
synced 2026-09-01 23:19:18 +08:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 54cd7fa3ee | |||
| 1f888a65e8 | |||
| 05c2b4bbcf | |||
| 676b446d47 | |||
| a8835425bf |
@@ -1,5 +1,20 @@
|
||||
# Changelog
|
||||
|
||||
## [3.88.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Add the latest Fireworks AI serverless models and update the default Fireworks model to Kimi K2.6.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix MCP server delete/add flows so settings writes do not cause the MCP server list to be emptied by the file watcher.
|
||||
- Remove stale Fireworks AI models and correct Fireworks model metadata and cache pricing.
|
||||
|
||||
### Changed
|
||||
|
||||
- Always use the upstream Cline recommended models endpoint instead of gating it behind a feature flag.
|
||||
|
||||
## [3.87.0]
|
||||
|
||||
### Added
|
||||
|
||||
@@ -153,6 +153,137 @@ describe("runDoctorCommand", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("reports hub daemon processes that are not the active discovered hub", async () => {
|
||||
const cwd = "/workspace";
|
||||
mockReadHubDiscovery.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
port: 25463,
|
||||
pid: 50174,
|
||||
});
|
||||
mockProbeHubServer.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
port: 25463,
|
||||
pid: 50174,
|
||||
});
|
||||
mockSpawnSync.mockImplementation((command: string, args?: string[]) => {
|
||||
if (command === "lsof") {
|
||||
return { status: 0, stdout: "50174\n" };
|
||||
}
|
||||
if (
|
||||
command === "pgrep" &&
|
||||
Array.isArray(args) &&
|
||||
args[0] === "-fal" &&
|
||||
args[1] === "cline-hub-daemon"
|
||||
) {
|
||||
return {
|
||||
status: 0,
|
||||
stdout: [
|
||||
"50174 /usr/local/bin/cline --cline-hub-daemon --cwd /workspace",
|
||||
"50190 /usr/local/bin/cline --cline-hub-daemon --cwd /other",
|
||||
].join("\n"),
|
||||
};
|
||||
}
|
||||
if (
|
||||
command === "pgrep" &&
|
||||
Array.isArray(args) &&
|
||||
args[0] === "-fal" &&
|
||||
args[1] === "/hub/daemon/entry.ts"
|
||||
) {
|
||||
return {
|
||||
status: 0,
|
||||
stdout:
|
||||
"50191 /Users/example/.bun/bin/bun /repo/sdk/packages/core/src/hub/daemon/entry.ts --cwd /repo",
|
||||
};
|
||||
}
|
||||
return { status: 1, stdout: "" };
|
||||
});
|
||||
|
||||
const output: string[] = [];
|
||||
const code = await runDoctorCommand(
|
||||
{ cwd, json: true },
|
||||
{
|
||||
writeln: (text) => {
|
||||
output.push(text ?? "");
|
||||
},
|
||||
writeErr: () => {},
|
||||
},
|
||||
);
|
||||
|
||||
expect(code).toBe(0);
|
||||
const status = JSON.parse(output[0] || "");
|
||||
expect(
|
||||
status.orphanedHubDaemonProcesses.map(
|
||||
(record: { pid: number }) => record.pid,
|
||||
),
|
||||
).toEqual(process.platform === "win32" ? [] : [50190, 50191]);
|
||||
});
|
||||
|
||||
it("reports unmanaged connector processes for every registered connector", async () => {
|
||||
const cwd = "/workspace";
|
||||
mockReadHubDiscovery.mockResolvedValue(undefined);
|
||||
mockProbeHubServer.mockResolvedValue(undefined);
|
||||
mockSpawnSync.mockImplementation((command: string, args?: string[]) => {
|
||||
if (
|
||||
command === "pgrep" &&
|
||||
Array.isArray(args) &&
|
||||
args[0] === "-fal" &&
|
||||
args[1] === "connect"
|
||||
) {
|
||||
return {
|
||||
status: 0,
|
||||
stdout: [
|
||||
"61001 cline connect slack --bot-token xoxb-secret --signing-secret=super-signature -i",
|
||||
"61002 cline connect discord --bot-token discord-secret -i",
|
||||
"61003 cline connect gchat --service-account-key /tmp/gchat-key.json -i",
|
||||
"61004 cline connect linear --api-key lin-secret -i",
|
||||
"61005 cline connect telegram --bot-token telegram-secret -i",
|
||||
"61006 cline connect whatsapp --access-token whatsapp-secret -i",
|
||||
"61007 cline something-else connect nope",
|
||||
"61008 /usr/bin/some-tool connect slack --bot-token unrelated-secret",
|
||||
"61009 node ./connect slack-proxy.js --bot-token unrelated-secret",
|
||||
].join("\n"),
|
||||
};
|
||||
}
|
||||
return { status: 1, stdout: "" };
|
||||
});
|
||||
|
||||
const output: string[] = [];
|
||||
const code = await runDoctorCommand(
|
||||
{ cwd, json: true },
|
||||
{
|
||||
writeln: (text) => {
|
||||
output.push(text ?? "");
|
||||
},
|
||||
writeErr: () => {},
|
||||
},
|
||||
);
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(output).toHaveLength(1);
|
||||
const status = JSON.parse(output[0] || "");
|
||||
expect(
|
||||
status.unmanagedConnectorProcesses.map(
|
||||
(record: { type: string }) => record.type,
|
||||
),
|
||||
).toEqual(["discord", "gchat", "linear", "slack", "telegram", "whatsapp"]);
|
||||
expect(
|
||||
status.unmanagedConnectorProcesses.map(
|
||||
(record: { pid: number }) => record.pid,
|
||||
),
|
||||
).toEqual([61002, 61003, 61004, 61001, 61005, 61006]);
|
||||
expect(JSON.stringify(status.unmanagedConnectorProcesses)).not.toContain(
|
||||
"xoxb-secret",
|
||||
);
|
||||
expect(JSON.stringify(status.unmanagedConnectorProcesses)).not.toContain(
|
||||
"super-signature",
|
||||
);
|
||||
expect(
|
||||
status.unmanagedConnectorProcesses.find(
|
||||
(record: { pid: number }) => record.pid === 61001,
|
||||
)?.command,
|
||||
).toContain("--signing-secret=[REDACTED]");
|
||||
});
|
||||
|
||||
it("doctor --fix clears wedged hub startup artifacts when no server is actually running", async () => {
|
||||
const cwd = mkdtempSync(path.join(os.tmpdir(), "doctor-hub-fix-"));
|
||||
tempDirs.push(cwd);
|
||||
@@ -252,6 +383,193 @@ describe("runDoctorCommand", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("doctor --fix kills orphaned hub daemon processes", async () => {
|
||||
const cwd = "/workspace";
|
||||
mockReadHubDiscovery.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
port: 25463,
|
||||
pid: 50174,
|
||||
});
|
||||
mockProbeHubServer.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
port: 25463,
|
||||
pid: 50174,
|
||||
});
|
||||
mockSpawnSync.mockImplementation((command: string, args?: string[]) => {
|
||||
if (command === "lsof") {
|
||||
return { status: 0, stdout: "50174\n" };
|
||||
}
|
||||
if (
|
||||
command === "pgrep" &&
|
||||
Array.isArray(args) &&
|
||||
args[0] === "-fal" &&
|
||||
args[1] === "cline-hub-daemon"
|
||||
) {
|
||||
return {
|
||||
status: 0,
|
||||
stdout: [
|
||||
"50174 /usr/local/bin/cline --cline-hub-daemon --cwd /workspace",
|
||||
"50190 /usr/local/bin/cline --cline-hub-daemon --cwd /other",
|
||||
].join("\n"),
|
||||
};
|
||||
}
|
||||
return { status: 1, stdout: "" };
|
||||
});
|
||||
mockStopLocalHubServerGracefully.mockResolvedValue(true);
|
||||
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true);
|
||||
|
||||
const output: string[] = [];
|
||||
const code = await runDoctorCommand(
|
||||
{ cwd, json: true, fix: true },
|
||||
{
|
||||
writeln: (text) => {
|
||||
output.push(text ?? "");
|
||||
},
|
||||
writeErr: () => {},
|
||||
},
|
||||
);
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(killSpy).toHaveBeenCalledWith(50190, "SIGKILL");
|
||||
expect(killSpy).not.toHaveBeenCalledWith(50174, "SIGKILL");
|
||||
expect(JSON.parse(output[0] || "")).toMatchObject({
|
||||
before: {
|
||||
orphanedHubDaemonProcesses:
|
||||
process.platform === "win32" ? [] : [{ pid: 50190 }],
|
||||
},
|
||||
killed: {
|
||||
orphanedHubDaemons: process.platform === "win32" ? 0 : 1,
|
||||
},
|
||||
});
|
||||
killSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("doctor --fix kills a previously active hub if it becomes orphaned after graceful stop", async () => {
|
||||
const cwd = "/workspace";
|
||||
let collection = 0;
|
||||
mockReadHubDiscovery.mockImplementation(async () => {
|
||||
collection += 1;
|
||||
return collection === 1
|
||||
? {
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
port: 25463,
|
||||
pid: 50174,
|
||||
}
|
||||
: {
|
||||
url: "ws://127.0.0.1:25464/hub",
|
||||
port: 25464,
|
||||
pid: 50190,
|
||||
};
|
||||
});
|
||||
mockProbeHubServer.mockImplementation(async (url: string) => {
|
||||
return url.includes(":25463/")
|
||||
? {
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
port: 25463,
|
||||
pid: 50174,
|
||||
}
|
||||
: {
|
||||
url: "ws://127.0.0.1:25464/hub",
|
||||
port: 25464,
|
||||
pid: 50190,
|
||||
};
|
||||
});
|
||||
mockSpawnSync.mockImplementation((command: string, args?: string[]) => {
|
||||
if (command === "lsof") {
|
||||
return {
|
||||
status: 0,
|
||||
stdout: args?.includes("-tiTCP:25463") ? "50174\n" : "50190\n",
|
||||
};
|
||||
}
|
||||
if (
|
||||
command === "pgrep" &&
|
||||
Array.isArray(args) &&
|
||||
args[0] === "-fal" &&
|
||||
args[1] === "cline-hub-daemon"
|
||||
) {
|
||||
return {
|
||||
status: 0,
|
||||
stdout: [
|
||||
"50174 /usr/local/bin/cline --cline-hub-daemon --cwd /old",
|
||||
"50190 /usr/local/bin/cline --cline-hub-daemon --cwd /new",
|
||||
].join("\n"),
|
||||
};
|
||||
}
|
||||
return { status: 1, stdout: "" };
|
||||
});
|
||||
mockStopLocalHubServerGracefully.mockResolvedValue(true);
|
||||
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true);
|
||||
|
||||
const output: string[] = [];
|
||||
const code = await runDoctorCommand(
|
||||
{ cwd, json: true, fix: true },
|
||||
{
|
||||
writeln: (text) => {
|
||||
output.push(text ?? "");
|
||||
},
|
||||
writeErr: () => {},
|
||||
},
|
||||
);
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(killSpy).toHaveBeenCalledWith(50174, "SIGKILL");
|
||||
expect(killSpy).not.toHaveBeenCalledWith(50190, "SIGKILL");
|
||||
expect(JSON.parse(output[0] || "")).toMatchObject({
|
||||
killed: {
|
||||
orphanedHubDaemons: process.platform === "win32" ? 0 : 1,
|
||||
},
|
||||
});
|
||||
killSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("doctor --fix kills unmanaged connector processes discovered from process args", async () => {
|
||||
const cwd = "/workspace";
|
||||
mockReadHubDiscovery.mockResolvedValue(undefined);
|
||||
mockProbeHubServer.mockResolvedValue(undefined);
|
||||
mockSpawnSync.mockImplementation((command: string, args?: string[]) => {
|
||||
if (
|
||||
command === "pgrep" &&
|
||||
Array.isArray(args) &&
|
||||
args[0] === "-fal" &&
|
||||
args[1] === "connect"
|
||||
) {
|
||||
return {
|
||||
status: 0,
|
||||
stdout: [
|
||||
"62001 cline connect slack --bot-token xoxb-secret -i",
|
||||
"62002 /usr/bin/some-tool connect slack --bot-token unrelated-secret",
|
||||
].join("\n"),
|
||||
};
|
||||
}
|
||||
return { status: 1, stdout: "" };
|
||||
});
|
||||
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true);
|
||||
|
||||
const output: string[] = [];
|
||||
const code = await runDoctorCommand(
|
||||
{ cwd, json: true, fix: true },
|
||||
{
|
||||
writeln: (text) => {
|
||||
output.push(text ?? "");
|
||||
},
|
||||
writeErr: () => {},
|
||||
},
|
||||
);
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(killSpy).toHaveBeenCalledWith(62001, "SIGKILL");
|
||||
expect(killSpy).not.toHaveBeenCalledWith(62002, "SIGKILL");
|
||||
expect(JSON.parse(output[0] || "")).toMatchObject({
|
||||
before: {
|
||||
unmanagedConnectorProcesses: [{ pid: 62001, type: "slack" }],
|
||||
},
|
||||
killed: {
|
||||
unmanagedConnectorProcesses: 1,
|
||||
},
|
||||
});
|
||||
killSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("doctor --fix kills stale code sidecar processes", async () => {
|
||||
const cwd = "/workspace";
|
||||
mockReadHubDiscovery.mockResolvedValue(undefined);
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { formatUptime } from "@cline/shared";
|
||||
import { Command } from "commander";
|
||||
import open from "open";
|
||||
import { listConnectorCatalog } from "../connectors/catalog";
|
||||
import { isProcessRunning } from "../connectors/common";
|
||||
import {
|
||||
type ActiveConnectorRecord,
|
||||
@@ -46,6 +47,17 @@ type SpawnedProcessRecord = {
|
||||
detached?: boolean;
|
||||
};
|
||||
|
||||
type UnmanagedConnectorProcessRecord = {
|
||||
pid: number;
|
||||
type: string;
|
||||
command: string;
|
||||
};
|
||||
|
||||
type HubDaemonProcessRecord = {
|
||||
pid: number;
|
||||
command: string;
|
||||
};
|
||||
|
||||
type DoctorStatus = {
|
||||
cwd: string;
|
||||
hubUrl?: string;
|
||||
@@ -54,10 +66,12 @@ type DoctorStatus = {
|
||||
hubStartedAt?: string;
|
||||
hubUptime?: string;
|
||||
listeningPids: number[];
|
||||
orphanedHubDaemonProcesses: HubDaemonProcessRecord[];
|
||||
hubStartupLocks: StartupArtifact[];
|
||||
staleCliPids: number[];
|
||||
staleSidecarPids: number[];
|
||||
activeConnectors: ActiveConnectorRecord[];
|
||||
unmanagedConnectorProcesses: UnmanagedConnectorProcessRecord[];
|
||||
recentSpawnedProcesses: SpawnedProcessRecord[];
|
||||
};
|
||||
|
||||
@@ -148,6 +162,144 @@ function listStaleCliPids(): number[] {
|
||||
.map((record) => record.pid);
|
||||
}
|
||||
|
||||
function listHubDaemonProcesses(): ProcessRecord[] {
|
||||
const patterns = ["cline-hub-daemon", "/hub/daemon/entry.ts"];
|
||||
const records = new Map<number, ProcessRecord>();
|
||||
for (const pattern of patterns) {
|
||||
for (const record of listMatchingProcesses(pattern)) {
|
||||
records.set(record.pid, record);
|
||||
}
|
||||
}
|
||||
return [...records.values()].sort((a, b) => a.pid - b.pid);
|
||||
}
|
||||
|
||||
function listOrphanedHubDaemonProcesses(options: {
|
||||
hubHealthy: boolean;
|
||||
hubPid?: number;
|
||||
listeningPids: number[];
|
||||
}): HubDaemonProcessRecord[] {
|
||||
const protectedPids = new Set<number>();
|
||||
if (options.hubHealthy) {
|
||||
if (options.hubPid) {
|
||||
protectedPids.add(options.hubPid);
|
||||
}
|
||||
for (const pid of options.listeningPids) {
|
||||
protectedPids.add(pid);
|
||||
}
|
||||
}
|
||||
return listHubDaemonProcesses()
|
||||
.filter((record) => !protectedPids.has(record.pid))
|
||||
.map((record) => ({
|
||||
pid: record.pid,
|
||||
command: redactSensitiveProcessArgs(record.command),
|
||||
}));
|
||||
}
|
||||
|
||||
function redactSensitiveProcessArgs(command: string): string {
|
||||
return command.replace(
|
||||
/(--[^\s=]*(?:token|secret|password|key)[^\s=]*)(=|\s+)(?:"[^"]*"|'[^']*'|[^\s]+)/gi,
|
||||
"$1$2[REDACTED]",
|
||||
);
|
||||
}
|
||||
|
||||
function processCommandTokens(command: string): string[] {
|
||||
return command
|
||||
.split(/\s+/)
|
||||
.map((token) => token.trim().replace(/^["']|["']$/g, ""))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function tokenBasename(token: string): string {
|
||||
const normalized = token.replace(/\\/g, "/");
|
||||
return normalized.slice(normalized.lastIndexOf("/") + 1);
|
||||
}
|
||||
|
||||
function isRuntimeLauncherToken(token: string): boolean {
|
||||
const basename = tokenBasename(token).toLowerCase();
|
||||
return basename === "bun" || basename === "node" || basename === "env";
|
||||
}
|
||||
|
||||
function isClineCliEntrypointToken(token: string): boolean {
|
||||
const normalized = token.replace(/\\/g, "/");
|
||||
const basename = tokenBasename(normalized).toLowerCase();
|
||||
return (
|
||||
basename === "cline" ||
|
||||
normalized.includes("/apps/cli/src/index.ts") ||
|
||||
normalized.includes("/apps/cli/dist/index.js") ||
|
||||
normalized.includes("/dist/cline")
|
||||
);
|
||||
}
|
||||
|
||||
function isClineCliPrefix(tokens: string[], connectIndex: number): boolean {
|
||||
const entrypointIndex = connectIndex - 1;
|
||||
if (entrypointIndex < 0) {
|
||||
return false;
|
||||
}
|
||||
if (!isClineCliEntrypointToken(tokens[entrypointIndex] ?? "")) {
|
||||
return false;
|
||||
}
|
||||
if (entrypointIndex === 0) {
|
||||
return true;
|
||||
}
|
||||
const launcher = tokens[0];
|
||||
if (!launcher || !isRuntimeLauncherToken(launcher)) {
|
||||
return false;
|
||||
}
|
||||
return tokens
|
||||
.slice(1, entrypointIndex)
|
||||
.every((token) => token.startsWith("-"));
|
||||
}
|
||||
|
||||
function parseClineConnectorProcessType(
|
||||
command: string,
|
||||
connectorNames: Set<string>,
|
||||
): string | undefined {
|
||||
const tokens = processCommandTokens(command);
|
||||
for (let index = 0; index < tokens.length - 1; index += 1) {
|
||||
if (tokens[index] !== "connect" || !isClineCliPrefix(tokens, index)) {
|
||||
continue;
|
||||
}
|
||||
const type = tokens[index + 1]?.toLowerCase();
|
||||
if (type && connectorNames.has(type)) {
|
||||
return type;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function listUnmanagedConnectorProcesses(
|
||||
activeConnectors: ActiveConnectorRecord[],
|
||||
): UnmanagedConnectorProcessRecord[] {
|
||||
const connectorNames = new Set(
|
||||
listConnectorCatalog().map((connector) => connector.name.toLowerCase()),
|
||||
);
|
||||
if (connectorNames.size === 0) {
|
||||
return [];
|
||||
}
|
||||
const activePids = new Set(activeConnectors.map((record) => record.pid));
|
||||
const records = new Map<number, UnmanagedConnectorProcessRecord>();
|
||||
for (const record of listMatchingProcesses("connect")) {
|
||||
if (activePids.has(record.pid)) {
|
||||
continue;
|
||||
}
|
||||
const type = parseClineConnectorProcessType(record.command, connectorNames);
|
||||
if (!type) {
|
||||
continue;
|
||||
}
|
||||
records.set(record.pid, {
|
||||
pid: record.pid,
|
||||
type,
|
||||
command: redactSensitiveProcessArgs(record.command),
|
||||
});
|
||||
}
|
||||
return [...records.values()].sort((a, b) => {
|
||||
if (a.type !== b.type) {
|
||||
return a.type.localeCompare(b.type);
|
||||
}
|
||||
return a.pid - b.pid;
|
||||
});
|
||||
}
|
||||
|
||||
function listStaleSidecarPids(): number[] {
|
||||
const patterns = [
|
||||
"/apps/examples/desktop-app/sidecar/index.ts",
|
||||
@@ -299,18 +451,29 @@ async function collectDoctorStatus(cwd: string): Promise<DoctorStatus> {
|
||||
: undefined;
|
||||
const current = health ?? discovery;
|
||||
const hubUptime = formatHubUptimeFromStartedAt(health?.startedAt);
|
||||
const activeConnectors = listActiveConnectors();
|
||||
const listeningPids = listListeningPids(current?.port);
|
||||
const hubHealthy = !!health?.url;
|
||||
const hubPid = current?.pid;
|
||||
return {
|
||||
cwd,
|
||||
hubUrl: current?.url,
|
||||
hubHealthy: !!health?.url,
|
||||
hubPid: current?.pid,
|
||||
hubHealthy,
|
||||
hubPid,
|
||||
hubStartedAt: health?.startedAt,
|
||||
hubUptime,
|
||||
listeningPids: listListeningPids(current?.port),
|
||||
listeningPids,
|
||||
orphanedHubDaemonProcesses: listOrphanedHubDaemonProcesses({
|
||||
hubHealthy,
|
||||
hubPid,
|
||||
listeningPids,
|
||||
}),
|
||||
hubStartupLocks: listHubStartupLocks(cwd),
|
||||
staleCliPids: listStaleCliPids(),
|
||||
staleSidecarPids: listStaleSidecarPids(),
|
||||
activeConnectors: listActiveConnectors(),
|
||||
activeConnectors,
|
||||
unmanagedConnectorProcesses:
|
||||
listUnmanagedConnectorProcesses(activeConnectors),
|
||||
recentSpawnedProcesses: readRecentSpawnedProcesses(),
|
||||
};
|
||||
}
|
||||
@@ -355,6 +518,16 @@ function formatActiveConnector(record: ActiveConnectorRecord): string {
|
||||
return pieces.join(" | ");
|
||||
}
|
||||
|
||||
function formatUnmanagedConnectorProcess(
|
||||
record: UnmanagedConnectorProcessRecord,
|
||||
): string {
|
||||
return [record.type, `pid=${record.pid}`, record.command].join(" | ");
|
||||
}
|
||||
|
||||
function formatHubDaemonProcess(record: HubDaemonProcessRecord): string {
|
||||
return [`pid=${record.pid}`, record.command].join(" | ");
|
||||
}
|
||||
|
||||
function killPids(pids: number[]): number {
|
||||
let killed = 0;
|
||||
for (const pid of pids) {
|
||||
@@ -388,6 +561,14 @@ export async function runDoctorCommand(
|
||||
);
|
||||
writeln(`hub uptime ${c.dim}${before.hubUptime ?? "n/a"}${c.reset}`);
|
||||
writeln(formatPidList("hub listeners", before.listeningPids));
|
||||
if (before.orphanedHubDaemonProcesses.length === 0) {
|
||||
writeln(`orphaned hub daemons ${c.dim}0${c.reset}`);
|
||||
} else {
|
||||
writeln("orphaned hub daemons:");
|
||||
for (const record of before.orphanedHubDaemonProcesses) {
|
||||
writeln(`- ${c.dim}${formatHubDaemonProcess(record)}${c.reset}`);
|
||||
}
|
||||
}
|
||||
writeln(
|
||||
formatPidList(
|
||||
"hub startup locks",
|
||||
@@ -404,6 +585,17 @@ export async function runDoctorCommand(
|
||||
writeln(`- ${c.dim}${formatActiveConnector(record)}${c.reset}`);
|
||||
}
|
||||
}
|
||||
if (before.unmanagedConnectorProcesses.length > 0) {
|
||||
writeln("unmanaged connector processes:");
|
||||
for (const record of before.unmanagedConnectorProcesses) {
|
||||
writeln(
|
||||
`- ${c.dim}${formatUnmanagedConnectorProcess(record)}${c.reset}`,
|
||||
);
|
||||
}
|
||||
io.writeln(
|
||||
"\nThese connector-looking processes do not have valid connector state files. Run `cline doctor fix` to stop them.",
|
||||
);
|
||||
}
|
||||
if (verbose && before.recentSpawnedProcesses.length > 0) {
|
||||
writeln("recent spawned processes:");
|
||||
for (const record of before.recentSpawnedProcesses) {
|
||||
@@ -412,11 +604,12 @@ export async function runDoctorCommand(
|
||||
}
|
||||
if (
|
||||
before.listeningPids.length > 0 ||
|
||||
before.orphanedHubDaemonProcesses.length > 0 ||
|
||||
before.staleCliPids.length > 0 ||
|
||||
before.staleSidecarPids.length > 0
|
||||
) {
|
||||
io.writeln(
|
||||
"\nRun `cline doctor fix` to kill all stale local processes, including stale sidecars.",
|
||||
"\nRun `cline doctor fix` to kill all stale local processes, including stale hubs.",
|
||||
);
|
||||
}
|
||||
return 0;
|
||||
@@ -431,8 +624,21 @@ export async function runDoctorCommand(
|
||||
const killedHub = gracefullyStoppedHub
|
||||
? 0
|
||||
: killPids(refreshedAfterGracefulStop.listeningPids);
|
||||
const orphanedHubDaemonTargets = [
|
||||
...before.orphanedHubDaemonProcesses,
|
||||
...refreshedAfterGracefulStop.orphanedHubDaemonProcesses,
|
||||
]
|
||||
.map((record) => record.pid)
|
||||
.filter(
|
||||
(pid, index, pids) =>
|
||||
pids.indexOf(pid) === index &&
|
||||
!refreshedAfterGracefulStop.listeningPids.includes(pid),
|
||||
);
|
||||
const killedOrphanedHubDaemons = killPids(orphanedHubDaemonTargets);
|
||||
const staleCliTargets = before.staleCliPids.filter(
|
||||
(pid) => !refreshedAfterGracefulStop.listeningPids.includes(pid),
|
||||
(pid) =>
|
||||
!refreshedAfterGracefulStop.listeningPids.includes(pid) &&
|
||||
!orphanedHubDaemonTargets.includes(pid),
|
||||
);
|
||||
const killedCli = killPids(staleCliTargets);
|
||||
const staleSidecarTargets = before.staleSidecarPids.filter(
|
||||
@@ -441,6 +647,15 @@ export async function runDoctorCommand(
|
||||
!staleCliTargets.includes(pid),
|
||||
);
|
||||
const killedSidecars = killPids(staleSidecarTargets);
|
||||
const unmanagedConnectorTargets = before.unmanagedConnectorProcesses
|
||||
.map((record) => record.pid)
|
||||
.filter(
|
||||
(pid) =>
|
||||
!refreshedAfterGracefulStop.listeningPids.includes(pid) &&
|
||||
!staleCliTargets.includes(pid) &&
|
||||
!staleSidecarTargets.includes(pid),
|
||||
);
|
||||
const killedUnmanagedConnectors = killPids(unmanagedConnectorTargets);
|
||||
const stoppedConnectors = await stopAllConnectors({
|
||||
writeln: () => {},
|
||||
writeErr: () => {},
|
||||
@@ -459,8 +674,10 @@ export async function runDoctorCommand(
|
||||
after,
|
||||
killed: {
|
||||
hubListeners: killedHub,
|
||||
orphanedHubDaemons: killedOrphanedHubDaemons,
|
||||
cliProcesses: killedCli,
|
||||
sidecarProcesses: killedSidecars,
|
||||
unmanagedConnectorProcesses: killedUnmanagedConnectors,
|
||||
connectorProcesses: stoppedConnectors.stoppedProcesses,
|
||||
connectorSessions: stoppedConnectors.stoppedSessions,
|
||||
hubStartupLocks: clearedArtifacts.startupLocks,
|
||||
@@ -471,8 +688,14 @@ export async function runDoctorCommand(
|
||||
return 0;
|
||||
}
|
||||
writeln(`killed hub listeners ${c.dim}${killedHub}${c.reset}`);
|
||||
writeln(
|
||||
`killed orphaned hub daemons ${c.dim}${killedOrphanedHubDaemons}${c.reset}`,
|
||||
);
|
||||
writeln(`killed cli processes ${c.dim}${killedCli}${c.reset}`);
|
||||
writeln(`killed sidecar processes ${c.dim}${killedSidecars}${c.reset}`);
|
||||
writeln(
|
||||
`killed unmanaged connector processes ${c.dim}${killedUnmanagedConnectors}${c.reset}`,
|
||||
);
|
||||
writeln(
|
||||
`stopped connector processes ${c.dim}${stoppedConnectors.stoppedProcesses}${c.reset}`,
|
||||
);
|
||||
@@ -487,6 +710,12 @@ export async function runDoctorCommand(
|
||||
);
|
||||
writeln(`hub healthy after fix: ${after.hubHealthy ? "yes" : "no"}`);
|
||||
writeln(formatPidList("remaining hub listeners", after.listeningPids));
|
||||
writeln(
|
||||
formatPidList(
|
||||
"remaining orphaned hub daemons",
|
||||
after.orphanedHubDaemonProcesses.map((record) => record.pid),
|
||||
),
|
||||
);
|
||||
writeln(
|
||||
formatPidList(
|
||||
"remaining hub startup locks",
|
||||
@@ -495,6 +724,12 @@ export async function runDoctorCommand(
|
||||
);
|
||||
writeln(formatPidList("remaining cli processes", after.staleCliPids));
|
||||
writeln(formatPidList("remaining sidecar processes", after.staleSidecarPids));
|
||||
writeln(
|
||||
formatPidList(
|
||||
"remaining unmanaged connector processes",
|
||||
after.unmanagedConnectorProcesses.map((record) => record.pid),
|
||||
),
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@ import { createCliLoggerAdapter } from "../../logging/adapter";
|
||||
import {
|
||||
ensureCliHubServer,
|
||||
parseHubEndpointOverride,
|
||||
resolveDefaultCliRpcAddress,
|
||||
} from "../../utils/hub-runtime";
|
||||
import { createWorkspaceChatCommandHost } from "../../utils/plugin-chat-commands";
|
||||
import { ConnectorBase } from "../base";
|
||||
@@ -789,11 +788,7 @@ class DiscordConnector extends ConnectorBase<
|
||||
"--hook-command <command>",
|
||||
"Run a shell command for connector events",
|
||||
)
|
||||
.option(
|
||||
"--rpc-address <host:port>",
|
||||
"RPC address",
|
||||
process.env.CLINE_RPC_ADDRESS?.trim() || resolveDefaultCliRpcAddress(),
|
||||
)
|
||||
.option("--rpc-address <host:port>", "RPC address")
|
||||
.option("--host <host>", "Webhook listen host")
|
||||
.option("--port <port>", "Webhook listen port")
|
||||
.option(
|
||||
@@ -888,9 +883,7 @@ class DiscordConnector extends ConnectorBase<
|
||||
interactive: Boolean(opts.interactive),
|
||||
enableTools: Boolean(opts.enableTools),
|
||||
rpcAddress:
|
||||
opts.rpcAddress?.trim() ||
|
||||
process.env.CLINE_RPC_ADDRESS?.trim() ||
|
||||
resolveDefaultCliRpcAddress(),
|
||||
opts.rpcAddress?.trim() || process.env.CLINE_RPC_ADDRESS?.trim(),
|
||||
hookCommand:
|
||||
opts.hookCommand?.trim() ||
|
||||
process.env.CLINE_CONNECT_HOOK_COMMAND?.trim(),
|
||||
|
||||
@@ -15,7 +15,6 @@ import { createCliLoggerAdapter } from "../../logging/adapter";
|
||||
import {
|
||||
ensureCliHubServer,
|
||||
parseHubEndpointOverride,
|
||||
resolveDefaultCliRpcAddress,
|
||||
} from "../../utils/hub-runtime";
|
||||
import { createWorkspaceChatCommandHost } from "../../utils/plugin-chat-commands";
|
||||
import { ConnectorBase } from "../base";
|
||||
@@ -250,11 +249,7 @@ class GoogleChatConnector extends ConnectorBase<
|
||||
"--hook-command <command>",
|
||||
"Run a shell command for connector events",
|
||||
)
|
||||
.option(
|
||||
"--rpc-address <host:port>",
|
||||
"RPC address",
|
||||
process.env.CLINE_RPC_ADDRESS?.trim() || resolveDefaultCliRpcAddress(),
|
||||
)
|
||||
.option("--rpc-address <host:port>", "RPC address")
|
||||
.option("--host <host>", "Webhook listen host")
|
||||
.option("--port <port>", "Webhook listen port")
|
||||
.option("--base-url <url>", "Public base URL for webhook configuration")
|
||||
@@ -317,9 +312,7 @@ class GoogleChatConnector extends ConnectorBase<
|
||||
interactive: Boolean(opts.interactive),
|
||||
enableTools: Boolean(opts.enableTools),
|
||||
rpcAddress:
|
||||
opts.rpcAddress?.trim() ||
|
||||
process.env.CLINE_RPC_ADDRESS?.trim() ||
|
||||
resolveDefaultCliRpcAddress(),
|
||||
opts.rpcAddress?.trim() || process.env.CLINE_RPC_ADDRESS?.trim(),
|
||||
hookCommand:
|
||||
opts.hookCommand?.trim() ||
|
||||
process.env.CLINE_CONNECT_HOOK_COMMAND?.trim(),
|
||||
|
||||
@@ -11,7 +11,6 @@ import { createCliLoggerAdapter } from "../../logging/adapter";
|
||||
import {
|
||||
ensureCliHubServer,
|
||||
parseHubEndpointOverride,
|
||||
resolveDefaultCliRpcAddress,
|
||||
} from "../../utils/hub-runtime";
|
||||
import { createWorkspaceChatCommandHost } from "../../utils/plugin-chat-commands";
|
||||
import { ConnectorBase } from "../base";
|
||||
@@ -310,11 +309,7 @@ class LinearConnector extends ConnectorBase<
|
||||
"--hook-command <command>",
|
||||
"Run a shell command for connector events",
|
||||
)
|
||||
.option(
|
||||
"--rpc-address <host:port>",
|
||||
"RPC address",
|
||||
process.env.CLINE_RPC_ADDRESS?.trim() || resolveDefaultCliRpcAddress(),
|
||||
)
|
||||
.option("--rpc-address <host:port>", "RPC address")
|
||||
.option("--host <host>", "Webhook listen host")
|
||||
.option("--port <port>", "Webhook listen port")
|
||||
.option("--base-url <url>", "Public base URL for webhook configuration")
|
||||
@@ -397,9 +392,7 @@ class LinearConnector extends ConnectorBase<
|
||||
interactive: Boolean(opts.interactive),
|
||||
enableTools: Boolean(opts.enableTools),
|
||||
rpcAddress:
|
||||
opts.rpcAddress?.trim() ||
|
||||
process.env.CLINE_RPC_ADDRESS?.trim() ||
|
||||
resolveDefaultCliRpcAddress(),
|
||||
opts.rpcAddress?.trim() || process.env.CLINE_RPC_ADDRESS?.trim(),
|
||||
hookCommand:
|
||||
opts.hookCommand?.trim() ||
|
||||
process.env.CLINE_CONNECT_HOOK_COMMAND?.trim(),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ConnectSlackOptions } from "@cline/shared";
|
||||
import { type Message, ThreadImpl } from "chat";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { __test__, slackConnector } from "./slack";
|
||||
|
||||
const parseSlackArgs = (rawArgs: string[]): ConnectSlackOptions =>
|
||||
@@ -10,6 +10,16 @@ const parseSlackArgs = (rawArgs: string[]): ConnectSlackOptions =>
|
||||
}
|
||||
).parseArgs(rawArgs);
|
||||
|
||||
const originalClineRpcAddress = process.env.CLINE_RPC_ADDRESS;
|
||||
|
||||
afterEach(() => {
|
||||
if (originalClineRpcAddress === undefined) {
|
||||
delete process.env.CLINE_RPC_ADDRESS;
|
||||
} else {
|
||||
process.env.CLINE_RPC_ADDRESS = originalClineRpcAddress;
|
||||
}
|
||||
});
|
||||
|
||||
describe("slack binding lookup", () => {
|
||||
const participantKey = __test__.buildSlackParticipantKey("T123", "U123");
|
||||
|
||||
@@ -41,6 +51,7 @@ describe("slack binding lookup", () => {
|
||||
|
||||
it("uses socket mode when Slack args omit a base URL", () => {
|
||||
const previousBaseUrl = process.env.BASE_URL;
|
||||
delete process.env.CLINE_RPC_ADDRESS;
|
||||
delete process.env.BASE_URL;
|
||||
let options: ConnectSlackOptions;
|
||||
try {
|
||||
@@ -61,6 +72,28 @@ describe("slack binding lookup", () => {
|
||||
expect(options.connectionMode).toBe("socket");
|
||||
expect(options.baseUrl).toBeUndefined();
|
||||
expect(options.appToken).toBe("xapp-token");
|
||||
expect(options.rpcAddress).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses an explicit RPC address only when configured", () => {
|
||||
delete process.env.CLINE_RPC_ADDRESS;
|
||||
|
||||
expect(
|
||||
parseSlackArgs([
|
||||
"--bot-token",
|
||||
"xoxb-token",
|
||||
"--app-token",
|
||||
"xapp-token",
|
||||
"--rpc-address",
|
||||
"127.0.0.1:4317",
|
||||
]).rpcAddress,
|
||||
).toBe("127.0.0.1:4317");
|
||||
|
||||
process.env.CLINE_RPC_ADDRESS = "127.0.0.1:4318";
|
||||
expect(
|
||||
parseSlackArgs(["--bot-token", "xoxb-token", "--app-token", "xapp-token"])
|
||||
.rpcAddress,
|
||||
).toBe("127.0.0.1:4318");
|
||||
});
|
||||
|
||||
it("falls back to channel identity when a restarted connector gets a new thread id", () => {
|
||||
|
||||
@@ -19,7 +19,6 @@ import { createCliLoggerAdapter } from "../../logging/adapter";
|
||||
import {
|
||||
ensureCliHubServer,
|
||||
parseHubEndpointOverride,
|
||||
resolveDefaultCliRpcAddress,
|
||||
} from "../../utils/hub-runtime";
|
||||
import { createWorkspaceChatCommandHost } from "../../utils/plugin-chat-commands";
|
||||
import { ConnectorBase } from "../base";
|
||||
@@ -478,11 +477,7 @@ class SlackConnector extends ConnectorBase<
|
||||
"--hook-command <command>",
|
||||
"Run a shell command for connector events",
|
||||
)
|
||||
.option(
|
||||
"--rpc-address <host:port>",
|
||||
"RPC address",
|
||||
process.env.CLINE_RPC_ADDRESS?.trim() || resolveDefaultCliRpcAddress(),
|
||||
)
|
||||
.option("--rpc-address <host:port>", "RPC address")
|
||||
.option("--host <host>", "Webhook listen host")
|
||||
.option("--port <port>", "Webhook listen port")
|
||||
.option(
|
||||
@@ -590,9 +585,7 @@ class SlackConnector extends ConnectorBase<
|
||||
interactive: Boolean(opts.interactive),
|
||||
enableTools: Boolean(opts.enableTools),
|
||||
rpcAddress:
|
||||
opts.rpcAddress?.trim() ||
|
||||
process.env.CLINE_RPC_ADDRESS?.trim() ||
|
||||
resolveDefaultCliRpcAddress(),
|
||||
opts.rpcAddress?.trim() || process.env.CLINE_RPC_ADDRESS?.trim(),
|
||||
hookCommand:
|
||||
opts.hookCommand?.trim() ||
|
||||
process.env.CLINE_CONNECT_HOOK_COMMAND?.trim(),
|
||||
|
||||
@@ -15,7 +15,6 @@ import { createCliLoggerAdapter } from "../../logging/adapter";
|
||||
import {
|
||||
ensureCliHubServer,
|
||||
parseHubEndpointOverride,
|
||||
resolveDefaultCliRpcAddress,
|
||||
} from "../../utils/hub-runtime";
|
||||
import { createWorkspaceChatCommandHost } from "../../utils/plugin-chat-commands";
|
||||
import { ConnectorBase } from "../base";
|
||||
@@ -440,11 +439,7 @@ class TelegramConnector extends ConnectorBase<
|
||||
"--hook-command <command>",
|
||||
"Run a shell command for connector events",
|
||||
)
|
||||
.option(
|
||||
"--rpc-address <host:port>",
|
||||
"RPC address",
|
||||
process.env.CLINE_RPC_ADDRESS?.trim() || resolveDefaultCliRpcAddress(),
|
||||
)
|
||||
.option("--rpc-address <host:port>", "RPC address")
|
||||
.addHelpText(
|
||||
"after",
|
||||
[
|
||||
@@ -506,9 +501,7 @@ class TelegramConnector extends ConnectorBase<
|
||||
interactive: Boolean(opts.interactive),
|
||||
enableTools: opts.tools !== false,
|
||||
rpcAddress:
|
||||
opts.rpcAddress?.trim() ||
|
||||
process.env.CLINE_RPC_ADDRESS?.trim() ||
|
||||
resolveDefaultCliRpcAddress(),
|
||||
opts.rpcAddress?.trim() || process.env.CLINE_RPC_ADDRESS?.trim(),
|
||||
hookCommand: allowedUserId
|
||||
? buildTelegramAllowedUserHookCommand(
|
||||
normalizeAllowedTelegramUserId(allowedUserId),
|
||||
|
||||
@@ -15,7 +15,6 @@ import { createCliLoggerAdapter } from "../../logging/adapter";
|
||||
import {
|
||||
ensureCliHubServer,
|
||||
parseHubEndpointOverride,
|
||||
resolveDefaultCliRpcAddress,
|
||||
} from "../../utils/hub-runtime";
|
||||
import { createWorkspaceChatCommandHost } from "../../utils/plugin-chat-commands";
|
||||
import { ConnectorBase } from "../base";
|
||||
@@ -293,11 +292,7 @@ class WhatsAppConnector extends ConnectorBase<
|
||||
"--hook-command <command>",
|
||||
"Run a shell command for connector events",
|
||||
)
|
||||
.option(
|
||||
"--rpc-address <host:port>",
|
||||
"RPC address",
|
||||
process.env.CLINE_RPC_ADDRESS?.trim() || resolveDefaultCliRpcAddress(),
|
||||
)
|
||||
.option("--rpc-address <host:port>", "RPC address")
|
||||
.option("--host <host>", "Webhook listen host")
|
||||
.option("--port <port>", "Webhook listen port")
|
||||
.option("--base-url <url>", "Public base URL for webhook configuration")
|
||||
@@ -365,9 +360,7 @@ class WhatsAppConnector extends ConnectorBase<
|
||||
interactive: Boolean(opts.interactive),
|
||||
enableTools: Boolean(opts.enableTools),
|
||||
rpcAddress:
|
||||
opts.rpcAddress?.trim() ||
|
||||
process.env.CLINE_RPC_ADDRESS?.trim() ||
|
||||
resolveDefaultCliRpcAddress(),
|
||||
opts.rpcAddress?.trim() || process.env.CLINE_RPC_ADDRESS?.trim(),
|
||||
hookCommand:
|
||||
opts.hookCommand?.trim() ||
|
||||
process.env.CLINE_CONNECT_HOOK_COMMAND?.trim(),
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "claude-dev",
|
||||
"version": "3.87.0",
|
||||
"version": "3.88.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "claude-dev",
|
||||
"version": "3.87.0",
|
||||
"version": "3.88.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.37.0",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "claude-dev",
|
||||
"displayName": "Cline",
|
||||
"description": "Autonomous coding agent right in your IDE, capable of creating/editing files, running commands, using the browser, and more with your permission every step of the way.",
|
||||
"version": "3.87.0",
|
||||
"version": "3.88.0",
|
||||
"icon": "assets/icons/icon.png",
|
||||
"engines": {
|
||||
"vscode": "^1.84.0"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ExtensionMessage } from "@shared/ExtensionMessage"
|
||||
import { ResetStateRequest } from "@shared/proto/cline/state"
|
||||
import { UserOrganization } from "@shared/proto/index.cline"
|
||||
import type { ExtensionMessage } from "@shared/ExtensionMessage";
|
||||
import { isClineInternalTester } from "@shared/internal/account";
|
||||
import { ResetStateRequest } from "@shared/proto/cline/state";
|
||||
import type { UserOrganization } from "@shared/proto/index.cline";
|
||||
import {
|
||||
CheckCheck,
|
||||
FlaskConical,
|
||||
@@ -11,38 +12,53 @@ import {
|
||||
SquareMousePointer,
|
||||
SquareTerminal,
|
||||
Wrench,
|
||||
} from "lucide-react"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { useEvent } from "react-use"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { useClineAuth } from "@/context/ClineAuthContext"
|
||||
import { useExtensionState } from "@/context/ExtensionStateContext"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { StateServiceClient } from "@/services/grpc-client"
|
||||
import { isAdminOrOwner } from "../account/helpers"
|
||||
import { Tab, TabContent, TabList, TabTrigger } from "../common/Tab"
|
||||
import ViewHeader from "../common/ViewHeader"
|
||||
import SectionHeader from "./SectionHeader"
|
||||
import AboutSection from "./sections/AboutSection"
|
||||
import ApiConfigurationSection from "./sections/ApiConfigurationSection"
|
||||
import BrowserSettingsSection from "./sections/BrowserSettingsSection"
|
||||
import DebugSection from "./sections/DebugSection"
|
||||
import FeatureSettingsSection from "./sections/FeatureSettingsSection"
|
||||
import GeneralSettingsSection from "./sections/GeneralSettingsSection"
|
||||
import { RemoteConfigSection } from "./sections/RemoteConfigSection"
|
||||
import TerminalSettingsSection from "./sections/TerminalSettingsSection"
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useEvent } from "react-use";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { type ClineUser, useClineAuth } from "@/context/ClineAuthContext";
|
||||
import { useExtensionState } from "@/context/ExtensionStateContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { StateServiceClient } from "@/services/grpc-client";
|
||||
import { isAdminOrOwner } from "../account/helpers";
|
||||
import { Tab, TabContent, TabList, TabTrigger } from "../common/Tab";
|
||||
import ViewHeader from "../common/ViewHeader";
|
||||
import SectionHeader from "./SectionHeader";
|
||||
import AboutSection from "./sections/AboutSection";
|
||||
import ApiConfigurationSection from "./sections/ApiConfigurationSection";
|
||||
import BrowserSettingsSection from "./sections/BrowserSettingsSection";
|
||||
import DebugSection from "./sections/DebugSection";
|
||||
import FeatureSettingsSection from "./sections/FeatureSettingsSection";
|
||||
import GeneralSettingsSection from "./sections/GeneralSettingsSection";
|
||||
import { RemoteConfigSection } from "./sections/RemoteConfigSection";
|
||||
import TerminalSettingsSection from "./sections/TerminalSettingsSection";
|
||||
|
||||
const IS_DEV = process.env.IS_DEV
|
||||
const IS_DEV = process.env.IS_DEV;
|
||||
|
||||
// Tab definitions
|
||||
type SettingsTabID = "api-config" | "features" | "browser" | "terminal" | "general" | "about" | "debug" | "remote-config"
|
||||
type SettingsTabID =
|
||||
| "api-config"
|
||||
| "features"
|
||||
| "browser"
|
||||
| "terminal"
|
||||
| "general"
|
||||
| "about"
|
||||
| "debug"
|
||||
| "remote-config";
|
||||
interface SettingsTab {
|
||||
id: SettingsTabID
|
||||
name: string
|
||||
tooltipText: string
|
||||
headerText: string
|
||||
icon: LucideIcon
|
||||
hidden?: (params?: { activeOrganization: UserOrganization | null }) => boolean
|
||||
id: SettingsTabID;
|
||||
name: string;
|
||||
tooltipText: string;
|
||||
headerText: string;
|
||||
icon: LucideIcon;
|
||||
hidden?: (params?: {
|
||||
user: ClineUser | null;
|
||||
activeOrganization: UserOrganization | null;
|
||||
}) => boolean;
|
||||
}
|
||||
|
||||
export const SETTINGS_TABS: SettingsTab[] = [
|
||||
@@ -87,8 +103,9 @@ export const SETTINGS_TABS: SettingsTab[] = [
|
||||
tooltipText: "Remotely configured fields",
|
||||
headerText: "Remote Config",
|
||||
icon: HardDriveDownload,
|
||||
hidden: ({ activeOrganization } = { activeOrganization: null }) =>
|
||||
!activeOrganization || !isAdminOrOwner(activeOrganization),
|
||||
hidden: (
|
||||
{ activeOrganization } = { user: null, activeOrganization: null },
|
||||
) => !activeOrganization || !isAdminOrOwner(activeOrganization),
|
||||
},
|
||||
{
|
||||
id: "about",
|
||||
@@ -104,20 +121,21 @@ export const SETTINGS_TABS: SettingsTab[] = [
|
||||
tooltipText: "Debug Tools",
|
||||
headerText: "Debug",
|
||||
icon: FlaskConical,
|
||||
hidden: () => !IS_DEV,
|
||||
hidden: ({ user } = { user: null, activeOrganization: null }) =>
|
||||
!IS_DEV && !isClineInternalTester(user?.email || ""),
|
||||
},
|
||||
]
|
||||
];
|
||||
|
||||
type SettingsViewProps = {
|
||||
onDone: () => void
|
||||
targetSection?: string
|
||||
}
|
||||
onDone: () => void;
|
||||
targetSection?: string;
|
||||
};
|
||||
|
||||
// Helper to render section header - moved outside component for better performance
|
||||
const renderSectionHeader = (tabId: string) => {
|
||||
const tab = SETTINGS_TABS.find((t) => t.id === tabId)
|
||||
const tab = SETTINGS_TABS.find((t) => t.id === tabId);
|
||||
if (!tab) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -127,8 +145,8 @@ const renderSectionHeader = (tabId: string) => {
|
||||
<div>{tab.headerText}</div>
|
||||
</div>
|
||||
</SectionHeader>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const SettingsView = ({ onDone, targetSection }: SettingsViewProps) => {
|
||||
// Memoize to avoid recreation
|
||||
@@ -144,76 +162,85 @@ const SettingsView = ({ onDone, targetSection }: SettingsViewProps) => {
|
||||
debug: DebugSection,
|
||||
}),
|
||||
[],
|
||||
) // Empty deps - these imports never change
|
||||
); // Empty deps - these imports never change
|
||||
|
||||
const { version, environment, settingsInitialModelTab } = useExtensionState()
|
||||
const { activeOrganization } = useClineAuth()
|
||||
const { version, environment, settingsInitialModelTab } = useExtensionState();
|
||||
const { activeOrganization, clineUser } = useClineAuth();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<string>(targetSection || SETTINGS_TABS[0].id)
|
||||
const [activeTab, setActiveTab] = useState<string>(
|
||||
targetSection || SETTINGS_TABS[0].id,
|
||||
);
|
||||
|
||||
// Optimized message handler with early returns
|
||||
const handleMessage = useCallback((event: MessageEvent) => {
|
||||
const message: ExtensionMessage = event.data
|
||||
const message: ExtensionMessage = event.data;
|
||||
if (message.type !== "grpc_response") {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
const grpcMessage = message.grpc_response?.message
|
||||
const grpcMessage = message.grpc_response?.message;
|
||||
if (grpcMessage?.key !== "scrollToSettings") {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
const tabId = grpcMessage.value
|
||||
const tabId = grpcMessage.value;
|
||||
if (!tabId) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if valid tab ID
|
||||
if (SETTINGS_TABS.some((tab) => tab.id === tabId)) {
|
||||
setActiveTab(tabId)
|
||||
return
|
||||
setActiveTab(tabId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback to element scrolling
|
||||
requestAnimationFrame(() => {
|
||||
const element = document.getElementById(tabId)
|
||||
const element = document.getElementById(tabId);
|
||||
if (!element) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
element.scrollIntoView({ behavior: "smooth" })
|
||||
element.style.transition = "background-color 0.5s ease"
|
||||
element.style.backgroundColor = "var(--vscode-textPreformat-background)"
|
||||
element.scrollIntoView({ behavior: "smooth" });
|
||||
element.style.transition = "background-color 0.5s ease";
|
||||
element.style.backgroundColor = "var(--vscode-textPreformat-background)";
|
||||
|
||||
setTimeout(() => {
|
||||
element.style.backgroundColor = "transparent"
|
||||
}, 1200)
|
||||
})
|
||||
}, [])
|
||||
element.style.backgroundColor = "transparent";
|
||||
}, 1200);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEvent("message", handleMessage)
|
||||
useEvent("message", handleMessage);
|
||||
|
||||
// Memoized reset state handler
|
||||
const handleResetState = useCallback(async (resetGlobalState?: boolean) => {
|
||||
try {
|
||||
await StateServiceClient.resetState(ResetStateRequest.create({ global: resetGlobalState }))
|
||||
await StateServiceClient.resetState(
|
||||
ResetStateRequest.create({ global: resetGlobalState }),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to reset state:", error)
|
||||
console.error("Failed to reset state:", error);
|
||||
}
|
||||
}, [])
|
||||
}, []);
|
||||
|
||||
// Update active tab when targetSection changes
|
||||
useEffect(() => {
|
||||
if (targetSection) {
|
||||
setActiveTab(targetSection)
|
||||
setActiveTab(targetSection);
|
||||
}
|
||||
}, [targetSection])
|
||||
}, [targetSection]);
|
||||
|
||||
// Memoized tab item renderer
|
||||
const renderTabItem = useCallback(
|
||||
(tab: (typeof SETTINGS_TABS)[0]) => {
|
||||
return (
|
||||
<TabTrigger className="flex justify-baseline" data-testid={`tab-${tab.id}`} key={tab.id} value={tab.id}>
|
||||
<TabTrigger
|
||||
className="flex justify-baseline"
|
||||
data-testid={`tab-${tab.id}`}
|
||||
key={tab.id}
|
||||
value={tab.id}
|
||||
>
|
||||
<Tooltip key={tab.id}>
|
||||
<TooltipTrigger>
|
||||
<div
|
||||
@@ -223,7 +250,8 @@ const SettingsView = ({ onDone, targetSection }: SettingsViewProps) => {
|
||||
"opacity-100 border-l-2 border-l-foreground border-t-0 border-r-0 border-b-0 bg-selection":
|
||||
activeTab === tab.id,
|
||||
},
|
||||
)}>
|
||||
)}
|
||||
>
|
||||
<tab.icon className="w-4 h-4" />
|
||||
<span className="hidden sm:block">{tab.name}</span>
|
||||
</div>
|
||||
@@ -231,30 +259,37 @@ const SettingsView = ({ onDone, targetSection }: SettingsViewProps) => {
|
||||
<TooltipContent side="right">{tab.tooltipText}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TabTrigger>
|
||||
)
|
||||
);
|
||||
},
|
||||
[activeTab],
|
||||
)
|
||||
);
|
||||
|
||||
// Memoized active content component
|
||||
const ActiveContent = useMemo(() => {
|
||||
const Component = TAB_CONTENT_MAP[activeTab as keyof typeof TAB_CONTENT_MAP]
|
||||
const Component =
|
||||
TAB_CONTENT_MAP[activeTab as keyof typeof TAB_CONTENT_MAP];
|
||||
if (!Component) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
// Special props for specific components
|
||||
const props: any = { renderSectionHeader }
|
||||
const props: any = { renderSectionHeader };
|
||||
if (activeTab === "debug") {
|
||||
props.onResetState = handleResetState
|
||||
props.onResetState = handleResetState;
|
||||
} else if (activeTab === "about") {
|
||||
props.version = version
|
||||
props.version = version;
|
||||
} else if (activeTab === "api-config") {
|
||||
props.initialModelTab = settingsInitialModelTab
|
||||
props.initialModelTab = settingsInitialModelTab;
|
||||
}
|
||||
|
||||
return <Component {...props} />
|
||||
}, [activeTab, handleResetState, settingsInitialModelTab, version])
|
||||
return <Component {...props} />;
|
||||
}, [
|
||||
activeTab,
|
||||
handleResetState,
|
||||
settingsInitialModelTab,
|
||||
version,
|
||||
TAB_CONTENT_MAP,
|
||||
]);
|
||||
|
||||
return (
|
||||
<Tab>
|
||||
@@ -264,14 +299,19 @@ const SettingsView = ({ onDone, targetSection }: SettingsViewProps) => {
|
||||
<TabList
|
||||
className="shrink-0 flex flex-col overflow-y-auto border-r border-sidebar-background"
|
||||
onValueChange={setActiveTab}
|
||||
value={activeTab}>
|
||||
{SETTINGS_TABS.filter((tab) => !tab.hidden?.({ activeOrganization })).map(renderTabItem)}
|
||||
value={activeTab}
|
||||
>
|
||||
{SETTINGS_TABS.filter(
|
||||
(tab) => !tab.hidden?.({ user: clineUser, activeOrganization }),
|
||||
).map(renderTabItem)}
|
||||
</TabList>
|
||||
|
||||
<TabContent className="flex-1 overflow-auto">{ActiveContent}</TabContent>
|
||||
<TabContent className="flex-1 overflow-auto">
|
||||
{ActiveContent}
|
||||
</TabContent>
|
||||
</div>
|
||||
</Tab>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsView
|
||||
export default SettingsView;
|
||||
|
||||
@@ -101,23 +101,17 @@ describe("ensureDetachedHubServer", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("lets the daemon bind port 0 when the configured endpoint is occupied", async () => {
|
||||
readHubDiscovery.mockResolvedValue(undefined);
|
||||
probeHubServer
|
||||
.mockResolvedValueOnce({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
buildId: "current-build",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
url: "ws://127.0.0.1:5555/hub",
|
||||
buildId: "current-build",
|
||||
});
|
||||
verifyHubConnection.mockResolvedValueOnce(true);
|
||||
it("starts the daemon on the resolved default hub port", async () => {
|
||||
readHubDiscovery.mockResolvedValueOnce(undefined).mockResolvedValueOnce({
|
||||
url: "ws://127.0.0.1:5555/hub",
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
buildId: "current-build",
|
||||
authToken: "new-token",
|
||||
});
|
||||
probeHubServer.mockResolvedValueOnce(undefined).mockResolvedValueOnce({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
buildId: "current-build",
|
||||
});
|
||||
verifyHubConnection.mockResolvedValueOnce(true);
|
||||
|
||||
const { ensureDetachedHubServer } = await import(".");
|
||||
const result = await ensureDetachedHubServer("/workspace");
|
||||
@@ -129,12 +123,12 @@ describe("ensureDetachedHubServer", () => {
|
||||
| undefined;
|
||||
|
||||
expect(result).toEqual({
|
||||
url: "ws://127.0.0.1:5555/hub",
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "new-token",
|
||||
});
|
||||
expect(spawn).toHaveBeenCalledOnce();
|
||||
expect(spawnArgs).toContain("--port");
|
||||
expect(spawnArgs).toContain("0");
|
||||
expect(spawnArgs).toContain("25463");
|
||||
expect(spawnOptions?.env?.[CLINE_RUN_AS_HUB_DAEMON_ENV]).toBe("1");
|
||||
});
|
||||
|
||||
|
||||
@@ -206,8 +206,6 @@ export function prewarmDetachedHubServer(
|
||||
return;
|
||||
}
|
||||
const owner = resolveSharedHubOwnerContext();
|
||||
const hasExplicitPort =
|
||||
endpoint.port !== undefined || !!process.env.CLINE_HUB_PORT?.trim();
|
||||
const resolvedEndpoint = resolveHubEndpointOptions(endpoint);
|
||||
const expectedUrl = createHubServerUrl(
|
||||
resolvedEndpoint.host,
|
||||
@@ -240,12 +238,7 @@ export function prewarmDetachedHubServer(
|
||||
if (expected?.url) {
|
||||
await retireIncompatibleHub(expected, owner.discoveryPath);
|
||||
}
|
||||
const shouldUseFallbackPort =
|
||||
!hasExplicitPort && resolvedEndpoint.port !== 0;
|
||||
const spawnEndpoint = shouldUseFallbackPort
|
||||
? { ...resolvedEndpoint, port: 0 }
|
||||
: resolvedEndpoint;
|
||||
await spawnDetachedHubServerWithRetry(workspaceRoot, spawnEndpoint);
|
||||
await spawnDetachedHubServerWithRetry(workspaceRoot, resolvedEndpoint);
|
||||
})
|
||||
.catch(() => {
|
||||
// best-effort prewarm only
|
||||
@@ -267,9 +260,6 @@ export async function ensureDetachedHubServer(
|
||||
endpointOverrides.port !== undefined ||
|
||||
endpointOverrides.pathname !== undefined ||
|
||||
!!process.env.CLINE_HUB_PORT?.trim();
|
||||
const hasExplicitPort =
|
||||
endpointOverrides.port !== undefined ||
|
||||
!!process.env.CLINE_HUB_PORT?.trim();
|
||||
const endpoint = resolveHubEndpointOptions(endpointOverrides);
|
||||
const expectedUrl = createHubServerUrl(
|
||||
endpoint.host,
|
||||
@@ -312,11 +302,7 @@ export async function ensureDetachedHubServer(
|
||||
if (expected?.url) {
|
||||
await retireIncompatibleHub(expected, owner.discoveryPath);
|
||||
}
|
||||
const shouldUseFallbackPort = !hasExplicitPort && endpoint.port !== 0;
|
||||
const spawnEndpoint = shouldUseFallbackPort
|
||||
? { ...endpoint, port: 0 }
|
||||
: endpoint;
|
||||
await spawnDetachedHubServerWithRetry(workspaceRoot, spawnEndpoint);
|
||||
await spawnDetachedHubServerWithRetry(workspaceRoot, endpoint);
|
||||
const deadline = Date.now() + HUB_STARTUP_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
const nextDiscovery = await readHubDiscovery(owner.discoveryPath);
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { resolveSharedHubOwnerContext } from "./workspace";
|
||||
|
||||
const envSnapshot = {
|
||||
CLINE_BUILD_ENV: process.env.CLINE_BUILD_ENV,
|
||||
CLINE_DATA_DIR: process.env.CLINE_DATA_DIR,
|
||||
CLINE_HUB_DISCOVERY_PATH: process.env.CLINE_HUB_DISCOVERY_PATH,
|
||||
};
|
||||
|
||||
function restoreEnv(): void {
|
||||
if (envSnapshot.CLINE_BUILD_ENV === undefined) {
|
||||
delete process.env.CLINE_BUILD_ENV;
|
||||
} else {
|
||||
process.env.CLINE_BUILD_ENV = envSnapshot.CLINE_BUILD_ENV;
|
||||
}
|
||||
if (envSnapshot.CLINE_DATA_DIR === undefined) {
|
||||
delete process.env.CLINE_DATA_DIR;
|
||||
} else {
|
||||
process.env.CLINE_DATA_DIR = envSnapshot.CLINE_DATA_DIR;
|
||||
}
|
||||
if (envSnapshot.CLINE_HUB_DISCOVERY_PATH === undefined) {
|
||||
delete process.env.CLINE_HUB_DISCOVERY_PATH;
|
||||
} else {
|
||||
process.env.CLINE_HUB_DISCOVERY_PATH = envSnapshot.CLINE_HUB_DISCOVERY_PATH;
|
||||
}
|
||||
}
|
||||
|
||||
describe("resolveSharedHubOwnerContext", () => {
|
||||
afterEach(() => {
|
||||
restoreEnv();
|
||||
});
|
||||
|
||||
it("uses separate discovery owners for production and development hubs", () => {
|
||||
delete process.env.CLINE_HUB_DISCOVERY_PATH;
|
||||
process.env.CLINE_DATA_DIR = "/tmp/cline-data";
|
||||
|
||||
process.env.CLINE_BUILD_ENV = "production";
|
||||
const production = resolveSharedHubOwnerContext();
|
||||
|
||||
process.env.CLINE_BUILD_ENV = "development";
|
||||
const development = resolveSharedHubOwnerContext();
|
||||
|
||||
expect(development.ownerId).not.toBe(production.ownerId);
|
||||
expect(development.discoveryPath).not.toBe(production.discoveryPath);
|
||||
});
|
||||
|
||||
it("honors an explicit shared owner label", () => {
|
||||
delete process.env.CLINE_HUB_DISCOVERY_PATH;
|
||||
process.env.CLINE_DATA_DIR = "/tmp/cline-data";
|
||||
process.env.CLINE_BUILD_ENV = "development";
|
||||
|
||||
const development = resolveSharedHubOwnerContext("shared:custom");
|
||||
|
||||
process.env.CLINE_BUILD_ENV = "production";
|
||||
const production = resolveSharedHubOwnerContext("shared:custom");
|
||||
|
||||
expect(development).toEqual(production);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,12 @@
|
||||
import { resolveClineBuildEnv } from "@cline/shared";
|
||||
import { normalizeWorkspacePath } from "../../services/workspace/workspace-manifest";
|
||||
import { type HubOwnerContext, resolveHubOwnerContext } from ".";
|
||||
|
||||
const DEFAULT_SHARED_HUB_OWNER_LABEL = "shared:cline";
|
||||
const SHARED_HUB_OWNER_LABEL_PREFIX = "shared:cline";
|
||||
|
||||
function resolveDefaultSharedHubOwnerLabel(): string {
|
||||
return `${SHARED_HUB_OWNER_LABEL_PREFIX}:${resolveClineBuildEnv()}`;
|
||||
}
|
||||
|
||||
export function resolveWorkspaceHubOwnerContext(
|
||||
workspaceRoot: string,
|
||||
@@ -13,7 +18,7 @@ export function resolveWorkspaceHubOwnerContext(
|
||||
}
|
||||
|
||||
export function resolveSharedHubOwnerContext(
|
||||
label = DEFAULT_SHARED_HUB_OWNER_LABEL,
|
||||
label = resolveDefaultSharedHubOwnerLabel(),
|
||||
): HubOwnerContext {
|
||||
return resolveHubOwnerContext(label);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ export type ConnectWhatsAppOptions = {
|
||||
interactive: boolean;
|
||||
maxIterations?: number;
|
||||
enableTools: boolean;
|
||||
rpcAddress: string;
|
||||
rpcAddress?: string;
|
||||
hookCommand?: string;
|
||||
port: number;
|
||||
host: string;
|
||||
@@ -44,7 +44,7 @@ export type ConnectTelegramOptions = {
|
||||
interactive: boolean;
|
||||
maxIterations?: number;
|
||||
enableTools: boolean;
|
||||
rpcAddress: string;
|
||||
rpcAddress?: string;
|
||||
hookCommand?: string;
|
||||
};
|
||||
|
||||
@@ -75,7 +75,7 @@ export type ConnectSlackOptions = {
|
||||
interactive: boolean;
|
||||
maxIterations?: number;
|
||||
enableTools: boolean;
|
||||
rpcAddress: string;
|
||||
rpcAddress?: string;
|
||||
hookCommand?: string;
|
||||
port: number;
|
||||
host: string;
|
||||
@@ -109,7 +109,7 @@ export type ConnectDiscordOptions = {
|
||||
interactive: boolean;
|
||||
maxIterations?: number;
|
||||
enableTools: boolean;
|
||||
rpcAddress: string;
|
||||
rpcAddress?: string;
|
||||
hookCommand?: string;
|
||||
port: number;
|
||||
host: string;
|
||||
@@ -137,7 +137,7 @@ export type ConnectGoogleChatOptions = {
|
||||
interactive: boolean;
|
||||
maxIterations?: number;
|
||||
enableTools: boolean;
|
||||
rpcAddress: string;
|
||||
rpcAddress?: string;
|
||||
hookCommand?: string;
|
||||
port: number;
|
||||
host: string;
|
||||
@@ -173,7 +173,7 @@ export type ConnectLinearOptions = {
|
||||
interactive: boolean;
|
||||
maxIterations?: number;
|
||||
enableTools: boolean;
|
||||
rpcAddress: string;
|
||||
rpcAddress?: string;
|
||||
hookCommand?: string;
|
||||
port: number;
|
||||
host: string;
|
||||
|
||||
Reference in New Issue
Block a user