Compare commits

...

5 Commits

Author SHA1 Message Date
abeatrix 54cd7fa3ee clean up orphaned hub daemon processes
Add doctor command support for identifying hub daemon processes that
are not the active discovered hub. These orphaned processes are now
reported in the status output and can be killed via `doctor --fix`.

Includes tests covering detection of orphaned daemons across multiple
process patterns and verification that the active hub is protected
during cleanup.
2026-06-05 20:44:50 -07:00
abeatrix 1f888a65e8 patches 2026-06-05 17:20:19 -07:00
abeatrix 05c2b4bbcf fix(cli): doctor detect unmanaged connector processes
Issue
cline doctor could report active connectors 0 even when connector processes were still running, such as foreground cline connect slack -i processes. This made stale or partially started connectors invisible unless the user manually inspected the process table.

Cause
Doctor only listed connectors from valid managed connector state files under the connector data directory. If a connector process was running but its state file was missing, stale, malformed, or never written because startup failed before state persistence, doctor ignored it. The stale CLI process scan also excluded connect, so connector-looking processes were not reported anywhere.

Fix
Added a separate unmanaged connector process diagnostic path that scans process args for connect <connector> using the shared connector catalog. This works across all registered connectors instead of being Slack-specific.

The new diagnostic:

Detects unmanaged connect processes for Slack, Discord, Google Chat, Linear, Telegram, and WhatsApp
Keeps managed state-file connectors in the existing activeConnectors output
Reports missing-state connector processes as unmanagedConnectorProcesses
Redacts sensitive token/secret/key/password argument values from process output
Makes cline doctor fix kill those unmanaged connector processes
Verification
bunx vitest run src/commands/doctor.test.ts --config vitest.config.ts
npm run typecheck
2026-06-05 16:20:49 -07:00
Tomás Barreiro 676b446d47 Add debug section for Cline testers (#11318) 2026-06-05 12:09:01 -07:00
Ara a8835425bf chore: bump version and update changelog (v3.88.0) (#11316) 2026-06-05 09:58:50 -07:00
18 changed files with 833 additions and 190 deletions
+15
View File
@@ -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
+318
View File
@@ -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);
+241 -6
View File
@@ -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;
}
+2 -9
View File
@@ -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(),
+2 -9
View File
@@ -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(),
+2 -9
View File
@@ -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(),
+34 -1
View File
@@ -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", () => {
+2 -9
View File
@@ -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(),
+2 -9
View File
@@ -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),
+2 -9
View File
@@ -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(),
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -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;
+9 -15
View File
@@ -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");
});
+2 -16
View File
@@ -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;