Compare commits

...
Author SHA1 Message Date
abeatrix dd5dbb4148 fix: serialize hub dashboard shutdown 2026-07-24 19:31:58 -07:00
abeatrix 3f5715ceb6 Merge branch 'main' into bee/hub-running-bg 2026-07-24 19:01:59 -07:00
abeatrix 0c8226c9ba fix: preserve dashboard lifecycle invariants 2026-07-24 18:59:31 -07:00
abeatrix 686bce7451 fix: harden dashboard restart failure handling 2026-07-24 18:19:35 -07:00
Bee 6362b425d1 Merge branch 'main' into bee/hub-running-bg 2026-07-24 18:10:52 -07:00
abeatrix ad90909ccf fix: address dashboard lifecycle review follow-ups 2026-07-24 17:30:13 -07:00
abeatrix d86283e09b fix: recover hub restart after crashes 2026-07-24 17:07:03 -07:00
abeatrix 1ff3784b39 fix: preserve dashboard discovery compatibility 2026-07-24 16:29:22 -07:00
abeatrix d279a99436 fix 2026-07-24 16:20:07 -07:00
abeatrix f3679d52f7 fix: address dashboard lifecycle review feedback 2026-07-24 14:57:06 -07:00
abeatrix b8dc65b4fe Merge remote-tracking branch 'origin/main' into bee/hub-running-bg 2026-07-24 14:39:24 -07:00
abeatrix 0b36eb53d0 Merge remote-tracking branch 'origin/main' into bee/hub-running-bg
# Conflicts:
#	apps/cli/src/tui/components/chat-entry.tsx
#	sdk/packages/core/src/hub/daemon/entry.ts
#	sdk/packages/core/src/index.ts
#	sdk/packages/llms/src/index.browser.ts
#	sdk/packages/llms/src/index.ts
#	sdk/packages/llms/src/providers.browser.ts
#	sdk/packages/llms/src/providers.ts
#	sdk/packages/llms/src/providers/format.test.ts
#	sdk/packages/shared/src/index.browser.ts
#	sdk/packages/shared/src/index.ts
2026-07-24 13:20:33 -07:00
abeatrix e89c6a422b Merge origin/main and fix hosted dashboard attach
Merge latest origin/main into the hub dashboard branch and resolve cline-hub conflicts between the new browser auth gate and hosted dashboard launch flow.

Fix the reviewed connect_hub issue by keeping tokenless hub URLs out of dashboard invite fragments and browser localStorage. Hosted invite URLs now carry only bridgeUrl and roomSecret, while custom hub reconnects still require an explicit authToken in the submitted URL.

Add hosted-dashboard origin coverage for the browser bridge, keep cline-hub typechecking against source @cline/llms, and preserve bridge credential persistence for refresh/navigation without persisting hub attach URLs.
2026-06-23 17:07:47 -07:00
abeatrix dbb6c4ac6b Secure hosted dashboard bridge
Open the dashboard through a hosted/static-compatible URL while keeping a local authenticated bridge for hub access. Generate a per-process dashboard room secret, validate browser origins, carry bridge and hub connection details in the URL fragment, and let the dashboard UI retarget the hub URL.

Centralize default hub owner selection so production clients share the singleton production hub record, including CLI and the VS Code example. Update tests for the new lifecycle, owner selection, and hosted dashboard URL behavior.
2026-06-22 16:34:04 -07:00
abeatrix 9838666a8a fix(cli): let hub own dashboard lifecycle
Previously, cline dashboard started the dashboard server in the CLI process and waited forever, leaving the command attached to the terminal. This also made dashboard lifetime a CLI concern even though the dashboard is coupled to the active hub daemon.

Change the public dashboard command into a controller that ensures a detached dashboard, opens the discovered URL in the default browser, and exits. Add dashboard stop/restart actions plus a hidden dashboard serve mode for the background server.

Have CLI-launched hub daemons replace any discovered dashboard on startup and stop it on shutdown. Add dashboard discovery records shared by CLI and core so a newly started hub can reliably own the background dashboard process.

Address review feedback by avoiding CLI fallback spawns while a newly started hub is responsible for launching its dashboard, writing dashboard discovery through atomic temp-file renames, centralizing dashboard PID liveness checks, and keeping the internal serve action out of public error text.

Also documents the new lifecycle and covers the CLI and managed dashboard process paths with focused tests.
2026-06-22 14:17:21 -07:00
61 changed files with 3243 additions and 243 deletions
@@ -0,0 +1,378 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { spawn, coreMocks, buildCliSubcommandCommand } = vi.hoisted(() => ({
spawn: vi.fn(() => ({ unref: vi.fn() })),
coreMocks: {
CLINE_HUB_DASHBOARD_DISCOVERY_PATH_ENV:
"CLINE_HUB_DASHBOARD_DISCOVERY_PATH",
clearHubDashboardDiscovery: vi.fn(async () => undefined),
ensureDetachedHubServer: vi.fn(async () => ({
url: "ws://127.0.0.1:25463/hub",
authToken: "hub-token",
})),
isHubDashboardPidAlive: vi.fn(
(pid: number | undefined) => !!pid && pid > 0,
),
readHubDashboardDiscovery: vi.fn(),
readHubDiscovery: vi.fn(),
resolveDefaultHubOwnerContext: vi.fn(() => ({
ownerId: "hub-shared",
discoveryPath: "/tmp/hub.json",
})),
resolveHubDashboardDiscoveryPath: vi.fn(() => "/tmp/dashboard.json"),
resolveProductionHubOwnerContext: vi.fn(() => ({
ownerId: "hub-production",
discoveryPath: "/tmp/hub.json",
})),
resolveSharedHubOwnerContext: vi.fn(() => ({
ownerId: "hub-shared",
discoveryPath: "/tmp/hub.json",
})),
stopManagedHubDashboardProcess: vi.fn(async () => false),
writeHubDashboardDiscovery: vi.fn(async () => undefined),
},
buildCliSubcommandCommand: vi.fn(() => ({
launcher: "bun",
childArgs: ["cline", "dashboard", "serve"],
})),
}));
vi.mock("node:child_process", () => ({ spawn }));
vi.mock("@cline/core", () => coreMocks);
vi.mock("@cline/shared", () => ({
resolveClineBuildEnv: () => "development",
}));
vi.mock("../utils/internal-launch", () => ({ buildCliSubcommandCommand }));
const originalEnv = {
CLINE_HUB_DASHBOARD_LAUNCHER: process.env.CLINE_HUB_DASHBOARD_LAUNCHER,
CLINE_HUB_DASHBOARD_ARGS: process.env.CLINE_HUB_DASHBOARD_ARGS,
CLINE_HUB_DASHBOARD_DISCOVERY_PATH:
process.env.CLINE_HUB_DASHBOARD_DISCOVERY_PATH,
};
function dashboardRecord(pid: number) {
return {
pid,
listenUrl: "http://127.0.0.1:8787/",
publicUrl: "http://127.0.0.1:8787",
inviteUrl: "http://127.0.0.1:8787",
hubUrl: "ws://127.0.0.1:25463/hub",
startedAt: "2026-06-22T20:00:00.000Z",
updatedAt: "2026-06-22T20:00:00.000Z",
};
}
describe("dashboard command lifecycle", () => {
beforeEach(() => {
vi.resetModules();
spawn.mockClear();
buildCliSubcommandCommand.mockClear();
coreMocks.clearHubDashboardDiscovery.mockClear();
coreMocks.ensureDetachedHubServer.mockClear();
coreMocks.isHubDashboardPidAlive.mockClear();
coreMocks.isHubDashboardPidAlive.mockImplementation(
(pid: number | undefined) => !!pid && pid > 0,
);
coreMocks.readHubDashboardDiscovery.mockReset();
coreMocks.readHubDiscovery.mockReset();
coreMocks.resolveHubDashboardDiscoveryPath.mockReset();
coreMocks.resolveHubDashboardDiscoveryPath.mockReturnValue(
"/tmp/dashboard.json",
);
coreMocks.stopManagedHubDashboardProcess.mockReset();
coreMocks.stopManagedHubDashboardProcess.mockResolvedValue(false);
coreMocks.writeHubDashboardDiscovery.mockClear();
vi.stubGlobal(
"fetch",
vi.fn(async () => ({ ok: true })),
);
process.env.CLINE_HUB_DASHBOARD_LAUNCHER = "bun";
process.env.CLINE_HUB_DASHBOARD_ARGS = JSON.stringify([
"cline",
"dashboard",
"serve",
]);
});
afterEach(() => {
if (originalEnv.CLINE_HUB_DASHBOARD_LAUNCHER === undefined) {
delete process.env.CLINE_HUB_DASHBOARD_LAUNCHER;
} else {
process.env.CLINE_HUB_DASHBOARD_LAUNCHER =
originalEnv.CLINE_HUB_DASHBOARD_LAUNCHER;
}
if (originalEnv.CLINE_HUB_DASHBOARD_ARGS === undefined) {
delete process.env.CLINE_HUB_DASHBOARD_ARGS;
} else {
process.env.CLINE_HUB_DASHBOARD_ARGS =
originalEnv.CLINE_HUB_DASHBOARD_ARGS;
}
if (originalEnv.CLINE_HUB_DASHBOARD_DISCOVERY_PATH === undefined) {
delete process.env.CLINE_HUB_DASHBOARD_DISCOVERY_PATH;
} else {
process.env.CLINE_HUB_DASHBOARD_DISCOVERY_PATH =
originalEnv.CLINE_HUB_DASHBOARD_DISCOVERY_PATH;
}
vi.useRealTimers();
vi.unstubAllGlobals();
});
it("waits for a newly started hub-owned dashboard instead of spawning a competing fallback", async () => {
const opened: string[] = [];
coreMocks.readHubDiscovery
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce({
url: "ws://127.0.0.1:25463/hub",
pid: 777,
startedAt: "2026-06-22T20:00:00.000Z",
});
coreMocks.readHubDashboardDiscovery
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce(dashboardRecord(888));
const { runDashboardCommand } = await import("./dashboard");
const exitCode = await runDashboardCommand({
io: {
writeln: () => {},
writeErr: () => {},
},
openUrl: async (url) => {
opened.push(url);
},
});
expect(exitCode).toBe(0);
expect(spawn).not.toHaveBeenCalled();
expect(opened).toEqual(["http://127.0.0.1:8787"]);
});
it("waits for the replacement dashboard after the hub process changes", async () => {
const opened: string[] = [];
const stale = dashboardRecord(888);
const replacement = {
...dashboardRecord(999),
startedAt: "2026-06-22T20:00:01.000Z",
updatedAt: "2026-06-22T20:00:01.000Z",
};
coreMocks.readHubDiscovery
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce({
url: "ws://127.0.0.1:25463/hub",
pid: 777,
startedAt: "2026-06-22T20:00:00.000Z",
});
coreMocks.readHubDashboardDiscovery
.mockResolvedValueOnce(stale)
.mockResolvedValueOnce(stale)
.mockResolvedValueOnce(stale)
.mockResolvedValueOnce(replacement);
const { runDashboardCommand } = await import("./dashboard");
const exitCode = await runDashboardCommand({
io: {
writeln: () => {},
writeErr: () => {},
},
openUrl: async (url) => {
opened.push(url);
},
});
expect(exitCode).toBe(0);
expect(spawn).not.toHaveBeenCalled();
expect(opened).toEqual(["http://127.0.0.1:8787"]);
expect(coreMocks.readHubDashboardDiscovery).toHaveBeenCalledTimes(4);
});
it("accepts a healthy dashboard whose optional hub URL is absent", async () => {
const existingHub = {
url: "ws://127.0.0.1:25463/hub",
pid: 777,
startedAt: "2026-06-22T20:00:00.000Z",
};
const dashboardWithoutHubUrl = {
...dashboardRecord(888),
hubUrl: undefined,
};
coreMocks.readHubDiscovery
.mockResolvedValueOnce(existingHub)
.mockResolvedValueOnce(existingHub);
coreMocks.readHubDashboardDiscovery
.mockResolvedValueOnce(dashboardWithoutHubUrl)
.mockResolvedValueOnce(dashboardWithoutHubUrl);
const { runDashboardCommand } = await import("./dashboard");
const exitCode = await runDashboardCommand({
io: {
writeln: () => {},
writeErr: () => {},
},
openUrl: async () => {},
});
expect(exitCode).toBe(0);
expect(spawn).not.toHaveBeenCalled();
expect(coreMocks.stopManagedHubDashboardProcess).not.toHaveBeenCalled();
});
it("falls back when a newly started hub never publishes a healthy dashboard", async () => {
vi.useFakeTimers();
coreMocks.readHubDiscovery
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce({
url: "ws://127.0.0.1:25463/hub",
pid: 777,
startedAt: "2026-06-22T20:00:00.000Z",
});
coreMocks.readHubDashboardDiscovery.mockResolvedValue(undefined);
const { runDashboardCommand } = await import("./dashboard");
const pending = runDashboardCommand({
io: {
writeln: () => {},
writeErr: () => {},
},
openUrl: async () => {},
});
await vi.advanceTimersByTimeAsync(8_000);
coreMocks.readHubDashboardDiscovery.mockResolvedValue(dashboardRecord(999));
await vi.advanceTimersByTimeAsync(200);
await expect(pending).resolves.toBe(0);
expect(coreMocks.stopManagedHubDashboardProcess).toHaveBeenCalledTimes(1);
expect(spawn).toHaveBeenCalledTimes(1);
});
it("allows CLI fallback only when the hub was already running", async () => {
const existingHub = {
url: "ws://127.0.0.1:25463/hub",
pid: 777,
startedAt: "2026-06-22T20:00:00.000Z",
};
coreMocks.readHubDiscovery
.mockResolvedValueOnce(existingHub)
.mockResolvedValueOnce(existingHub);
coreMocks.readHubDashboardDiscovery
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce(dashboardRecord(999));
const { runDashboardCommand } = await import("./dashboard");
const exitCode = await runDashboardCommand({
io: {
writeln: () => {},
writeErr: () => {},
},
openUrl: async () => {},
});
expect(exitCode).toBe(0);
expect(spawn).toHaveBeenCalledTimes(1);
expect(buildCliSubcommandCommand).toHaveBeenCalledWith(
"dashboard",
["serve"],
expect.any(Object),
);
});
it("does not spawn a replacement when the existing dashboard cannot stop", async () => {
const existingHub = {
url: "ws://127.0.0.1:25463/hub",
pid: 777,
startedAt: "2026-06-22T20:00:00.000Z",
};
coreMocks.readHubDiscovery
.mockResolvedValueOnce(existingHub)
.mockResolvedValueOnce(existingHub);
coreMocks.readHubDashboardDiscovery.mockResolvedValueOnce(undefined);
coreMocks.stopManagedHubDashboardProcess.mockRejectedValueOnce(
new Error("dashboard process did not stop"),
);
const errors: string[] = [];
const { runDashboardCommand } = await import("./dashboard");
const exitCode = await runDashboardCommand({
io: {
writeln: () => {},
writeErr: (message) => errors.push(message),
},
openUrl: async () => {},
});
expect(exitCode).toBe(1);
expect(errors).toEqual(["dashboard process did not stop"]);
expect(spawn).not.toHaveBeenCalled();
});
it("writes and clears serve discovery inside the sandbox environment", async () => {
const observedDataDirs: Array<string | undefined> = [];
coreMocks.resolveHubDashboardDiscoveryPath.mockImplementation(() => {
observedDataDirs.push(process.env.CLINE_DATA_DIR);
return "/tmp/dashboard.json";
});
const { runDashboardCommand } = await import("./dashboard");
const exitCode = await runDashboardCommand({
action: "serve",
cwd: "/tmp/dashboard-serve-cwd",
dataDir: "/tmp/dashboard-serve-data",
io: {
writeln: () => {},
writeErr: () => {},
},
startServer: async () => ({
listenUrl: "http://127.0.0.1:8787/",
publicUrl: "http://127.0.0.1:8787",
inviteUrl: "http://127.0.0.1:8787",
stop: vi.fn(),
}),
waitForShutdown: async () => {},
});
expect(exitCode).toBe(0);
expect(observedDataDirs).toEqual([
"/tmp/dashboard-serve-data",
"/tmp/dashboard-serve-data",
]);
expect(coreMocks.writeHubDashboardDiscovery).toHaveBeenCalledWith(
"/tmp/dashboard.json",
expect.objectContaining({
listenUrl: "http://127.0.0.1:8787/",
}),
);
expect(coreMocks.clearHubDashboardDiscovery).toHaveBeenCalledWith(
"/tmp/dashboard.json",
);
});
it("stops the serve process when dashboard discovery cannot be written", async () => {
coreMocks.writeHubDashboardDiscovery.mockRejectedValueOnce(
new Error("discovery write failed"),
);
const stop = vi.fn(async () => undefined);
const errors: string[] = [];
const { runDashboardCommand } = await import("./dashboard");
const exitCode = await runDashboardCommand({
action: "serve",
io: {
writeln: () => {},
writeErr: (message) => errors.push(message),
},
startServer: async () => ({
listenUrl: "http://127.0.0.1:8787/",
publicUrl: "http://127.0.0.1:8787",
inviteUrl: "http://127.0.0.1:8787",
stop,
}),
});
expect(exitCode).toBe(1);
expect(errors).toEqual(["discovery write failed"]);
expect(stop).toHaveBeenCalledOnce();
expect(coreMocks.clearHubDashboardDiscovery).toHaveBeenCalledWith(
"/tmp/dashboard.json",
);
});
});
+97 -9
View File
@@ -20,6 +20,8 @@ const ENV_KEYS = [
"PUBLIC_URL",
"ROOM_SECRET",
"CLINE_HUB_WEBVIEW_DIST_DIR",
"CLINE_HUB_DASHBOARD_WEB_URL",
"CLINE_HUB_DASHBOARD_DISCOVERY_PATH",
"CLINE_WRAPPER_PATH",
] as const;
@@ -39,10 +41,9 @@ afterEach(() => {
});
describe("runDashboardCommand", () => {
it("starts the dashboard server, opens the invite URL, and waits for shutdown", async () => {
it("serves the dashboard in the foreground and waits for shutdown", async () => {
const output: string[] = [];
const errors: string[] = [];
const opened: string[] = [];
const stop = vi.fn();
let observedEnv:
| {
@@ -55,13 +56,16 @@ describe("runDashboardCommand", () => {
publicUrl: string | undefined;
roomSecret: string | undefined;
webviewDistDir: string | undefined;
dashboardWebUrl: string | undefined;
}
| undefined;
const webviewDistDir = mkdtempSync(join(tmpdir(), "cline-webview-dist-"));
mkdirSync(webviewDistDir, { recursive: true });
process.env.CLINE_HUB_WEBVIEW_DIST_DIR = webviewDistDir;
delete process.env.CLINE_HUB_DASHBOARD_WEB_URL;
const exitCode = await runDashboardCommand({
action: "serve",
configDir: "/tmp/cline-config",
cwd: "sdk",
dataDir: ".cline-dashboard-data",
@@ -84,6 +88,7 @@ describe("runDashboardCommand", () => {
publicUrl: process.env.PUBLIC_URL,
roomSecret: process.env.ROOM_SECRET,
webviewDistDir: process.env.CLINE_HUB_WEBVIEW_DIST_DIR,
dashboardWebUrl: process.env.CLINE_HUB_DASHBOARD_WEB_URL,
};
return {
listenUrl: "http://127.0.0.1:9090/",
@@ -93,9 +98,6 @@ describe("runDashboardCommand", () => {
stop,
};
},
openUrl: async (url) => {
opened.push(url);
},
waitForShutdown: async (server) => {
await server.stop();
},
@@ -116,8 +118,8 @@ describe("runDashboardCommand", () => {
publicUrl: "http://127.0.0.1:9090",
roomSecret: "secret",
webviewDistDir,
dashboardWebUrl: undefined,
});
expect(opened).toEqual(["http://127.0.0.1:9090/?roomSecret=secret"]);
expect(stop).toHaveBeenCalledTimes(1);
expect(output.join("\n")).toContain("Cline dashboard listening at");
expect(output.join("\n")).toContain("ws://127.0.0.1:25463/hub");
@@ -126,6 +128,42 @@ describe("runDashboardCommand", () => {
expect(process.env.CLINE_HUB_WEBVIEW_DIST_DIR).toBe(webviewDistDir);
});
it("opens a detached dashboard and exits without waiting for shutdown", async () => {
const output: string[] = [];
const errors: string[] = [];
const opened: string[] = [];
const ensureDashboard = vi.fn(async () => ({
pid: 123,
listenUrl: "http://127.0.0.1:8787/",
publicUrl: "http://127.0.0.1:8787",
inviteUrl: "http://127.0.0.1:8787",
hubUrl: "ws://127.0.0.1:25463/hub",
startedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}));
const waitForShutdown = vi.fn();
const exitCode = await runDashboardCommand({
io: {
writeln: (text) => output.push(text ?? ""),
writeErr: (text) => errors.push(text),
},
ensureDashboard,
openUrl: async (url) => {
opened.push(url);
},
waitForShutdown,
});
expect(exitCode).toBe(0);
expect(ensureDashboard).toHaveBeenCalledTimes(1);
expect(waitForShutdown).not.toHaveBeenCalled();
expect(opened).toEqual(["http://127.0.0.1:8787"]);
expect(output.join("\n")).toContain("Cline dashboard listening at");
expect(output.join("\n")).toContain("ws://127.0.0.1:25463/hub");
expect(errors).toEqual([]);
});
it("honors --no-open behavior", async () => {
const openUrl = vi.fn();
@@ -135,20 +173,69 @@ describe("runDashboardCommand", () => {
writeln: () => {},
writeErr: () => {},
},
startServer: async () => ({
ensureDashboard: async () => ({
pid: 123,
listenUrl: "http://127.0.0.1:8787/",
publicUrl: "http://127.0.0.1:8787",
inviteUrl: "http://127.0.0.1:8787",
stop: vi.fn(),
startedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}),
openUrl,
waitForShutdown: async () => {},
});
expect(exitCode).toBe(0);
expect(openUrl).not.toHaveBeenCalled();
});
it("stops a detached dashboard", async () => {
const output: string[] = [];
const stopDashboard = vi.fn(async () => true);
const exitCode = await runDashboardCommand({
action: "stop",
io: {
writeln: (text) => output.push(text ?? ""),
writeErr: () => {},
},
stopDashboard,
});
expect(exitCode).toBe(0);
expect(stopDashboard).toHaveBeenCalledTimes(1);
expect(output).toEqual([JSON.stringify({ stopped: true })]);
});
it("restarts a detached dashboard before opening it", async () => {
const stopDashboard = vi.fn(async () => true);
const ensureDashboard = vi.fn(async () => ({
pid: 456,
listenUrl: "http://127.0.0.1:8787/",
publicUrl: "http://127.0.0.1:8787",
inviteUrl: "http://127.0.0.1:8787",
startedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}));
const exitCode = await runDashboardCommand({
action: "restart",
io: {
writeln: () => {},
writeErr: () => {},
},
stopDashboard,
ensureDashboard,
openUrl: async () => {},
});
expect(exitCode).toBe(0);
expect(stopDashboard).toHaveBeenCalledTimes(1);
expect(ensureDashboard).toHaveBeenCalledTimes(1);
expect(stopDashboard.mock.invocationCallOrder[0]).toBeLessThan(
ensureDashboard.mock.invocationCallOrder[0],
);
});
it("finds webview assets from the published wrapper package layout", async () => {
const root = mkdtempSync(join(tmpdir(), "cline-wrapper-layout-"));
const wrapperPath = join(root, "node_modules", "cline", "bin", "cline");
@@ -170,6 +257,7 @@ describe("runDashboardCommand", () => {
let observedWebviewDistDir: string | undefined;
const exitCode = await runDashboardCommand({
action: "serve",
openBrowser: false,
io: {
writeln: () => {},
+289 -22
View File
@@ -1,9 +1,25 @@
import { spawn } from "node:child_process";
import { existsSync } from "node:fs";
import { arch, platform } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import {
CLINE_HUB_DASHBOARD_DISCOVERY_PATH_ENV,
clearHubDashboardDiscovery,
ensureDetachedHubServer,
type HubDashboardDiscoveryRecord,
type HubServerDiscoveryRecord,
isHubDashboardPidAlive,
readHubDashboardDiscovery,
readHubDiscovery,
resolveDefaultHubOwnerContext,
resolveHubDashboardDiscoveryPath,
stopManagedHubDashboardProcess,
writeHubDashboardDiscovery,
} from "@cline/core";
import open from "open";
import { configureSandboxEnvironment } from "../utils/helpers";
import { buildCliSubcommandCommand } from "../utils/internal-launch";
import { c } from "../utils/output";
export interface DashboardServerHandle {
@@ -20,6 +36,7 @@ interface DashboardCommandIo {
}
export interface RunDashboardCommandOptions {
action?: "open" | "restart" | "serve" | "stop";
configDir?: string;
cwd?: string;
dataDir?: string;
@@ -30,12 +47,20 @@ export interface RunDashboardCommandOptions {
openBrowser?: boolean;
io: DashboardCommandIo;
startServer?: () => Promise<DashboardServerHandle>;
ensureDashboard?: () => Promise<HubDashboardDiscoveryRecord>;
stopDashboard?: () => Promise<boolean>;
openUrl?: (url: string) => Promise<void>;
waitForShutdown?: (server: DashboardServerHandle) => Promise<void>;
}
const DASHBOARD_PORT_ENV = "CLINE_HUB_DASHBOARD_PORT";
const WEBVIEW_DIST_ENV = "CLINE_HUB_WEBVIEW_DIST_DIR";
const DASHBOARD_WEB_URL_ENV = "CLINE_HUB_DASHBOARD_WEB_URL";
const DEFAULT_HOSTED_DASHBOARD_WEB_URL = "https://cline.bot/dashboard";
const DASHBOARD_STARTUP_TIMEOUT_MS = 8_000;
const DASHBOARD_STARTUP_POLL_MS = 200;
const DASHBOARD_LAUNCHER_ENV = "CLINE_HUB_DASHBOARD_LAUNCHER";
const DASHBOARD_ARGS_ENV = "CLINE_HUB_DASHBOARD_ARGS";
function setEnvValue(name: string, value: string | undefined): () => void {
const previous = process.env[name];
@@ -67,14 +92,20 @@ async function withDashboardEnvironment<T>(
fn: () => Promise<T>,
): Promise<T> {
const cwd = options.cwd ? resolve(options.cwd) : process.cwd();
const webviewDistDir = resolveDefaultWebviewDistDir();
const restore = [
setEnvValue("WORKSPACE_ROOT", options.cwd ? cwd : undefined),
setEnvValue("CLINE_DIR", options.configDir?.trim() || undefined),
setEnvValue("HOST", options.host),
setEnvValue(DASHBOARD_PORT_ENV, options.port),
setEnvValue("PUBLIC_URL", options.publicUrl),
setEnvValue(
DASHBOARD_WEB_URL_ENV,
process.env[DASHBOARD_WEB_URL_ENV]?.trim() ||
(webviewDistDir ? undefined : DEFAULT_HOSTED_DASHBOARD_WEB_URL),
),
setEnvValue("ROOM_SECRET", options.roomSecret),
setEnvValue(WEBVIEW_DIST_ENV, resolveDefaultWebviewDistDir()),
setEnvValue(WEBVIEW_DIST_ENV, webviewDistDir),
...SANDBOX_ENV_KEYS.map((key) => setEnvValue(key, undefined)),
];
if (options.dataDir || process.env.CLINE_SANDBOX?.trim() === "1") {
@@ -94,8 +125,9 @@ async function withDashboardEnvironment<T>(
}
function resolveDefaultWebviewDistDir(): string | undefined {
if (process.env[WEBVIEW_DIST_ENV]?.trim()) {
return undefined;
const configuredWebviewDistDir = process.env[WEBVIEW_DIST_ENV]?.trim();
if (configuredWebviewDistDir) {
return configuredWebviewDistDir;
}
const moduleDir = dirname(fileURLToPath(import.meta.url));
@@ -149,6 +181,187 @@ async function openDefaultUrl(url: string): Promise<void> {
await open(url, { wait: false });
}
function resolveDashboardDiscoveryPath(): string {
return (
process.env[CLINE_HUB_DASHBOARD_DISCOVERY_PATH_ENV]?.trim() ||
resolveHubDashboardDiscoveryPath(resolveDefaultHubOwnerContext())
);
}
async function isDashboardHealthy(
record: HubDashboardDiscoveryRecord,
): Promise<boolean> {
if (!isHubDashboardPidAlive(record.pid)) {
return false;
}
try {
const response = await fetch(new URL("/health", record.listenUrl));
return response.ok;
} catch {
return false;
}
}
async function stopDefaultDashboard(): Promise<boolean> {
return await stopManagedHubDashboardProcess(resolveDashboardDiscoveryPath());
}
function spawnDetachedDashboardServer(cwd: string): void {
const command = buildCliSubcommandCommand("dashboard", ["serve"], { cwd });
if (!command) {
throw new Error("unable to resolve CLI command for dashboard process");
}
const child = spawn(command.launcher, command.childArgs, {
cwd,
detached: true,
stdio: "ignore",
env: {
...process.env,
CLINE_NO_INTERACTIVE: "1",
[CLINE_HUB_DASHBOARD_DISCOVERY_PATH_ENV]: resolveDashboardDiscoveryPath(),
},
windowsHide: true,
});
child.unref();
}
async function waitForDashboardDiscovery(
discoveryPath: string,
options: {
timeoutMs?: number;
acceptRecord?: (record: HubDashboardDiscoveryRecord) => boolean;
} = {},
): Promise<HubDashboardDiscoveryRecord> {
const timeoutMs = options.timeoutMs ?? DASHBOARD_STARTUP_TIMEOUT_MS;
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const discovered = await readHubDashboardDiscovery(discoveryPath);
if (
discovered &&
(options.acceptRecord?.(discovered) ?? true) &&
(await isDashboardHealthy(discovered))
) {
return discovered;
}
await new Promise((resolve) =>
setTimeout(resolve, DASHBOARD_STARTUP_POLL_MS),
);
}
throw new Error("Timed out waiting for dashboard startup.");
}
function hasHubManagedDashboardLaunchSpec(): boolean {
return Boolean(
process.env[DASHBOARD_LAUNCHER_ENV]?.trim() &&
process.env[DASHBOARD_ARGS_ENV]?.trim(),
);
}
function isSameHubProcess(
before: HubServerDiscoveryRecord | undefined,
after: HubServerDiscoveryRecord | undefined,
): boolean {
return Boolean(
before &&
after &&
before.url === after.url &&
before.pid === after.pid &&
before.startedAt === after.startedAt,
);
}
function isSameDashboardProcess(
before: HubDashboardDiscoveryRecord | undefined,
after: HubDashboardDiscoveryRecord,
): boolean {
return Boolean(
before && before.pid === after.pid && before.startedAt === after.startedAt,
);
}
function isExpectedDashboard(
record: HubDashboardDiscoveryRecord,
options: {
hubChanged: boolean;
hubUrl?: string;
previousDashboard?: HubDashboardDiscoveryRecord;
},
): boolean {
if (
options.hubChanged &&
isSameDashboardProcess(options.previousDashboard, record)
) {
return false;
}
if (options.hubUrl && record.hubUrl && record.hubUrl !== options.hubUrl) {
return false;
}
return true;
}
async function ensureDefaultDashboard(
options: RunDashboardCommandOptions,
): Promise<HubDashboardDiscoveryRecord> {
return await withDashboardEnvironment(options, async () => {
const cwd = options.cwd ? resolve(options.cwd) : process.cwd();
const owner = resolveDefaultHubOwnerContext();
const discoveryPath = resolveDashboardDiscoveryPath();
const hubBefore = await readHubDiscovery(owner.discoveryPath);
const dashboardBefore = await readHubDashboardDiscovery(discoveryPath);
await ensureDetachedHubServer(cwd);
const hubAfter = await readHubDiscovery(owner.discoveryPath);
const hubChanged = !isSameHubProcess(hubBefore, hubAfter);
const acceptRecord = (record: HubDashboardDiscoveryRecord) =>
isExpectedDashboard(record, {
hubChanged,
hubUrl: hubAfter?.url,
previousDashboard: dashboardBefore,
});
const discovered = await readHubDashboardDiscovery(discoveryPath);
if (
discovered &&
acceptRecord(discovered) &&
(await isDashboardHealthy(discovered))
) {
return discovered;
}
if (hasHubManagedDashboardLaunchSpec() && hubChanged) {
try {
return await waitForDashboardDiscovery(discoveryPath, {
acceptRecord,
});
} catch {
// A healthy hub may outlive a failed dashboard child. Fall back to
// the CLI-managed child after the hub startup window has elapsed.
}
}
await stopDefaultDashboard();
spawnDetachedDashboardServer(cwd);
return await waitForDashboardDiscovery(discoveryPath, { acceptRecord });
});
}
async function writeDashboardDiscovery(
server: DashboardServerHandle,
): Promise<void> {
const timestamp = new Date().toISOString();
await writeHubDashboardDiscovery(resolveDashboardDiscoveryPath(), {
pid: process.pid,
listenUrl: server.listenUrl,
publicUrl: server.publicUrl,
inviteUrl: server.inviteUrl,
hubUrl: server.hubUrl,
startedAt: timestamp,
updatedAt: timestamp,
});
}
async function clearDashboardDiscovery(): Promise<void> {
await clearHubDashboardDiscovery(resolveDashboardDiscoveryPath()).catch(
() => undefined,
);
}
export function waitForProcessShutdown(
server: DashboardServerHandle,
): Promise<void> {
@@ -181,32 +394,86 @@ export function waitForProcessShutdown(
});
}
async function runDashboardServeCommand(
options: RunDashboardCommandOptions,
): Promise<number> {
return await withDashboardEnvironment(options, async () => {
const server = await (options.startServer ?? startDefaultDashboardServer)();
try {
await writeDashboardDiscovery(server);
const dashboardUrl =
server.inviteUrl || server.publicUrl || server.listenUrl;
options.io.writeln(
`${c.green}Cline dashboard listening at${c.reset} ${dashboardUrl}`,
);
if (server.hubUrl) {
options.io.writeln(`${c.dim}Hub endpoint: ${server.hubUrl}${c.reset}`);
}
await (options.waitForShutdown ?? waitForProcessShutdown)(server);
} catch (error) {
try {
await server.stop();
} catch {
// Preserve the original serve failure after best-effort cleanup.
}
throw error;
} finally {
await clearDashboardDiscovery();
}
return 0;
});
}
async function openDashboardUrl(
options: RunDashboardCommandOptions,
record: Pick<
HubDashboardDiscoveryRecord,
"inviteUrl" | "publicUrl" | "listenUrl"
>,
): Promise<void> {
const dashboardUrl = record.inviteUrl || record.publicUrl || record.listenUrl;
options.io.writeln(
`${c.green}Cline dashboard listening at${c.reset} ${dashboardUrl}`,
);
if (options.openBrowser !== false) {
try {
await (options.openUrl ?? openDefaultUrl)(dashboardUrl);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
options.io.writeErr(`Failed to open browser: ${message}`);
}
}
}
export async function runDashboardCommand(
options: RunDashboardCommandOptions,
): Promise<number> {
try {
const server = await withDashboardEnvironment(options, () =>
(options.startServer ?? startDefaultDashboardServer)(),
);
const dashboardUrl =
server.inviteUrl || server.publicUrl || server.listenUrl;
options.io.writeln(
`${c.green}Cline dashboard listening at${c.reset} ${dashboardUrl}`,
);
if (server.hubUrl) {
options.io.writeln(`${c.dim}Hub endpoint: ${server.hubUrl}${c.reset}`);
const action = options.action ?? "open";
if (action === "serve") {
return await runDashboardServeCommand(options);
}
if (options.openBrowser !== false) {
try {
await (options.openUrl ?? openDefaultUrl)(dashboardUrl);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
options.io.writeErr(`Failed to open browser: ${message}`);
if (action === "stop") {
const stopped = options.stopDashboard
? await options.stopDashboard()
: await withDashboardEnvironment(options, stopDefaultDashboard);
options.io.writeln(JSON.stringify({ stopped }));
return 0;
}
if (action === "restart") {
if (options.stopDashboard) {
await options.stopDashboard();
} else {
await withDashboardEnvironment(options, stopDefaultDashboard);
}
}
await (options.waitForShutdown ?? waitForProcessShutdown)(server);
const record = await (
options.ensureDashboard ?? (() => ensureDefaultDashboard(options))
)();
await openDashboardUrl(options, record);
if (record.hubUrl) {
options.io.writeln(`${c.dim}Hub endpoint: ${record.hubUrl}${c.reset}`);
}
return 0;
} catch (error) {
options.io.writeErr(error instanceof Error ? error.message : String(error));
+26
View File
@@ -15,6 +15,7 @@ import { getCliBuildInfo } from "../utils/common";
const {
mockSpawnSync,
mockResolveClineDataDir,
mockResolveDefaultHubOwnerContext,
mockResolveProductionHubOwnerContext,
mockResolveSharedHubOwnerContext,
mockReadHubDiscovery,
@@ -26,6 +27,16 @@ const {
} = vi.hoisted(() => ({
mockSpawnSync: vi.fn(),
mockResolveClineDataDir: vi.fn(() => "/tmp/cline-data"),
mockResolveDefaultHubOwnerContext: vi.fn(() => ({
ownerId: "hub-owner",
discoveryPath: path.join(
"/tmp/cline-data",
"locks",
"hub",
"owners",
"hub-owner.json",
),
})),
mockResolveProductionHubOwnerContext: vi.fn(() => ({
ownerId: "hub-production",
discoveryPath: path.join(
@@ -63,6 +74,7 @@ vi.mock("node:child_process", () => ({
vi.mock("@cline/core", () => ({
resolveClineDataDir: mockResolveClineDataDir,
resolveDefaultHubOwnerContext: mockResolveDefaultHubOwnerContext,
resolveProductionHubOwnerContext: mockResolveProductionHubOwnerContext,
resolveSharedHubOwnerContext: mockResolveSharedHubOwnerContext,
clearHubDiscovery: mockClearHubDiscovery,
@@ -97,6 +109,16 @@ describe("runDoctorCommand", () => {
"production.json",
),
});
mockResolveDefaultHubOwnerContext.mockReturnValue({
ownerId: "hub-owner",
discoveryPath: path.join(
"/tmp/cline-data",
"locks",
"hub",
"owners",
"hub-owner.json",
),
});
mockStopLocalHubServerGracefully.mockResolvedValue(false);
mockStopAllConnectors.mockResolvedValue({
stoppedProcesses: 0,
@@ -217,6 +239,10 @@ describe("runDoctorCommand", () => {
ownerId: "hub-owner",
discoveryPath,
});
mockResolveDefaultHubOwnerContext.mockReturnValue({
ownerId: "hub-owner",
discoveryPath,
});
mockReadHubDiscovery.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
port: 25463,
+6 -9
View File
@@ -7,11 +7,10 @@ import {
probeHubServer,
readHubDiscovery,
resolveClineDataDir,
resolveProductionHubOwnerContext,
resolveSharedHubOwnerContext,
resolveDefaultHubOwnerContext,
stopLocalHubServerGracefully,
} from "@cline/core";
import { formatUptime, resolveClineBuildEnv } from "@cline/shared";
import { formatUptime } from "@cline/shared";
import { Command } from "commander";
import open from "open";
import { version as cliVersion } from "../../package.json";
@@ -320,9 +319,7 @@ function formatHubUptimeFromStartedAt(
}
function resolveCliHubOwnerContext() {
return resolveClineBuildEnv() === "production"
? resolveProductionHubOwnerContext()
: resolveSharedHubOwnerContext();
return resolveDefaultHubOwnerContext();
}
async function collectDoctorStatus(cwd: string): Promise<DoctorStatus> {
@@ -469,9 +466,9 @@ export async function runDoctorCommand(
}
const gracefullyStoppedHub = before.hubHealthy
? await stopLocalHubServerGracefully(resolveCliHubOwnerContext()).catch(
() => false,
)
? await stopLocalHubServerGracefully({
owner: resolveCliHubOwnerContext(),
}).catch(() => false)
: false;
const refreshedAfterGracefulStop = gracefullyStoppedHub
? await collectDoctorStatus(opts.cwd)
+10 -2
View File
@@ -5,6 +5,7 @@ const {
mockEnsureDetachedHubServer,
mockProbeHubServer,
mockReadHubDiscovery,
mockResolveDefaultHubOwnerContext,
mockResolveProductionHubOwnerContext,
mockResolveSharedHubOwnerContext,
mockStopLocalHubServerGracefully,
@@ -13,6 +14,10 @@ const {
mockEnsureDetachedHubServer: vi.fn(),
mockProbeHubServer: vi.fn(),
mockReadHubDiscovery: vi.fn(),
mockResolveDefaultHubOwnerContext: vi.fn(() => ({
ownerId: "hub-owner",
discoveryPath: "/tmp/cline-data/locks/hub/owners/hub-owner.json",
})),
mockResolveProductionHubOwnerContext: vi.fn(() => ({
ownerId: "hub-production",
discoveryPath: "/tmp/cline-data/locks/hub/production.json",
@@ -29,6 +34,7 @@ vi.mock("@cline/core", () => ({
ensureDetachedHubServer: mockEnsureDetachedHubServer,
probeHubServer: mockProbeHubServer,
readHubDiscovery: mockReadHubDiscovery,
resolveDefaultHubOwnerContext: mockResolveDefaultHubOwnerContext,
resolveProductionHubOwnerContext: mockResolveProductionHubOwnerContext,
resolveSharedHubOwnerContext: mockResolveSharedHubOwnerContext,
stopLocalHubServerGracefully: mockStopLocalHubServerGracefully,
@@ -122,8 +128,10 @@ describe("createHubCommand", () => {
expect(exitCode).toBe(0);
expect(mockStopLocalHubServerGracefully).toHaveBeenCalledWith({
ownerId: "hub-owner",
discoveryPath: "/tmp/cline-data/locks/hub/owners/hub-owner.json",
owner: {
ownerId: "hub-owner",
discoveryPath: "/tmp/cline-data/locks/hub/owners/hub-owner.json",
},
});
expect(JSON.parse(output[0] || "")).toEqual({ stopped: true });
});
+4 -7
View File
@@ -3,11 +3,10 @@ import {
ensureDetachedHubServer,
probeHubServer,
readHubDiscovery,
resolveProductionHubOwnerContext,
resolveSharedHubOwnerContext,
resolveDefaultHubOwnerContext,
stopLocalHubServerGracefully,
} from "@cline/core";
import { formatUptime, resolveClineBuildEnv } from "@cline/shared";
import { formatUptime } from "@cline/shared";
import { Command } from "commander";
import { version as cliVersion } from "../../package.json";
@@ -19,7 +18,7 @@ interface HubCommandIo {
async function stopHubServer(_workspaceRoot: string): Promise<boolean> {
const owner = resolveCliHubOwnerContext();
const discovery = await readHubDiscovery(owner.discoveryPath);
if (await stopLocalHubServerGracefully(owner)) {
if (await stopLocalHubServerGracefully({ owner })) {
await clearHubDiscovery(owner.discoveryPath);
return true;
}
@@ -49,9 +48,7 @@ function formatHubUptimeFromStartedAt(
}
function resolveCliHubOwnerContext() {
return resolveClineBuildEnv() === "production"
? resolveProductionHubOwnerContext()
: resolveSharedHubOwnerContext();
return resolveDefaultHubOwnerContext();
}
export function createHubCommand(
+5 -7
View File
@@ -5,11 +5,9 @@ import {
isAutoUpdateEnabledGlobally,
probeHubServer,
readHubDiscovery,
resolveProductionHubOwnerContext,
resolveSharedHubOwnerContext,
resolveDefaultHubOwnerContext,
stopLocalHubServerGracefully,
} from "@cline/core";
import { resolveClineBuildEnv } from "@cline/shared";
import { version } from "../../package.json";
import { ensureCliHubServer } from "../utils/hub-runtime";
import { c, writeErr, writeln } from "../utils/output";
@@ -277,9 +275,7 @@ export function getPreferredKanbanInstaller(
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
export function resolveCliHubOwnerContext() {
return resolveClineBuildEnv() === "production"
? resolveProductionHubOwnerContext()
: resolveSharedHubOwnerContext();
return resolveDefaultHubOwnerContext();
}
async function waitForHubToStop(
@@ -319,7 +315,9 @@ async function restartHubServerIfRunning(): Promise<void> {
const pid = discovery?.pid;
writeln(`${c.dim}[hub] restarting server…${c.reset}`);
let stopped = await stopLocalHubServerGracefully(owner).catch(() => false);
let stopped = await stopLocalHubServerGracefully({ owner }).catch(
() => false,
);
if (!stopped && pid) {
try {
process.kill(pid, "SIGTERM");
+5
View File
@@ -61,6 +61,11 @@ if (!isMainThread) {
});
void (async () => {
const { configureCliHubDashboardLaunchEnvironment } = await import(
"./utils/hub-dashboard-launch"
);
configureCliHubDashboardLaunchEnvironment();
let exitCode = 0;
try {
const { runCli } = await import("./main");
+21
View File
@@ -1131,6 +1131,27 @@ describe("runCli lightweight command dispatch", () => {
expect(process.exitCode).toBe(0);
});
it("does not expose the internal dashboard serve action in invalid action errors", async () => {
const consoleError = vi
.spyOn(console, "error")
.mockImplementation(() => undefined);
process.argv = ["bun", "src/index.ts", "dashboard", "nonesuch"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(consoleError).toHaveBeenCalledWith(
expect.stringContaining(
'Unknown dashboard action "nonesuch". Expected restart or stop.',
),
);
expect(
consoleError.mock.calls.some((call) => String(call[0]).includes("serve")),
).toBe(false);
expect(dashboardMocks.runDashboardCommand).not.toHaveBeenCalled();
expect(process.exitCode).toBe(1);
});
it("prints an install hint when kanban is missing", async () => {
const stderrWrite = vi
.spyOn(process.stderr, "write")
+17 -2
View File
@@ -623,7 +623,8 @@ export async function runCli(): Promise<void> {
const dashboardCmd = program
.command("dashboard")
.description("Start the Cline Hub dashboard and open it in a browser")
.description("Open or manage the Cline Hub dashboard")
.argument("[action]", "dashboard action: restart or stop")
.option("--config <dir>", "configuration directory")
.option("-c, --cwd <path>", "Workspace root", process.cwd())
.option(
@@ -635,7 +636,7 @@ export async function runCli(): Promise<void> {
.option("--public-url <url>", "Public dashboard URL")
.option("--room-secret <secret>", "Invite secret for browser access")
.option("--no-open", "Start the dashboard without opening a browser")
.action(async () => {
.action(async (action?: string) => {
const opts = dashboardCmd.opts<{
config?: string;
cwd?: string;
@@ -646,8 +647,22 @@ export async function runCli(): Promise<void> {
roomSecret?: string;
open?: boolean;
}>();
const normalizedAction = action?.trim().toLowerCase();
if (
normalizedAction &&
normalizedAction !== "restart" &&
normalizedAction !== "stop" &&
normalizedAction !== "serve"
) {
io.writeErr(
`Unknown dashboard action "${action}". Expected restart or stop.`,
);
ctx.exitCode = 1;
return;
}
const { runDashboardCommand } = await import("./commands/dashboard");
ctx.exitCode = await runDashboardCommand({
action: normalizedAction as "restart" | "serve" | "stop" | undefined,
configDir: opts.config,
cwd: opts.cwd,
dataDir: opts.dataDir,
@@ -0,0 +1,19 @@
import { buildCliSubcommandCommand } from "./internal-launch";
const DASHBOARD_LAUNCHER_ENV = "CLINE_HUB_DASHBOARD_LAUNCHER";
const DASHBOARD_ARGS_ENV = "CLINE_HUB_DASHBOARD_ARGS";
export function configureCliHubDashboardLaunchEnvironment(): void {
if (
process.env[DASHBOARD_LAUNCHER_ENV]?.trim() &&
process.env[DASHBOARD_ARGS_ENV]?.trim()
) {
return;
}
const command = buildCliSubcommandCommand("dashboard", ["serve"]);
if (!command) {
return;
}
process.env[DASHBOARD_LAUNCHER_ENV] = command.launcher;
process.env[DASHBOARD_ARGS_ENV] = JSON.stringify(command.childArgs);
}
+6
View File
@@ -23,6 +23,12 @@ bun run start
Open <http://127.0.0.1:8787> and click **Connect**. The server will discover or spawn a local detached hub on startup; the hub endpoint is printed in the console and shown in the sidebar.
From the CLI, `cline dashboard` opens the running dashboard in the default
browser and exits. The local hub daemon owns the background dashboard process
when the hub is launched by the CLI; starting a new hub replaces the discovered
dashboard process. Use `cline dashboard restart` or `cline dashboard stop` for
manual lifecycle control.
For webview development with Vite hot reload:
```bash
+3 -1
View File
@@ -54,7 +54,9 @@ process.on("SIGINT", () => shutdown(0));
process.on("SIGTERM", () => shutdown(0));
console.log(`[cline-hub:dev] Vite webview: ${webviewDevServerUrl}`);
console.log("[cline-hub:dev] Hub dashboard: http://127.0.0.1:8787/");
console.log(
"[cline-hub:dev] Hub dashboard: use the invite URL printed by the server (includes roomSecret).",
);
spawn(
"webview",
+42
View File
@@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import {
buildDashboardLaunchUrl,
resolveClineHubServerOptions,
} from "./options";
describe("resolveClineHubServerOptions", () => {
it("generates a room secret for local dashboard bridges by default", () => {
const options = resolveClineHubServerOptions({});
expect(options.host).toBe("127.0.0.1");
expect(options.publicUrl).toBe("http://127.0.0.1:8787");
expect(options.dashboardWebUrl).toBe("http://127.0.0.1:8787");
expect(options.roomSecret).toMatch(/^[a-f0-9]{64}$/);
});
it("resolves a hosted dashboard web URL separately from the local bridge URL", () => {
const options = resolveClineHubServerOptions({
PUBLIC_URL: "http://127.0.0.1:8787/",
CLINE_HUB_DASHBOARD_WEB_URL: "https://cline.bot/dashboard/",
ROOM_SECRET: "invite-123",
});
expect(options.publicUrl).toBe("http://127.0.0.1:8787");
expect(options.dashboardWebUrl).toBe("https://cline.bot/dashboard");
expect(options.roomSecret).toBe("invite-123");
});
});
describe("buildDashboardLaunchUrl", () => {
it("puts local bridge credentials in the URL fragment", () => {
expect(
buildDashboardLaunchUrl(
"https://cline.bot/dashboard",
"http://127.0.0.1:8787",
"invite-123",
),
).toBe(
"https://cline.bot/dashboard#bridgeUrl=http%3A%2F%2F127.0.0.1%3A8787&roomSecret=invite-123",
);
});
});
+44 -15
View File
@@ -1,16 +1,19 @@
import { randomBytes } from "node:crypto";
import { isIP } from "node:net";
export interface ClineHubServerOptions {
host: string;
port: number;
publicUrl: string;
roomSecret?: string;
dashboardWebUrl: string;
roomSecret: string;
workspaceRoot: string;
}
const DEFAULT_HOST = "127.0.0.1";
const DEFAULT_PORT = 8787;
const DASHBOARD_PORT_ENV = "CLINE_HUB_DASHBOARD_PORT";
const DASHBOARD_WEB_URL_ENV = "CLINE_HUB_DASHBOARD_WEB_URL";
function parsePort(value: string | undefined): number {
if (!value?.trim()) return DEFAULT_PORT;
@@ -56,9 +59,33 @@ function normalizePublicUrl(
return parsed.toString().replace(/\/$/, "");
}
function normalizeRoomSecret(value: string | undefined): string | undefined {
function normalizeDashboardWebUrl(
value: string | undefined,
publicUrl: string,
): string {
const raw = value?.trim() || publicUrl;
let parsed: URL;
try {
parsed = new URL(raw);
} catch (error) {
throw new Error(
`${DASHBOARD_WEB_URL_ENV} must be a valid http(s) URL, got ${raw}: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error(
`${DASHBOARD_WEB_URL_ENV} must use http: or https:, got ${parsed.protocol}`,
);
}
parsed.hash = "";
return parsed.toString().replace(/\/$/, "");
}
function normalizeRoomSecret(value: string | undefined): string {
const secret = value?.trim();
return secret ? secret : undefined;
return secret ? secret : randomBytes(32).toString("hex");
}
function isLocalBindHost(host: string): boolean {
@@ -75,16 +102,16 @@ export function resolveClineHubServerOptions(
const host = normalizeHost(env.HOST);
const port = parsePort(env[DASHBOARD_PORT_ENV]);
const publicUrl = normalizePublicUrl(env.PUBLIC_URL, host, port);
const dashboardWebUrl = normalizeDashboardWebUrl(
env[DASHBOARD_WEB_URL_ENV],
publicUrl,
);
const roomSecret = normalizeRoomSecret(env.ROOM_SECRET);
if (isNonLocalBindHost(host) && !roomSecret) {
throw new Error(
`ROOM_SECRET is required when HOST=${host}. Use HOST=127.0.0.1 for local-only development or set ROOM_SECRET before exposing this example on a LAN/tunnel.`,
);
}
return {
host,
port,
publicUrl,
dashboardWebUrl,
roomSecret,
workspaceRoot: env.WORKSPACE_ROOT?.trim() || process.cwd(),
};
@@ -103,13 +130,15 @@ function shouldAddDashboardPortToPublicUrl(url: URL, port: number): boolean {
return hostname === "localhost" || isIP(hostname) !== 0;
}
export function buildInviteUrl(
publicUrl: string,
roomSecret: string | undefined,
export function buildDashboardLaunchUrl(
dashboardWebUrl: string,
bridgeUrl: string,
roomSecret: string,
): string {
const url = new URL(publicUrl);
if (roomSecret) {
url.searchParams.set("roomSecret", roomSecret);
}
const url = new URL(dashboardWebUrl);
const fragment = new URLSearchParams(url.hash.replace(/^#/, ""));
fragment.set("bridgeUrl", bridgeUrl);
fragment.set("roomSecret", roomSecret);
url.hash = fragment.toString();
return url.toString();
}
+18 -19
View File
@@ -1,5 +1,5 @@
import { CORE_BUILD_VERSION } from "@cline/core";
import { isNonLocalBindHost } from "./options";
import { buildDashboardLaunchUrl } from "./options";
import {
handleToolApprovalResponse,
rejectOrphanedApprovals,
@@ -7,8 +7,8 @@ import {
import { isAuthorizedBrowserToDesktopRequest } from "./server/browser-auth";
import {
browserConfig,
dashboardWebUrl,
host,
inviteUrl,
port,
publicUrl,
roomSecret,
@@ -23,10 +23,13 @@ import {
import {
attachHub,
detachHub,
restartHub,
syncHubClientsAndSessions,
syncHubHealth,
} from "./server/hub";
import {
connectHubFromWebview,
restartHubFromWebview,
} from "./server/hub-actions";
import { fetchMarketplaceCatalog } from "./server/marketplace";
import {
loadModels,
@@ -52,8 +55,6 @@ export interface ClineHubDashboardServer {
listenUrl: string;
publicUrl: string;
inviteUrl: string;
bindHost: string;
inviteRequired: boolean;
hubUrl: string | undefined;
stop: () => Promise<void>;
}
@@ -85,7 +86,7 @@ export async function startClineHubDashboardServer(): Promise<ClineHubDashboardS
const syncClientsAndSessions = () => syncHubClientsAndSessions(ctx);
let stopped = false;
await attachHub(ctx);
await attachHub(ctx, { preserveDashboard: true });
const healthInterval = setInterval(() => {
void (async () => {
await syncHubHealth(ctx);
@@ -104,6 +105,7 @@ export async function startClineHubDashboardServer(): Promise<ClineHubDashboardS
url,
{
bindHost: host,
dashboardWebUrl,
port,
publicUrl,
roomSecret,
@@ -183,6 +185,13 @@ export async function startClineHubDashboardServer(): Promise<ClineHubDashboardS
}
} else if (frame.type === "ready") {
await initializePeer(ctx, peer, syncClientsAndSessions);
} else if (frame.type === "connect_hub") {
await connectHubFromWebview(
ctx,
peer,
frame,
syncClientsAndSessions,
);
} else if (frame.type === "loadModels") {
await loadModels(ctx, peer, frame.providerId);
} else if (frame.type === "loadProviderCatalog") {
@@ -243,7 +252,7 @@ export async function startClineHubDashboardServer(): Promise<ClineHubDashboardS
syncClientsAndSessions,
);
} else if (frame.type === "restart_hub") {
await restartHub(ctx);
await restartHubFromWebview(ctx, peer);
}
} catch (error) {
ctx.send(peer, {
@@ -264,9 +273,7 @@ export async function startClineHubDashboardServer(): Promise<ClineHubDashboardS
return {
listenUrl: server.url.toString(),
publicUrl,
inviteUrl,
bindHost: host,
inviteRequired: Boolean(roomSecret),
inviteUrl: buildDashboardLaunchUrl(dashboardWebUrl, publicUrl, roomSecret),
hubUrl: ctx.hubUrl,
stop: async () => {
if (stopped) return;
@@ -287,15 +294,7 @@ export function printClineHubDashboardServerInfo(
console.log(`Cline Hub dashboard listening: ${server.listenUrl}`);
console.log(`Cline Hub public URL: ${server.publicUrl}`);
console.log(`hub endpoint: ${server.hubUrl}`);
if (server.inviteRequired) {
console.log(`Cline Hub invite URL: ${server.inviteUrl}`);
} else if (isNonLocalBindHost(server.bindHost)) {
console.warn("WARNING: non-local bind without ROOM_SECRET is not allowed.");
} else {
console.log(
"ROOM_SECRET is not set; this local-only instance accepts browser connections without an invite token.",
);
}
console.log(`Cline Hub invite URL: ${server.inviteUrl}`);
}
if (import.meta.main) {
@@ -89,6 +89,22 @@ describe("allowedBrowserOrigins", () => {
].sort(),
).toEqual(["https://0.0.0.0:8787", "https://example.ngrok-free.app"]);
});
it("allows a separately hosted dashboard web origin", () => {
expect(
[
...allowedBrowserOrigins({
...defaultOptions,
dashboardWebUrl: "https://cline.bot/dashboard",
}),
].sort(),
).toEqual([
"http://127.0.0.1:8787",
"http://[::1]:8787",
"http://localhost:8787",
"https://cline.bot",
]);
});
});
describe("allowedBrowserHosts", () => {
@@ -292,6 +308,20 @@ describe("isAuthorizedBrowserRequest", () => {
),
).toBe(false);
});
it("allows hosted dashboard browsers to connect to the local bridge with the room secret", () => {
expect(
isAuthorizedBrowserRequest(
browserRequest("https://cline.bot"),
new URL("http://127.0.0.1:8787/browser?roomSecret=invite-123"),
{
...defaultOptions,
dashboardWebUrl: "https://cline.bot/dashboard",
roomSecret: "invite-123",
},
),
).toBe(true);
});
});
describe("isAuthorizedBrowserToDesktopRequest", () => {
@@ -2,6 +2,7 @@ import { isNonLocalBindHost } from "../options";
export interface BrowserRequestAuthOptions {
bindHost: string;
dashboardWebUrl?: string;
port: number;
publicUrl: string;
roomSecret?: string;
@@ -57,12 +58,16 @@ function hostHeaderForHost(
export function allowedBrowserOrigins({
bindHost,
dashboardWebUrl,
port,
publicUrl,
}: BrowserRequestAuthOptions): Set<string> {
const publicUrlParts = new URL(publicUrl);
const origins = new Set<string>();
origins.add(publicUrlParts.origin);
if (dashboardWebUrl?.trim()) {
origins.add(new URL(dashboardWebUrl).origin);
}
origins.add(originForHost(publicUrlParts.protocol, bindHost, port));
+11 -4
View File
@@ -1,12 +1,18 @@
import { dirname, join, normalize } from "node:path";
import { fileURLToPath } from "node:url";
import { ProviderSettingsManager } from "@cline/core";
import { buildInviteUrl, resolveClineHubServerOptions } from "../options";
import { resolveClineHubServerOptions } from "../options";
import type { BrowserConfig } from "./types";
export const options = resolveClineHubServerOptions();
export const { host, port, publicUrl, roomSecret, workspaceRoot } = options;
export const inviteUrl = buildInviteUrl(publicUrl, roomSecret);
export const {
dashboardWebUrl,
host,
port,
publicUrl,
roomSecret,
workspaceRoot,
} = options;
const serverDir = dirname(fileURLToPath(import.meta.url));
/** server.ts lives one level up from this module, so resolve relative to it. */
@@ -21,6 +27,7 @@ export const cliIndexPath = normalize(
export const providerSettingsManager = new ProviderSettingsManager();
export const browserConfig: BrowserConfig = {
inviteRequired: Boolean(roomSecret),
bridgeUrl: publicUrl,
dashboardWebUrl,
publicUrl,
};
@@ -0,0 +1,164 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
attachHub: vi.fn(),
initializePeer: vi.fn(),
restartHub: vi.fn(),
}));
vi.mock("./hub", () => ({
attachHub: mocks.attachHub,
restartHub: mocks.restartHub,
}));
vi.mock("./sessions", () => ({
initializePeer: mocks.initializePeer,
}));
describe("connectHubFromWebview", () => {
beforeEach(() => {
mocks.attachHub.mockReset();
mocks.initializePeer.mockReset();
mocks.restartHub.mockReset();
});
it("reports the attached hub after initialization succeeds", async () => {
const send = vi.fn();
const ctx = {
hubUrl: "ws://127.0.0.1:25464/hub",
send,
};
const peer = {};
const syncClientsAndSessions = vi.fn(async () => undefined);
const { connectHubFromWebview } = await import("./hub-actions");
await connectHubFromWebview(
ctx as never,
peer as never,
{
type: "connect_hub",
hubUrl: "ws://127.0.0.1:25464/hub?authToken=custom-token",
},
syncClientsAndSessions,
);
expect(mocks.attachHub).toHaveBeenCalledWith(ctx, {
hubUrl: "ws://127.0.0.1:25464/hub?authToken=custom-token",
authToken: undefined,
});
expect(mocks.initializePeer).toHaveBeenCalledWith(
ctx,
peer,
syncClientsAndSessions,
);
expect(send).toHaveBeenCalledWith(peer, {
type: "hub_connection_result",
ok: true,
hubUrl: "ws://127.0.0.1:25464/hub",
});
});
it("reports connection failures without reinitializing the peer", async () => {
mocks.attachHub.mockRejectedValue(new Error("connection refused"));
const send = vi.fn();
const ctx = { send };
const peer = {};
const { connectHubFromWebview } = await import("./hub-actions");
await connectHubFromWebview(
ctx as never,
peer as never,
{
type: "connect_hub",
hubUrl: "ws://127.0.0.1:25464/hub",
authToken: "custom-token",
},
vi.fn(),
);
expect(mocks.initializePeer).not.toHaveBeenCalled();
expect(send).toHaveBeenCalledWith(peer, {
type: "hub_connection_result",
ok: false,
error: "connection refused",
});
});
it("keeps a successful connection result when peer refresh fails", async () => {
mocks.initializePeer.mockRejectedValue(
new Error("provider refresh failed"),
);
const send = vi.fn();
const ctx = {
hubUrl: "ws://127.0.0.1:25464/hub",
send,
};
const peer = {};
const { connectHubFromWebview } = await import("./hub-actions");
await connectHubFromWebview(
ctx as never,
peer as never,
{
type: "connect_hub",
hubUrl: "ws://127.0.0.1:25464/hub",
authToken: "custom-token",
},
vi.fn(),
);
expect(send).toHaveBeenCalledWith(peer, {
type: "hub_connection_result",
ok: true,
hubUrl: "ws://127.0.0.1:25464/hub",
});
expect(send).not.toHaveBeenCalledWith(
peer,
expect.objectContaining({
type: "hub_connection_result",
ok: false,
}),
);
expect(send).toHaveBeenCalledWith(peer, {
type: "error",
text: "Connected to the hub, but failed to refresh dashboard state: provider refresh failed",
});
});
});
describe("restartHubFromWebview", () => {
beforeEach(() => {
mocks.restartHub.mockReset();
});
it("reports a successful restart", async () => {
const send = vi.fn();
const ctx = { send };
const peer = {};
const { restartHubFromWebview } = await import("./hub-actions");
await restartHubFromWebview(ctx as never, peer as never);
expect(mocks.restartHub).toHaveBeenCalledWith(ctx);
expect(send).toHaveBeenCalledWith(peer, {
type: "hub_restart_result",
ok: true,
});
});
it("reports restart failures", async () => {
mocks.restartHub.mockRejectedValue(new Error("Unable to stop the hub"));
const send = vi.fn();
const ctx = { send };
const peer = {};
const { restartHubFromWebview } = await import("./hub-actions");
await restartHubFromWebview(ctx as never, peer as never);
expect(send).toHaveBeenCalledWith(peer, {
type: "hub_restart_result",
ok: false,
error: "Unable to stop the hub",
});
});
});
+66
View File
@@ -0,0 +1,66 @@
import type { WebviewInboundMessage } from "../webview-protocol";
import { attachHub, restartHub } from "./hub";
import { initializePeer } from "./sessions";
import type { HubContext } from "./state";
import type { BrowserPeer } from "./types";
type ConnectHubMessage = Extract<
WebviewInboundMessage,
{ type: "connect_hub" }
>;
export async function connectHubFromWebview(
ctx: HubContext,
peer: BrowserPeer,
frame: ConnectHubMessage,
syncClientsAndSessions: () => Promise<void>,
): Promise<void> {
try {
await attachHub(ctx, {
hubUrl: frame.hubUrl,
authToken: frame.authToken,
});
} catch (error) {
ctx.send(peer, {
type: "hub_connection_result",
ok: false,
error: error instanceof Error ? error.message : String(error),
});
return;
}
ctx.send(peer, {
type: "hub_connection_result",
ok: true,
hubUrl: ctx.hubUrl,
});
try {
await initializePeer(ctx, peer, syncClientsAndSessions);
} catch (error) {
ctx.send(peer, {
type: "error",
text: `Connected to the hub, but failed to refresh dashboard state: ${
error instanceof Error ? error.message : String(error)
}`,
});
}
}
export async function restartHubFromWebview(
ctx: HubContext,
peer: BrowserPeer,
): Promise<void> {
try {
await restartHub(ctx);
ctx.send(peer, {
type: "hub_restart_result",
ok: true,
});
} catch (error) {
ctx.send(peer, {
type: "hub_restart_result",
ok: false,
error: error instanceof Error ? error.message : String(error),
});
}
}
+366
View File
@@ -0,0 +1,366 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
createCline: vi.fn(),
createUiClient: vi.fn(),
ensureDetachedHubServer: vi.fn(),
probeHubServer: vi.fn(),
readHubDashboardDiscovery: vi.fn(),
readHubDiscovery: vi.fn(),
rememberRecoverableLocalHubUrl: vi.fn((url: string) => url),
resolveDefaultHubOwnerContext: vi.fn(() => ({
discoveryPath: "/tmp/hub.json",
})),
resolveHubDashboardDiscoveryPath: vi.fn(() => "/tmp/dashboard.json"),
stopLocalHubServerGracefully: vi.fn(),
writeHubDashboardDiscovery: vi.fn(),
rejectAllPendingApprovals: vi.fn(),
broadcastHubState: vi.fn(),
}));
vi.mock("@cline/core", () => ({
CLINE_HUB_DASHBOARD_DISCOVERY_PATH_ENV: "CLINE_HUB_DASHBOARD_DISCOVERY_PATH",
CORE_BUILD_VERSION: "test",
ClineCore: { create: mocks.createCline },
HubUIClient: vi.fn(function HubUIClient(options: unknown) {
return mocks.createUiClient(options);
}),
ensureDetachedHubServer: mocks.ensureDetachedHubServer,
probeHubServer: mocks.probeHubServer,
readHubDashboardDiscovery: mocks.readHubDashboardDiscovery,
readHubDiscovery: mocks.readHubDiscovery,
rememberRecoverableLocalHubUrl: mocks.rememberRecoverableLocalHubUrl,
resolveDefaultHubOwnerContext: mocks.resolveDefaultHubOwnerContext,
resolveHubDashboardDiscoveryPath: mocks.resolveHubDashboardDiscoveryPath,
stopLocalHubServerGracefully: mocks.stopLocalHubServerGracefully,
toHubHealthUrl: (url: string) => url,
writeHubDashboardDiscovery: mocks.writeHubDashboardDiscovery,
}));
vi.mock("./agent-events", () => ({
handleSessionEvent: vi.fn(),
}));
vi.mock("./approvals", () => ({
rejectAllPendingApprovals: mocks.rejectAllPendingApprovals,
requestToolApprovalFromWebview: vi.fn(),
}));
vi.mock("./deps", () => ({
workspaceRoot: "/workspace",
}));
vi.mock("./state-payloads", () => ({
broadcastHubState: mocks.broadcastHubState,
}));
function createClineClient() {
return {
dispose: vi.fn(async () => undefined),
subscribe: vi.fn(),
};
}
function createUiClient(options: { connectError?: Error } = {}) {
return {
close: vi.fn(),
connect: options.connectError
? vi.fn(async () => {
throw options.connectError;
})
: vi.fn(async () => undefined),
listClients: vi.fn(async () => []),
listSessions: vi.fn(async () => []),
subscribeUI: vi.fn(),
};
}
describe("hub attachment lifecycle", () => {
beforeEach(() => {
mocks.createCline.mockReset();
mocks.createUiClient.mockReset();
mocks.ensureDetachedHubServer.mockReset();
mocks.ensureDetachedHubServer.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
authToken: "new-token",
});
mocks.probeHubServer.mockReset();
mocks.probeHubServer.mockResolvedValue(undefined);
mocks.readHubDashboardDiscovery.mockReset();
mocks.readHubDashboardDiscovery.mockResolvedValue(undefined);
mocks.readHubDiscovery.mockReset();
mocks.readHubDiscovery.mockResolvedValue(undefined);
mocks.rememberRecoverableLocalHubUrl.mockClear();
mocks.resolveDefaultHubOwnerContext.mockClear();
mocks.resolveHubDashboardDiscoveryPath.mockClear();
mocks.stopLocalHubServerGracefully.mockReset();
mocks.stopLocalHubServerGracefully.mockResolvedValue(true);
mocks.writeHubDashboardDiscovery.mockReset();
mocks.writeHubDashboardDiscovery.mockResolvedValue(undefined);
mocks.rejectAllPendingApprovals.mockClear();
mocks.broadcastHubState.mockClear();
vi.stubGlobal(
"fetch",
vi.fn(async () => ({ ok: false })),
);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it("keeps the existing attachment when a replacement connection fails", async () => {
const oldCline = createClineClient();
const oldUiClient = createUiClient();
const nextCline = createClineClient();
const connectError = new Error("connection refused");
const nextUiClient = createUiClient({ connectError });
mocks.createCline.mockResolvedValue(nextCline);
mocks.createUiClient.mockReturnValue(nextUiClient);
const { HubContext } = await import("./state");
const ctx = new HubContext();
ctx.hubUrl = "ws://127.0.0.1:25463/hub";
ctx.hubAuthToken = "old-token";
ctx.hubManagedLocally = true;
ctx.cline = oldCline as never;
ctx.uiClient = oldUiClient as never;
const { attachHub } = await import("./hub");
await expect(
attachHub(ctx, {
hubUrl: "ws://127.0.0.1:25464/hub?authToken=new-token",
}),
).rejects.toThrow("connection refused");
expect(oldUiClient.close).not.toHaveBeenCalled();
expect(oldCline.dispose).not.toHaveBeenCalled();
expect(ctx.uiClient).toBe(oldUiClient);
expect(ctx.cline).toBe(oldCline);
expect(ctx.hubUrl).toBe("ws://127.0.0.1:25463/hub");
expect(ctx.hubManagedLocally).toBe(true);
expect(nextUiClient.close).toHaveBeenCalledOnce();
expect(nextCline.dispose).toHaveBeenCalledOnce();
});
it("preserves the dashboard when the initial attachment starts a hub", async () => {
const nextCline = createClineClient();
const nextUiClient = createUiClient();
mocks.createCline.mockResolvedValue(nextCline);
mocks.createUiClient.mockReturnValue(nextUiClient);
const { HubContext } = await import("./state");
const ctx = new HubContext();
const { attachHub } = await import("./hub");
await attachHub(ctx);
expect(mocks.ensureDetachedHubServer).toHaveBeenCalledWith("/workspace", {
preserveDashboard: true,
});
expect(ctx.hubManagedLocally).toBe(true);
});
it("uses discovery auth and remains restartable for the managed local hub URL", async () => {
const nextCline = createClineClient();
const nextUiClient = createUiClient();
mocks.createCline.mockResolvedValue(nextCline);
mocks.createUiClient.mockReturnValue(nextUiClient);
mocks.readHubDiscovery.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
authToken: "discovery-token",
});
const { HubContext } = await import("./state");
const ctx = new HubContext();
const { attachHub } = await import("./hub");
await attachHub(ctx, {
hubUrl: "ws://127.0.0.1:25463/hub",
});
expect(mocks.readHubDiscovery).toHaveBeenCalledWith("/tmp/hub.json");
expect(mocks.createCline).toHaveBeenCalledWith(
expect.objectContaining({
hub: expect.objectContaining({
endpoint: "ws://127.0.0.1:25463/hub",
authToken: "discovery-token",
}),
}),
);
expect(mocks.createUiClient).toHaveBeenCalledWith(
expect.objectContaining({
address: "ws://127.0.0.1:25463/hub",
authToken: "discovery-token",
}),
);
expect(ctx.hubManagedLocally).toBe(true);
});
it("refreshes this dashboard's discovery after a custom reattach", async () => {
const nextCline = createClineClient();
const nextUiClient = createUiClient();
mocks.createCline.mockResolvedValue(nextCline);
mocks.createUiClient.mockReturnValue(nextUiClient);
mocks.readHubDashboardDiscovery.mockResolvedValue({
pid: process.pid,
listenUrl: "http://127.0.0.1:8787",
publicUrl: "http://127.0.0.1:8787",
inviteUrl: "https://cline.bot/dashboard#bridgeUrl=old",
hubUrl: "ws://127.0.0.1:25463/hub",
startedAt: "2026-07-24T00:00:00.000Z",
updatedAt: "2026-07-24T00:00:00.000Z",
});
const { HubContext } = await import("./state");
const ctx = new HubContext();
const { attachHub } = await import("./hub");
await attachHub(ctx, {
hubUrl: "ws://127.0.0.1:25464/hub?authToken=custom-token",
});
expect(mocks.writeHubDashboardDiscovery).toHaveBeenCalledWith(
"/tmp/dashboard.json",
expect.objectContaining({
pid: process.pid,
hubUrl: "ws://127.0.0.1:25464/hub",
updatedAt: expect.any(String),
}),
);
expect(ctx.hubManagedLocally).toBe(false);
});
it("preserves the dashboard while replacing and reattaching to the hub", async () => {
const oldCline = createClineClient();
const oldUiClient = createUiClient();
const nextCline = createClineClient();
const nextUiClient = createUiClient();
mocks.createCline.mockResolvedValue(nextCline);
mocks.createUiClient.mockReturnValue(nextUiClient);
const { HubContext } = await import("./state");
const ctx = new HubContext();
ctx.hubManagedLocally = true;
ctx.cline = oldCline as never;
ctx.uiClient = oldUiClient as never;
const { restartHub } = await import("./hub");
await restartHub(ctx);
expect(mocks.stopLocalHubServerGracefully).toHaveBeenCalledWith({
preserveDashboard: true,
});
expect(mocks.ensureDetachedHubServer).toHaveBeenCalledWith("/workspace", {
preserveDashboard: true,
});
expect(nextUiClient.connect).toHaveBeenCalledOnce();
expect(oldUiClient.close).toHaveBeenCalledOnce();
expect(oldCline.dispose).toHaveBeenCalledOnce();
expect(ctx.uiClient).toBe(nextUiClient);
expect(ctx.cline).toBe(nextCline);
});
it("remains detached when a stopped hub cannot be replaced", async () => {
const oldCline = createClineClient();
const oldUiClient = createUiClient();
const nextCline = createClineClient();
const nextUiClient = createUiClient({
connectError: new Error("replacement unavailable"),
});
mocks.createCline.mockResolvedValue(nextCline);
mocks.createUiClient.mockReturnValue(nextUiClient);
const { HubContext } = await import("./state");
const ctx = new HubContext();
ctx.hubUrl = "ws://127.0.0.1:25463/hub";
ctx.hubAuthToken = "old-token";
ctx.hubManagedLocally = true;
ctx.cline = oldCline as never;
ctx.uiClient = oldUiClient as never;
const { restartHub } = await import("./hub");
await expect(restartHub(ctx)).rejects.toThrow("replacement unavailable");
expect(oldUiClient.close).toHaveBeenCalledOnce();
expect(oldCline.dispose).toHaveBeenCalledOnce();
expect(nextUiClient.close).toHaveBeenCalledOnce();
expect(nextCline.dispose).toHaveBeenCalledOnce();
expect(ctx.uiClient).toBeUndefined();
expect(ctx.cline).toBeUndefined();
expect(ctx.hubManagedLocally).toBe(false);
expect(mocks.broadcastHubState).toHaveBeenCalledOnce();
});
it("does not claim a restart when the current hub cannot stop", async () => {
mocks.stopLocalHubServerGracefully.mockResolvedValue(false);
mocks.probeHubServer.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
});
const oldCline = createClineClient();
const oldUiClient = createUiClient();
const { HubContext } = await import("./state");
const ctx = new HubContext();
ctx.hubUrl = "ws://127.0.0.1:25463/hub";
ctx.hubAuthToken = "old-token";
ctx.hubManagedLocally = true;
ctx.cline = oldCline as never;
ctx.uiClient = oldUiClient as never;
const { restartHub } = await import("./hub");
await expect(restartHub(ctx)).rejects.toThrow(
"Unable to stop the current Cline Hub.",
);
expect(mocks.probeHubServer).toHaveBeenCalledWith(ctx.hubUrl, {
authToken: "old-token",
});
expect(mocks.ensureDetachedHubServer).not.toHaveBeenCalled();
expect(oldUiClient.close).not.toHaveBeenCalled();
expect(oldCline.dispose).not.toHaveBeenCalled();
});
it("reattaches when the current hub crashed before it could stop", async () => {
mocks.stopLocalHubServerGracefully.mockResolvedValue(false);
const oldCline = createClineClient();
const oldUiClient = createUiClient();
const nextCline = createClineClient();
const nextUiClient = createUiClient();
mocks.createCline.mockResolvedValue(nextCline);
mocks.createUiClient.mockReturnValue(nextUiClient);
const { HubContext } = await import("./state");
const ctx = new HubContext();
ctx.hubUrl = "ws://127.0.0.1:25463/hub";
ctx.hubAuthToken = "old-token";
ctx.hubManagedLocally = true;
ctx.cline = oldCline as never;
ctx.uiClient = oldUiClient as never;
const { restartHub } = await import("./hub");
await restartHub(ctx);
expect(mocks.probeHubServer).toHaveBeenCalledWith(
"ws://127.0.0.1:25463/hub",
{ authToken: "old-token" },
);
expect(mocks.ensureDetachedHubServer).toHaveBeenCalledWith("/workspace", {
preserveDashboard: true,
});
expect(nextUiClient.connect).toHaveBeenCalledOnce();
expect(oldUiClient.close).toHaveBeenCalledOnce();
expect(oldCline.dispose).toHaveBeenCalledOnce();
expect(ctx.uiClient).toBe(nextUiClient);
expect(ctx.cline).toBe(nextCline);
});
it("does not restart or replace a custom hub attachment", async () => {
const { HubContext } = await import("./state");
const ctx = new HubContext();
ctx.hubUrl = "ws://custom.example.test/hub";
ctx.hubAuthToken = "custom-token";
ctx.hubManagedLocally = false;
const { restartHub } = await import("./hub");
await expect(restartHub(ctx)).rejects.toThrow(
"Custom hubs must be restarted externally",
);
expect(mocks.stopLocalHubServerGracefully).not.toHaveBeenCalled();
expect(mocks.ensureDetachedHubServer).not.toHaveBeenCalled();
});
});
+152 -16
View File
@@ -1,10 +1,18 @@
import {
CLINE_HUB_DASHBOARD_DISCOVERY_PATH_ENV,
ClineCore,
ensureDetachedHubServer,
type HubServerDiscoveryRecord,
HubUIClient,
probeHubServer,
readHubDashboardDiscovery,
readHubDiscovery,
rememberRecoverableLocalHubUrl,
resolveDefaultHubOwnerContext,
resolveHubDashboardDiscoveryPath,
stopLocalHubServerGracefully,
toHubHealthUrl,
writeHubDashboardDiscovery,
} from "@cline/core";
import type { HubUINotifyPayload } from "@cline/shared";
import { handleSessionEvent } from "./agent-events";
@@ -24,6 +32,30 @@ import { broadcastHubState } from "./state-payloads";
import type { SessionContext } from "./types";
import { asString, basename, isActiveSession, isVisibleClient } from "./utils";
function resolveDashboardDiscoveryPath(): string {
return (
process.env[CLINE_HUB_DASHBOARD_DISCOVERY_PATH_ENV]?.trim() ||
resolveHubDashboardDiscoveryPath(resolveDefaultHubOwnerContext())
);
}
async function refreshDashboardDiscoveryHubUrl(hubUrl: string): Promise<void> {
const discoveryPath = resolveDashboardDiscoveryPath();
const discovered = await readHubDashboardDiscovery(discoveryPath);
if (
!discovered ||
discovered.pid !== process.pid ||
discovered.hubUrl === hubUrl
) {
return;
}
await writeHubDashboardDiscovery(discoveryPath, {
...discovered,
hubUrl,
updatedAt: new Date().toISOString(),
});
}
export async function syncHubHealth(ctx: HubContext): Promise<void> {
if (!ctx.hubUrl) {
ctx.hubHealthy = false;
@@ -89,12 +121,74 @@ export async function syncHubClientsAndSessions(
if (mostRecent) ctx.lastSessionContext = mostRecent;
}
export async function attachHub(ctx: HubContext): Promise<void> {
const hub = await ensureDetachedHubServer(workspaceRoot);
ctx.hubUrl = hub.url;
ctx.hubAuthToken = hub.authToken;
export interface HubAttachmentOverride {
hubUrl?: string;
authToken?: string;
preserveDashboard?: boolean;
}
ctx.cline = await ClineCore.create({
function sameHubEndpoint(left: string, right: string): boolean {
const leftUrl = new URL(left);
const rightUrl = new URL(right);
leftUrl.search = "";
leftUrl.hash = "";
rightUrl.search = "";
rightUrl.hash = "";
return leftUrl.toString() === rightUrl.toString();
}
async function resolveHubAttachmentOverride(
override?: HubAttachmentOverride,
): Promise<
{ hubUrl: string; authToken: string; managedLocally: boolean } | undefined
> {
const rawHubUrl = override?.hubUrl?.trim();
if (!rawHubUrl) {
return undefined;
}
const parsed = new URL(rawHubUrl);
const queryToken = parsed.searchParams.get("authToken")?.trim();
parsed.searchParams.delete("authToken");
parsed.hash = "";
const owner = resolveDefaultHubOwnerContext();
const discovery = await readHubDiscovery(owner.discoveryPath);
const managedLocally = Boolean(
discovery?.url && sameHubEndpoint(parsed.toString(), discovery.url),
);
const authToken =
override?.authToken?.trim() ||
queryToken ||
(managedLocally ? discovery?.authToken.trim() : undefined);
if (!authToken) {
throw new Error(
"Hub auth token is required when connecting the dashboard to a custom hub URL.",
);
}
return {
hubUrl: parsed.toString(),
authToken,
managedLocally,
};
}
export async function attachHub(
ctx: HubContext,
override?: HubAttachmentOverride,
): Promise<void> {
const resolvedOverride = await resolveHubAttachmentOverride(override);
const hub = resolvedOverride
? {
url: rememberRecoverableLocalHubUrl(
resolvedOverride.hubUrl,
resolvedOverride.authToken,
),
authToken: resolvedOverride.authToken,
}
: await ensureDetachedHubServer(workspaceRoot, {
preserveDashboard: override?.preserveDashboard ?? true,
});
const nextCline = await ClineCore.create({
clientName: "cline-hub",
backendMode: "hub",
capabilities: {
@@ -102,21 +196,39 @@ export async function attachHub(ctx: HubContext): Promise<void> {
requestToolApprovalFromWebview(ctx, request),
},
hub: {
endpoint: ctx.hubUrl,
authToken: ctx.hubAuthToken,
endpoint: hub.url,
authToken: hub.authToken,
clientType: "cline-hub-chat",
displayName: "Cline Hub Chat",
workspaceRoot,
},
});
ctx.uiClient = new HubUIClient({
address: ctx.hubUrl,
authToken: ctx.hubAuthToken,
const nextUiClient = new HubUIClient({
address: hub.url,
authToken: hub.authToken,
clientType: "cline-hub-server",
displayName: "Cline Hub Server",
});
await ctx.uiClient.connect();
try {
await nextUiClient.connect();
} catch (error) {
try {
nextUiClient.close();
} catch {
// Preserve the connection error while cleanup remains best-effort.
}
await nextCline.dispose().catch(() => undefined);
throw error;
}
await detachHub(ctx);
ctx.hubUrl = hub.url;
ctx.hubAuthToken = hub.authToken;
ctx.hubManagedLocally =
resolvedOverride === undefined || resolvedOverride.managedLocally;
ctx.cline = nextCline;
ctx.uiClient = nextUiClient;
ctx.uiClient.subscribeUI({
onNotify(payload: HubUINotifyPayload) {
@@ -219,6 +331,9 @@ export async function attachHub(ctx: HubContext): Promise<void> {
await syncHubClientsAndSessions(ctx);
await syncHubHealth(ctx);
await refreshDashboardDiscoveryHubUrl(hub.url).catch((error) => {
console.warn("Unable to refresh dashboard discovery:", error);
});
}
export async function detachHub(ctx: HubContext): Promise<void> {
@@ -242,6 +357,7 @@ export async function detachHub(ctx: HubContext): Promise<void> {
// ignore
}
ctx.cline = undefined;
ctx.hubManagedLocally = false;
ctx.clients.clear();
ctx.sessions.clear();
ctx.hubStartedAt = undefined;
@@ -249,20 +365,40 @@ export async function detachHub(ctx: HubContext): Promise<void> {
ctx.initialHubEventEmitted = false;
}
async function isAttachedHubReachable(ctx: HubContext): Promise<boolean> {
if (!ctx.hubUrl) {
return false;
}
const record = await probeHubServer(ctx.hubUrl, {
authToken: ctx.hubAuthToken,
}).catch(() => undefined);
return Boolean(record);
}
export async function restartHub(ctx: HubContext): Promise<void> {
if (!ctx.hubManagedLocally) {
throw new Error(
"Custom hubs must be restarted externally before reconnecting the dashboard.",
);
}
ctx.broadcast({
type: "notification",
title: "Hub restarting",
body: "Shutting down and respawning hub...",
severity: "warn",
});
await detachHub(ctx);
try {
await stopLocalHubServerGracefully();
} catch (error) {
const stopped = await stopLocalHubServerGracefully({
preserveDashboard: true,
}).catch((error) => {
console.warn("stopLocalHubServerGracefully failed:", error);
return false;
});
if (!stopped && (await isAttachedHubReachable(ctx))) {
throw new Error("Unable to stop the current Cline Hub.");
}
await attachHub(ctx);
await detachHub(ctx);
broadcastHubState(ctx);
await attachHub(ctx, { preserveDashboard: true });
broadcastHubState(ctx);
ctx.broadcast({
type: "notification",
@@ -25,6 +25,7 @@ export function hubStatePayload(ctx: HubContext): WebviewHubState {
return {
type: "hub_state",
connected: Boolean(ctx.cline && ctx.uiClient),
restartable: ctx.hubManagedLocally,
hubUrl: ctx.hubUrl,
hubStartedAt: ctx.hubStartedAt,
coreVersion: ctx.coreVersion,
+1
View File
@@ -27,6 +27,7 @@ export class HubContext {
hubUrl = "";
hubAuthToken = "";
hubHealthy = false;
hubManagedLocally = false;
cline: ClineCore | undefined;
uiClient: HubUIClient | undefined;
hubStartedAt: string | undefined;
+2 -1
View File
@@ -12,7 +12,8 @@ export type ProviderSettingsUpdate = Partial<
>;
export interface BrowserConfig {
inviteRequired: boolean;
bridgeUrl: string;
dashboardWebUrl: string;
publicUrl: string;
}
+15 -10
View File
@@ -1,4 +1,7 @@
import { buildInviteUrl, resolveClineHubServerOptions } from "./options";
import {
buildDashboardLaunchUrl,
resolveClineHubServerOptions,
} from "./options";
function expectEqual<T>(actual: T, expected: T, label: string): void {
if (actual !== expected) {
@@ -21,7 +24,9 @@ const defaults = resolveClineHubServerOptions({});
expectEqual(defaults.host, "127.0.0.1", "default host");
expectEqual(defaults.port, 8787, "default port");
expectEqual(defaults.publicUrl, "http://127.0.0.1:8787", "default public URL");
expectEqual(defaults.roomSecret, undefined, "default room secret");
if (!defaults.roomSecret) {
throw new Error("default room secret: expected a generated secret");
}
const lan = resolveClineHubServerOptions({
HOST: "0.0.0.0",
@@ -36,8 +41,8 @@ expectEqual(lan.publicUrl, "https://example.ngrok-free.app", "LAN public URL");
expectEqual(lan.roomSecret, "invite-123", "LAN room secret");
expectEqual(lan.workspaceRoot, "/tmp/workspace", "workspace root");
expectEqual(
buildInviteUrl(lan.publicUrl, lan.roomSecret),
"https://example.ngrok-free.app/?roomSecret=invite-123",
buildDashboardLaunchUrl(lan.publicUrl, lan.publicUrl, lan.roomSecret),
"https://example.ngrok-free.app/#bridgeUrl=https%3A%2F%2Fexample.ngrok-free.app&roomSecret=invite-123",
"invite URL",
);
@@ -53,15 +58,15 @@ expectEqual(
"direct IP public URL gets dashboard port",
);
expectEqual(
buildInviteUrl(tailscale.publicUrl, tailscale.roomSecret),
"http://100.82.5.118:8787/?roomSecret=invite-123",
buildDashboardLaunchUrl(
tailscale.publicUrl,
tailscale.publicUrl,
tailscale.roomSecret,
),
"http://100.82.5.118:8787/#bridgeUrl=http%3A%2F%2F100.82.5.118%3A8787&roomSecret=invite-123",
"invite URL for direct IP public URL",
);
expectThrows(
() => resolveClineHubServerOptions({ HOST: "0.0.0.0" }),
"non-local bind without ROOM_SECRET",
);
expectThrows(
() => resolveClineHubServerOptions({ CLINE_HUB_DASHBOARD_PORT: "70000" }),
"invalid dashboard port",
+18
View File
@@ -214,6 +214,7 @@ export type WebviewHubEvent = {
export type WebviewHubState = {
type: "hub_state";
connected: boolean;
restartable: boolean;
hubUrl?: string;
hubStartedAt?: string;
coreVersion?: string;
@@ -230,6 +231,7 @@ export type WebviewHubState = {
export type WebviewInboundMessage =
| { type: "ready" }
| { type: "restart_hub" }
| { type: "connect_hub"; hubUrl: string; authToken?: string }
| {
type: "desktopCommand";
id: string;
@@ -273,6 +275,22 @@ export type WebviewInboundMessage =
export type WebviewOutboundMessage =
| { type: "status"; text: string }
| { type: "error"; text: string }
| {
type: "hub_connection_result";
ok: true;
hubUrl: string;
}
| {
type: "hub_connection_result";
ok: false;
error: string;
}
| { type: "hub_restart_result"; ok: true }
| {
type: "hub_restart_result";
ok: false;
error: string;
}
| {
type: "desktopCommandResult";
id: string;
+107 -13
View File
@@ -63,6 +63,8 @@ import type {
import { PageFrame, PageHeader } from "./components/views/page-layout";
import type { CustomizationSection } from "./components/views/settings/extensions-view";
import type { SettingsSection } from "./components/views/settings/settings-view";
import { sameHubUrl } from "./lib/hub-url";
import { locationPath, pathWithLocationHash } from "./lib/navigation-url";
import { syncHubTheme } from "./lib/theme";
import { postToHost } from "./vscode";
@@ -136,6 +138,7 @@ const CUSTOMIZATION_VIEW_SECTIONS = {
const EMPTY_HUB_STATE: WebviewHubState = {
type: "hub_state",
connected: false,
restartable: false,
clients: [],
connectors: [],
sessions: [],
@@ -240,14 +243,15 @@ function replaceLegacyCustomizationRoute(): void {
return;
}
const nextPath = routePath(VIEW_PATHS.rules);
if (currentPathWithSearch() !== nextPath) {
window.history.replaceState(null, "", nextPath);
const nextLocation = pathWithLocationHash(nextPath, window.location);
if (currentLocationPath() !== nextLocation) {
window.history.replaceState(null, "", nextLocation);
}
}
function currentPathWithSearch(): string {
function currentLocationPath(): string {
if (typeof window === "undefined") return "/";
return `${window.location.pathname}${window.location.search}`;
return locationPath(window.location);
}
function ViewLoading() {
@@ -469,14 +473,20 @@ function Shell({
}
function HomeView({
actionError,
connectPending,
hubState,
onConnectHub,
onOpenSession,
onRestartHub,
onViewSessions,
restartPending,
recentSessions,
}: {
actionError?: string;
connectPending: boolean;
hubState: WebviewHubState;
onConnectHub: (hubUrl: string) => void;
onOpenSession: (sessionId: string) => void;
onRestartHub: () => void;
onViewSessions: () => void;
@@ -491,6 +501,11 @@ function HomeView({
recentSessions.length > 0 ? recentSessions : activeSessions
).slice(0, 2);
const [restartDialogOpen, setRestartDialogOpen] = useState(false);
const [hubUrlInput, setHubUrlInput] = useState(() => hubState.hubUrl ?? "");
useEffect(() => {
setHubUrlInput(hubState.hubUrl ?? "");
}, [hubState.hubUrl]);
const copyText = useCallback((value?: string) => {
if (!value || typeof navigator === "undefined") return;
@@ -502,6 +517,18 @@ function HomeView({
onRestartHub();
};
const submitHubUrl = () => {
const nextHubUrl = hubUrlInput.trim();
if (!nextHubUrl) return;
if (
hubState.connected &&
hubState.hubUrl &&
sameHubUrl(nextHubUrl, hubState.hubUrl)
)
return;
onConnectHub(nextHubUrl);
};
return (
<PageFrame>
<PageHeader
@@ -534,10 +561,16 @@ function HomeView({
</span>
</button>
<Button
disabled={!hubState.connected || restartPending}
disabled={
!hubState.connected || !hubState.restartable || restartPending
}
onClick={() => setRestartDialogOpen(true)}
size="sm"
title="Restart Cline Hub"
title={
hubState.restartable
? "Restart Cline Hub"
: "Custom hubs must be restarted externally"
}
type="button"
variant="outline"
className="h-7 rounded px-2 text-xs"
@@ -550,6 +583,37 @@ function HomeView({
</>
}
/>
<div className="mb-5 max-w-[52rem]">
<form
className="flex items-center gap-2"
onSubmit={(event) => {
event.preventDefault();
submitHubUrl();
}}
>
<Input
aria-label="Hub URL"
className="h-8"
onChange={(event) => setHubUrlInput(event.target.value)}
placeholder="ws://127.0.0.1:25463/hub?authToken=..."
value={hubUrlInput}
/>
<Button
className="h-8 rounded px-2"
disabled={connectPending}
size="sm"
type="submit"
>
<LinkIcon className="size-3.5" />
<span>{connectPending ? "Connecting" : "Connect"}</span>
</Button>
</form>
{actionError ? (
<p className="mt-2 text-sm text-destructive" role="alert">
{actionError}
</p>
) : null}
</div>
<AlertDialog
open={restartDialogOpen}
onOpenChange={(open) => {
@@ -572,7 +636,9 @@ function HomeView({
Cancel
</AlertDialogCancel>
<AlertDialogAction
disabled={!hubState.connected || restartPending}
disabled={
!hubState.connected || !hubState.restartable || restartPending
}
onClick={confirmRestartHub}
variant="destructive"
>
@@ -1118,6 +1184,8 @@ function App() {
readCurrentSettingsSection(),
);
const [hubState, setHubState] = useState<WebviewHubState>(EMPTY_HUB_STATE);
const [connectPending, setConnectPending] = useState(false);
const [hubActionError, setHubActionError] = useState<string | undefined>();
const [restartPending, setRestartPending] = useState(false);
const [selectedSessionId, setSelectedSessionId] = useState<
string | undefined
@@ -1160,6 +1228,16 @@ function App() {
}
if (message.type === "sessions") {
setRecentSessions(message.sessions);
return;
}
if (message.type === "hub_connection_result") {
setConnectPending(false);
setHubActionError(message.ok ? undefined : message.error);
return;
}
if (message.type === "hub_restart_result") {
setRestartPending(false);
setHubActionError(message.ok ? undefined : message.error);
}
};
window.addEventListener("message", handleMessage);
@@ -1169,9 +1247,16 @@ function App() {
const restartHub = useCallback(() => {
setRestartPending(true);
setHubActionError(undefined);
postToHost({ type: "restart_hub" });
}, []);
const connectHub = useCallback((hubUrl: string) => {
setConnectPending(true);
setHubActionError(undefined);
postToHost({ type: "connect_hub", hubUrl });
}, []);
const navigate = useCallback((nextView: View) => {
if (nextView === "chat") {
setSelectedSessionId(undefined);
@@ -1183,8 +1268,9 @@ function App() {
setSelectedSessionId(undefined);
}
const nextPath = routePath(VIEW_PATHS[nextView]);
if (currentPathWithSearch() !== nextPath) {
window.history.pushState(null, "", nextPath);
const nextLocation = pathWithLocationHash(nextPath, window.location);
if (currentLocationPath() !== nextLocation) {
window.history.pushState(null, "", nextLocation);
}
setView(nextView);
}, []);
@@ -1192,8 +1278,9 @@ function App() {
const openSession = useCallback((sessionId: string) => {
setSelectedSessionId(sessionId);
const nextPath = chatPath(sessionId);
if (currentPathWithSearch() !== nextPath) {
window.history.pushState(null, "", nextPath);
const nextLocation = pathWithLocationHash(nextPath, window.location);
if (currentLocationPath() !== nextLocation) {
window.history.pushState(null, "", nextLocation);
}
setView("chat");
}, []);
@@ -1201,8 +1288,9 @@ function App() {
const updateChatSessionRoute = useCallback((sessionId?: string) => {
setSelectedSessionId(sessionId);
const nextPath = chatPath(sessionId);
if (currentPathWithSearch() !== nextPath) {
window.history.replaceState(null, "", nextPath);
const nextLocation = pathWithLocationHash(nextPath, window.location);
if (currentLocationPath() !== nextLocation) {
window.history.replaceState(null, "", nextLocation);
}
}, []);
@@ -1328,7 +1416,10 @@ function App() {
}
return (
<HomeView
actionError={hubActionError}
connectPending={connectPending}
hubState={hubState}
onConnectHub={connectHub}
onOpenSession={openSession}
onRestartHub={restartHub}
onViewSessions={() => navigate("sessions")}
@@ -1337,7 +1428,10 @@ function App() {
/>
);
}, [
connectPending,
hubActionError,
hubState,
connectHub,
deleteSession,
navigate,
openSession,
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { sameHubUrl } from "./hub-url";
describe("sameHubUrl", () => {
it("ignores fragments that are not sent to the hub", () => {
expect(
sameHubUrl(
"ws://127.0.0.1:25463/hub#dashboard",
"ws://127.0.0.1:25463/hub",
),
).toBe(true);
});
it("treats an auth token change as a new connection target", () => {
expect(
sameHubUrl(
"ws://127.0.0.1:25463/hub?authToken=new-token",
"ws://127.0.0.1:25463/hub",
),
).toBe(false);
expect(
sameHubUrl(
"ws://127.0.0.1:25463/hub?authToken=new-token",
"ws://127.0.0.1:25463/hub?authToken=old-token",
),
).toBe(false);
});
});
@@ -0,0 +1,11 @@
export function sameHubUrl(left: string, right: string): boolean {
try {
const leftUrl = new URL(left);
const rightUrl = new URL(right);
leftUrl.hash = "";
rightUrl.hash = "";
return leftUrl.toString() === rightUrl.toString();
} catch {
return left.trim() === right.trim();
}
}
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { locationPath, pathWithLocationHash } from "./navigation-url";
const inviteHash =
"#bridgeUrl=ws%3A%2F%2F127.0.0.1%3A8787&roomSecret=dashboard-secret";
describe("dashboard navigation URLs", () => {
it("includes the invite fragment in the current location", () => {
expect(
locationPath({
pathname: "/sessions",
search: "?filter=active",
hash: inviteHash,
}),
).toBe(`/sessions?filter=active${inviteHash}`);
});
it.each([
"/",
"/settings",
"/chat?sessionId=session-1",
])("preserves bridge credentials while navigating to %s", (path) => {
expect(pathWithLocationHash(path, { hash: inviteHash })).toBe(
`${path}${inviteHash}`,
);
});
});
@@ -0,0 +1,16 @@
interface BrowserLocationParts {
pathname: string;
search: string;
hash: string;
}
export function locationPath(location: BrowserLocationParts): string {
return `${location.pathname}${location.search}${location.hash}`;
}
export function pathWithLocationHash(
path: string,
location: Pick<BrowserLocationParts, "hash">,
): string {
return `${path}${location.hash}`;
}
@@ -0,0 +1,62 @@
import { afterEach, describe, expect, it, vi } from "vitest";
const browserConnectionKey = "cline-hub-browser-connection";
function stubBrowserWindow(
url: string,
persistedConnection?: Record<string, unknown>,
): Map<string, string> {
const storage = new Map<string, string>();
if (persistedConnection) {
storage.set(browserConnectionKey, JSON.stringify(persistedConnection));
}
vi.stubGlobal("window", {
location: new URL(url),
localStorage: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => storage.set(key, value),
},
});
return storage;
}
describe("browser dashboard connection target", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("uses the current invite secret without persisting rotating credentials", async () => {
const storage = stubBrowserWindow(
"https://cline.bot/dashboard#bridgeUrl=http%3A%2F%2F127.0.0.1%3A8787&roomSecret=fresh-secret",
{
bridgeUrl: "http://127.0.0.1:9999",
roomSecret: "stale-secret",
},
);
const { readBrowserConnectionTarget, writeBrowserConnectionTarget } =
await import("./vscode");
expect(readBrowserConnectionTarget()).toEqual({
bridgeUrl: "http://127.0.0.1:8787",
roomSecret: "fresh-secret",
});
writeBrowserConnectionTarget({ bridgeUrl: "http://127.0.0.1:8787" });
expect(JSON.parse(storage.get(browserConnectionKey) ?? "{}")).toEqual({
bridgeUrl: "http://127.0.0.1:8787",
});
});
it("ignores a room secret left by an older dashboard process", async () => {
stubBrowserWindow("https://cline.bot/dashboard", {
bridgeUrl: "http://127.0.0.1:8787",
roomSecret: "stale-secret",
});
const { readBrowserConnectionTarget } = await import("./vscode");
expect(readBrowserConnectionTarget()).toEqual({
bridgeUrl: "http://127.0.0.1:8787",
roomSecret: undefined,
});
});
});
+90 -14
View File
@@ -19,16 +19,100 @@ let cachedApi: VsCodeApi | undefined;
let browserSocket: WebSocket | undefined;
const pendingMessages: WebviewInboundMessage[] = [];
const stateKey = "cline-hub-webview-state";
const browserConnectionKey = "cline-hub-browser-connection";
type BrowserConnectionTarget = {
bridgeUrl?: string;
roomSecret?: string;
};
type PersistedBrowserConnectionTarget = Pick<
BrowserConnectionTarget,
"bridgeUrl"
>;
function dispatchHostMessage(message: WebviewOutboundMessage): void {
window.dispatchEvent(new MessageEvent("message", { data: message }));
}
function readBrowserRoomSecret(): string | undefined {
const roomSecret = new URLSearchParams(window.location.search)
.get("roomSecret")
?.trim();
return roomSecret || undefined;
function readFragmentParams(): URLSearchParams {
return new URLSearchParams(window.location.hash.replace(/^#/, ""));
}
function readPersistedBrowserConnection(): PersistedBrowserConnectionTarget {
try {
const raw = window.localStorage.getItem(browserConnectionKey);
if (!raw) return {};
const parsed = JSON.parse(raw) as Record<string, unknown>;
const bridgeUrl =
typeof parsed.bridgeUrl === "string" ? parsed.bridgeUrl.trim() : "";
return bridgeUrl ? { bridgeUrl } : {};
} catch {
return {};
}
}
export function readBrowserConnectionTarget(): BrowserConnectionTarget {
if (typeof window === "undefined") return {};
const fragment = readFragmentParams();
const search = new URLSearchParams(window.location.search);
const persisted = readPersistedBrowserConnection();
return {
bridgeUrl:
fragment.get("bridgeUrl")?.trim() ||
fragment.get("bridge")?.trim() ||
search.get("bridgeUrl")?.trim() ||
persisted.bridgeUrl,
// Per-process secrets rotate whenever the dashboard restarts, so a
// persisted value can only be stale (and should not live in storage).
roomSecret:
fragment.get("roomSecret")?.trim() ||
search.get("roomSecret")?.trim() ||
undefined,
};
}
export function writeBrowserConnectionTarget(
target: PersistedBrowserConnectionTarget,
): void {
if (typeof window === "undefined") return;
const bridgeUrl =
target.bridgeUrl?.trim() ||
readPersistedBrowserConnection().bridgeUrl?.trim();
try {
window.localStorage.setItem(
browserConnectionKey,
JSON.stringify(bridgeUrl ? { bridgeUrl } : {}),
);
} catch {
// Browser persistence is best-effort.
}
}
function resolveBrowserSocketUrl(): string {
const target = readBrowserConnectionTarget();
const bridgeUrl = target.bridgeUrl?.trim();
if (bridgeUrl) {
writeBrowserConnectionTarget({ bridgeUrl });
}
const base = bridgeUrl ? new URL(bridgeUrl) : new URL(window.location.href);
const protocol =
base.protocol === "https:"
? "wss:"
: base.protocol === "http:"
? "ws:"
: "";
if (!protocol) {
throw new Error(`Unsupported dashboard bridge protocol: ${base.protocol}`);
}
base.protocol = protocol;
base.pathname = "/browser";
base.search = "";
base.hash = "";
if (target.roomSecret?.trim()) {
base.searchParams.set("roomSecret", target.roomSecret.trim());
}
return base.toString();
}
function createBrowserSocket(): WebSocket {
@@ -40,15 +124,7 @@ function createBrowserSocket(): WebSocket {
return browserSocket;
}
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const params = new URLSearchParams();
const roomSecret = readBrowserRoomSecret();
if (roomSecret) {
params.set("roomSecret", roomSecret);
}
const query = params.toString();
const socketUrl = `${protocol}//${window.location.host}/browser${query ? `?${query}` : ""}`;
browserSocket = new WebSocket(socketUrl);
browserSocket = new WebSocket(resolveBrowserSocketUrl());
browserSocket.addEventListener("open", () => {
for (const message of pendingMessages.splice(0)) {
browserSocket?.send(JSON.stringify(message));
+5
View File
@@ -9,6 +9,11 @@
"../../sdk/packages/core/src/*",
"../../sdk/packages/core/src/*/index.ts"
],
"@cline/llms": ["../../sdk/packages/llms/src/index.ts"],
"@cline/llms/*": [
"../../sdk/packages/llms/src/*",
"../../sdk/packages/llms/src/*/index.ts"
],
"@cline/shared": ["../../sdk/packages/shared/src/index.ts"],
"@cline/shared/*": [
"../../sdk/packages/shared/src/*",
+4 -5
View File
@@ -7,7 +7,7 @@ import {
captureExtensionActivated,
createConfiguredTelemetryService,
createLocalHubScheduleRuntimeHandlers,
ensureHubWebSocketServer,
ensureHubServer,
type ITelemetryService,
Llms,
NodeHubClient,
@@ -17,7 +17,7 @@ import {
type RuntimeCapabilities,
readHubDiscovery,
rememberRecoverableLocalHubUrl,
resolveSharedHubOwnerContext,
resolveDefaultHubOwnerContext,
type ToolPolicy,
} from "@cline/core";
import {
@@ -698,7 +698,7 @@ class CoreChatWebviewController implements vscode.Disposable {
}
private async discoverOrStartHub(): Promise<HubResolution | undefined> {
const owner = resolveSharedHubOwnerContext();
const owner = resolveDefaultHubOwnerContext();
if (this.hubUrl) {
const healthy = await probeHubServer(this.hubUrl, {
@@ -715,8 +715,7 @@ class CoreChatWebviewController implements vscode.Disposable {
const discovered = await this.probeDiscoveredHub(owner.discoveryPath);
if (discovered) return discovered;
await ensureHubWebSocketServer({
owner,
await ensureHubServer({
allowPortFallback: true,
runtimeHandlers: createLocalHubScheduleRuntimeHandlers(),
});
+2 -4
View File
@@ -3,8 +3,7 @@ import {
type ConfiguredTelemetryHandle,
createConfiguredTelemetryHandle,
createLocalHubScheduleRuntimeHandlers,
ensureHubWebSocketServer,
resolveSharedHubOwnerContext,
ensureHubServer,
} from "@cline/core/hub";
import {
createClineTelemetryServiceConfig,
@@ -71,8 +70,7 @@ async function main(): Promise<void> {
const config = parseConfig(process.argv);
const telemetryHandle = createDaemonTelemetry(config.telemetryMetadata);
const ensured = await ensureHubWebSocketServer({
owner: resolveSharedHubOwnerContext(),
const ensured = await ensureHubServer({
runtimeHandlers: createLocalHubScheduleRuntimeHandlers({
telemetry: telemetryHandle?.telemetry,
}),
+20 -6
View File
@@ -142,12 +142,13 @@ event payload and `source` field.
1. Host constructs a `RuntimeHost` through `@cline/core`.
2. `@cline/core` selects `HubRuntimeHost` or `RemoteRuntimeHost` through `packages/core/src/runtime/host.ts`.
3. When no compatible local hub is already discovered, `@cline/core` can spawn a detached hub daemon and reconnect through discovery.
4. Hosts attach and detach from shared sessions without stopping the authority runtime, so another client can keep streaming or resume the same session later.
5. The hub-hosted runtime executes the agent loop using `@cline/agents` and `@cline/llms`.
6. `@cline/core` hub services broker sessions, events, approvals, schedules, and client-owned runtime capabilities such as session-local tool executors.
7. Hub event forwarding preserves structured streaming lifecycle boundaries: text/reasoning deltas, final text/reasoning completion, tool start/finish, and agent done events are translated across the hub transport so host UIs can reliably close loading/streaming state.
8. Hub client adapters exported from `@cline/core/hub` (`NodeHubClient`, `HubSessionClient`, `HubUIClient`, `connectToHub`) translate command/reply and event streams into host-facing APIs.
9. Hub `session.get` records include both canonical root-session usage and explicit aggregate usage from the hub-owned `RuntimeHost`, so attached clients can intentionally render either root-only or root-plus-teammate costs without replaying event streams.
4. CLI-launched hub daemons receive a dashboard launch spec from `apps/cli`. After the daemon starts, it stops any discovered dashboard process, spawns a fresh `cline dashboard serve` process, and records that dashboard in hub dashboard discovery.
5. Hosts attach and detach from shared sessions without stopping the authority runtime, so another client can keep streaming or resume the same session later.
6. The hub-hosted runtime executes the agent loop using `@cline/agents` and `@cline/llms`.
7. `@cline/core` hub services broker sessions, events, approvals, schedules, and client-owned runtime capabilities such as session-local tool executors.
8. Hub event forwarding preserves structured streaming lifecycle boundaries: text/reasoning deltas, final text/reasoning completion, tool start/finish, and agent done events are translated across the hub transport so host UIs can reliably close loading/streaming state.
9. Hub client adapters exported from `@cline/core/hub` (`NodeHubClient`, `HubSessionClient`, `HubUIClient`, `connectToHub`) translate command/reply and event streams into host-facing APIs.
10. Hub `session.get` records include both canonical root-session usage and explicit aggregate usage from the hub-owned `RuntimeHost`, so attached clients can intentionally render either root-only or root-plus-teammate costs without replaying event streams.
Workspace bootstrap is owned by the runtime that executes the session. Hub
clients preserve an omitted `cwd` and `workspaceRoot` across the transport so
@@ -165,6 +166,19 @@ Detached daemon startup retries transient `ETXTBSY` spawn failures before
polling discovery. This covers package-manager updates that replace the CLI
binary immediately before a command restarts the shared hub.
The local hub also owns the browser dashboard lifecycle when a dashboard launch
spec is available. `cline dashboard` is only a controller: it ensures a detached
dashboard exists, opens the discovered invite URL, and exits. The hidden
`cline dashboard serve` process hosts the dashboard and writes
owner-permissioned dashboard discovery; `cline dashboard stop` and
`cline dashboard restart` use that same discovery record for manual lifecycle
control. Dashboard children never inherit the hub-daemon marker environment, so
they start the dashboard command instead of recursively entering the hub daemon.
Dashboard-initiated hub restarts preserve the current dashboard through both
the old daemon's authenticated shutdown and the replacement daemon's startup,
then transactionally swap the dashboard's hub clients after the new connection
is established.
Local hub discovery also carries the authentication contract for the shared
daemon. On startup, the hub server generates a cryptographically random
per-process auth token, stores it in the owner discovery record, and writes that
+1 -11
View File
@@ -3,16 +3,12 @@ import type {
HubReplyEnvelope,
HubTransportFrame,
} from "@cline/shared";
import { resolveClineBuildEnv } from "@cline/shared";
import { createHubServerUrl, readHubDiscovery } from "../discovery";
import {
type HubEndpointOverrides,
resolveHubEndpointOptions,
} from "../discovery/defaults";
import {
resolveProductionHubOwnerContext,
resolveSharedHubOwnerContext,
} from "../discovery/workspace";
import { resolveDefaultHubOwnerContext } from "../discovery/workspace";
export interface HubConnection {
send(envelope: HubCommandEnvelope): Promise<HubReplyEnvelope>;
@@ -72,12 +68,6 @@ function sameHubEndpoint(left: string, right: string): boolean {
return leftUrl.toString() === rightUrl.toString();
}
function resolveDefaultHubOwnerContext() {
return resolveClineBuildEnv() === "production"
? resolveProductionHubOwnerContext()
: resolveSharedHubOwnerContext();
}
async function resolveHubUrlAuthToken(url: URL): Promise<string | undefined> {
const queryToken = url.searchParams.get("authToken")?.trim();
url.searchParams.delete("authToken");
@@ -3,6 +3,7 @@ import {
HubTransportError,
isHubReconnectableTransportError,
NodeHubClient,
requestHubShutdown,
} from "../client";
type SocketListener = (...args: unknown[]) => void;
@@ -144,6 +145,34 @@ class FakeWebSocket {
}
}
describe("requestHubShutdown", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("marks dashboard-initiated shutdowns for preservation", async () => {
const fetchMock = vi.fn(async () => ({ ok: true }));
vi.stubGlobal("fetch", fetchMock);
await expect(
requestHubShutdown("ws://127.0.0.1:25463/hub", "hub-token", {
preserveDashboard: true,
}),
).resolves.toBe(true);
expect(fetchMock).toHaveBeenCalledWith(
new URL("http://127.0.0.1:25463/shutdown"),
{
method: "POST",
headers: {
authorization: "Bearer hub-token",
"x-cline-preserve-dashboard": "1",
},
},
);
});
});
describe("NodeHubClient", () => {
describe("subscription re-registration", () => {
afterEach(() => {
@@ -564,6 +593,10 @@ describe("NodeHubClient", () => {
ownerId: "hub-test",
discoveryPath: "/tmp/hub-discovery-recovery.json",
}),
resolveDefaultHubOwnerContext: () => ({
ownerId: "hub-test",
discoveryPath: "/tmp/hub-discovery.json",
}),
}));
vi.doMock("../discovery", async () => {
const actual =
@@ -719,6 +752,10 @@ describe("NodeHubClient", () => {
ownerId: "hub-test",
discoveryPath: "/tmp/hub-discovery-explicit.json",
}),
resolveDefaultHubOwnerContext: () => ({
ownerId: "hub-test",
discoveryPath: "/tmp/hub-discovery.json",
}),
}));
vi.doMock("../discovery", async () => {
const actual =
@@ -790,6 +827,10 @@ describe("resolveCompatibleLocalHubUrl", () => {
ownerId: "hub-test",
discoveryPath: "/tmp/hub-discovery.json",
}),
resolveDefaultHubOwnerContext: () => ({
ownerId: "hub-test",
discoveryPath: "/tmp/hub-discovery.json",
}),
}));
vi.doMock("../discovery", async () => {
const actual =
@@ -831,6 +872,10 @@ describe("resolveCompatibleLocalHubUrl", () => {
ownerId: "hub-test",
discoveryPath: "/tmp/hub-discovery.json",
}),
resolveDefaultHubOwnerContext: () => ({
ownerId: "hub-test",
discoveryPath: "/tmp/hub-discovery.json",
}),
}));
vi.doMock("../discovery", async () => {
const actual =
@@ -883,6 +928,10 @@ describe("resolveCompatibleLocalHubUrl", () => {
ownerId: "hub-test",
discoveryPath: "/tmp/hub-discovery.json",
}),
resolveDefaultHubOwnerContext: () => ({
ownerId: "hub-test",
discoveryPath: "/tmp/hub-discovery.json",
}),
}));
vi.doMock("../discovery", async () => {
const actual =
@@ -933,6 +982,10 @@ describe("resolveCompatibleLocalHubUrl", () => {
ownerId: "hub-test",
discoveryPath: "/tmp/hub-discovery.json",
}),
resolveDefaultHubOwnerContext: () => ({
ownerId: "hub-test",
discoveryPath: "/tmp/hub-discovery.json",
}),
}));
vi.doMock("../discovery", async () => {
const actual =
@@ -1003,6 +1056,10 @@ describe("resolveCompatibleLocalHubUrl", () => {
ownerId: "hub-test",
discoveryPath: "/tmp/hub-discovery.json",
}),
resolveDefaultHubOwnerContext: () => ({
ownerId: "hub-test",
discoveryPath: "/tmp/hub-discovery.json",
}),
}));
vi.doMock("../discovery", async () => {
const actual =
@@ -1066,6 +1123,16 @@ describe("resolveCompatibleLocalHubUrl", () => {
ownerId: "shared",
discoveryPath: "/tmp/shared-hub-discovery.json",
}),
resolveDefaultHubOwnerContext: () =>
process.env.CLINE_BUILD_ENV === "development"
? {
ownerId: "shared",
discoveryPath: "/tmp/shared-hub-discovery.json",
}
: {
ownerId: "production",
discoveryPath: "/tmp/production-hub-discovery.json",
},
}));
vi.doMock("../discovery", async () => {
const actual =
@@ -1123,6 +1190,10 @@ describe("resolveCompatibleLocalHubUrl", () => {
ownerId: "hub-test",
discoveryPath: "/tmp/hub-discovery.json",
}),
resolveDefaultHubOwnerContext: () => ({
ownerId: "hub-test",
discoveryPath: "/tmp/hub-discovery.json",
}),
}));
vi.doMock("../daemon", () => ({
spawnDetachedHubServerWithRetry: vi.fn(async () => undefined),
+14 -16
View File
@@ -6,7 +6,6 @@ import {
type HubReplyEnvelope,
type HubTransportFrame,
isHubProtocolCompatible,
resolveClineBuildEnv,
resolveHubCommandTimeoutMs,
} from "@cline/shared";
import {
@@ -20,10 +19,7 @@ import {
probeHubServer,
readHubDiscovery,
} from "../discovery";
import {
resolveProductionHubOwnerContext,
resolveSharedHubOwnerContext,
} from "../discovery/workspace";
import { resolveDefaultHubOwnerContext } from "../discovery/workspace";
type PendingReply = {
resolve: (reply: HubReplyEnvelope) => void;
@@ -35,12 +31,6 @@ type SubscriptionEntry = {
sessionId?: string;
};
function resolveDefaultHubOwnerContext(): HubOwnerContext {
return resolveClineBuildEnv() === "production"
? resolveProductionHubOwnerContext()
: resolveSharedHubOwnerContext();
}
type WebSocketLike = {
readyState: number;
send(data: string): void;
@@ -1032,6 +1022,7 @@ export async function ensureCompatibleLocalHubUrl(
export async function requestHubShutdown(
url: string,
authToken?: string,
options: { preserveDashboard?: boolean } = {},
): Promise<boolean> {
const parsed = new URL(url);
const resolvedAuthToken =
@@ -1045,16 +1036,22 @@ export async function requestHubShutdown(
parsed.hash = "";
const response = await fetch(parsed, {
method: "POST",
headers: resolvedAuthToken
? { authorization: `Bearer ${resolvedAuthToken}` }
: undefined,
headers: {
...(resolvedAuthToken
? { authorization: `Bearer ${resolvedAuthToken}` }
: {}),
...(options.preserveDashboard
? { "x-cline-preserve-dashboard": "1" }
: {}),
},
});
return response.ok;
}
export async function stopLocalHubServerGracefully(
owner: HubOwnerContext = resolveDefaultHubOwnerContext(),
options: { owner?: HubOwnerContext; preserveDashboard?: boolean } = {},
): Promise<boolean> {
const owner = options.owner ?? resolveDefaultHubOwnerContext();
const discovery = await readHubDiscovery(owner.discoveryPath);
if (!discovery?.url) {
return false;
@@ -1063,9 +1060,10 @@ export async function stopLocalHubServerGracefully(
const stopped = await requestHubShutdown(
discovery.url,
discovery.authToken,
{ preserveDashboard: options.preserveDashboard },
);
if (stopped) {
return true;
return await waitForHubToRetire(discovery.url);
}
} catch {
// Fall through so callers can apply a stronger fallback.
@@ -0,0 +1,203 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const {
spawn,
clearHubDashboardDiscovery,
isHubDashboardPidAlive,
readHubDashboardDiscovery,
} = vi.hoisted(() => ({
spawn: vi.fn(() => ({ unref: vi.fn() })),
clearHubDashboardDiscovery: vi.fn(async () => undefined),
isHubDashboardPidAlive: vi.fn(() => false),
readHubDashboardDiscovery: vi.fn(),
}));
vi.mock("node:child_process", () => ({
spawn,
}));
vi.mock("../dashboard-discovery", () => ({
CLINE_HUB_DASHBOARD_DISCOVERY_PATH_ENV: "CLINE_HUB_DASHBOARD_DISCOVERY_PATH",
clearHubDashboardDiscovery,
isHubDashboardPidAlive,
readHubDashboardDiscovery,
}));
describe("managed hub dashboard process", () => {
beforeEach(() => {
spawn.mockReset();
spawn.mockImplementation(() => ({ unref: vi.fn() }));
clearHubDashboardDiscovery.mockClear();
isHubDashboardPidAlive.mockReset();
isHubDashboardPidAlive.mockReturnValue(false);
readHubDashboardDiscovery.mockReset();
readHubDashboardDiscovery.mockResolvedValue(undefined);
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it("spawns the configured dashboard command without the hub daemon marker", async () => {
const { restartManagedHubDashboardProcess } = await import(
"./dashboard-process"
);
await restartManagedHubDashboardProcess({
discoveryPath: "/tmp/dashboard.json",
cwd: "/workspace",
env: {
CLINE_HUB_DASHBOARD_LAUNCHER: "bun",
CLINE_HUB_DASHBOARD_ARGS: JSON.stringify([
"cline",
"dashboard",
"serve",
]),
CLINE_RUN_AS_HUB_DAEMON: "1",
},
});
expect(spawn).toHaveBeenCalledWith(
"bun",
["cline", "dashboard", "serve"],
expect.objectContaining({
cwd: "/workspace",
detached: true,
stdio: "ignore",
windowsHide: true,
env: expect.objectContaining({
CLINE_HUB_DASHBOARD_DISCOVERY_PATH: "/tmp/dashboard.json",
CLINE_NO_INTERACTIVE: "1",
}),
}),
);
const call = spawn.mock.calls[0] as unknown as
| [string, string[], { env?: NodeJS.ProcessEnv }]
| undefined;
const env = call?.[2].env;
expect(env?.CLINE_RUN_AS_HUB_DAEMON).toBeUndefined();
});
it("does nothing without a configured dashboard command", async () => {
const { restartManagedHubDashboardProcess } = await import(
"./dashboard-process"
);
await restartManagedHubDashboardProcess({
discoveryPath: "/tmp/dashboard.json",
cwd: "/workspace",
env: {},
});
expect(spawn).not.toHaveBeenCalled();
});
it("preserves the existing dashboard for a hub restart", async () => {
readHubDashboardDiscovery.mockResolvedValue({
pid: 4242,
listenUrl: "http://127.0.0.1:8787/",
publicUrl: "http://127.0.0.1:8787",
inviteUrl: "http://127.0.0.1:8787",
startedAt: "2026-06-22T20:00:00.000Z",
updatedAt: "2026-06-22T20:00:00.000Z",
});
const { restartManagedHubDashboardProcess } = await import(
"./dashboard-process"
);
await restartManagedHubDashboardProcess({
discoveryPath: "/tmp/dashboard.json",
cwd: "/workspace",
env: {
CLINE_HUB_DASHBOARD_LAUNCHER: "bun",
CLINE_HUB_DASHBOARD_ARGS: JSON.stringify([
"cline",
"dashboard",
"serve",
]),
CLINE_HUB_PRESERVE_DASHBOARD: "1",
},
});
expect(readHubDashboardDiscovery).not.toHaveBeenCalled();
expect(spawn).not.toHaveBeenCalled();
});
it("keeps discovery and rejects when the dashboard ignores SIGTERM", async () => {
vi.useFakeTimers();
vi.spyOn(process, "kill").mockImplementation(() => true);
isHubDashboardPidAlive.mockReturnValue(true);
readHubDashboardDiscovery.mockResolvedValue({
pid: 4242,
listenUrl: "http://127.0.0.1:8787/",
publicUrl: "http://127.0.0.1:8787",
inviteUrl: "http://127.0.0.1:8787",
startedAt: "2026-06-22T20:00:00.000Z",
updatedAt: "2026-06-22T20:00:00.000Z",
});
const { stopManagedHubDashboardProcess } = await import(
"./dashboard-process"
);
const stopping = stopManagedHubDashboardProcess("/tmp/dashboard.json");
const expectedRejection = expect(stopping).rejects.toThrow(
"Timed out waiting for dashboard process 4242 to stop.",
);
await vi.advanceTimersByTimeAsync(3_000);
await expectedRejection;
expect(clearHubDashboardDiscovery).not.toHaveBeenCalled();
});
it("clears discovery after stopping the dashboard that owns it", async () => {
vi.spyOn(process, "kill").mockImplementation(() => true);
isHubDashboardPidAlive.mockReturnValue(false);
const dashboard = {
pid: 4242,
listenUrl: "http://127.0.0.1:8787/",
publicUrl: "http://127.0.0.1:8787",
inviteUrl: "http://127.0.0.1:8787",
startedAt: "2026-06-22T20:00:00.000Z",
updatedAt: "2026-06-22T20:00:00.000Z",
};
readHubDashboardDiscovery.mockResolvedValue(dashboard);
const { stopManagedHubDashboardProcess } = await import(
"./dashboard-process"
);
await expect(
stopManagedHubDashboardProcess("/tmp/dashboard.json"),
).resolves.toBe(true);
expect(clearHubDashboardDiscovery).toHaveBeenCalledWith(
"/tmp/dashboard.json",
);
});
it("does not clear discovery replaced while the old dashboard exits", async () => {
vi.spyOn(process, "kill").mockImplementation(() => true);
isHubDashboardPidAlive.mockReturnValue(false);
const oldDashboard = {
pid: 4242,
listenUrl: "http://127.0.0.1:8787/",
publicUrl: "http://127.0.0.1:8787",
inviteUrl: "http://127.0.0.1:8787",
startedAt: "2026-06-22T20:00:00.000Z",
updatedAt: "2026-06-22T20:00:00.000Z",
};
readHubDashboardDiscovery
.mockResolvedValueOnce(oldDashboard)
.mockResolvedValueOnce({ ...oldDashboard, pid: 4343 });
const { stopManagedHubDashboardProcess } = await import(
"./dashboard-process"
);
await expect(
stopManagedHubDashboardProcess("/tmp/dashboard.json"),
).resolves.toBe(true);
expect(process.kill).toHaveBeenCalledWith(4242, "SIGTERM");
expect(clearHubDashboardDiscovery).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,121 @@
import { spawn } from "node:child_process";
import { CLINE_RUN_AS_HUB_DAEMON_ENV } from "@cline/shared";
import {
CLINE_HUB_DASHBOARD_DISCOVERY_PATH_ENV,
clearHubDashboardDiscovery,
type HubDashboardDiscoveryRecord,
isHubDashboardPidAlive,
readHubDashboardDiscovery,
} from "../dashboard-discovery";
const DASHBOARD_LAUNCHER_ENV = "CLINE_HUB_DASHBOARD_LAUNCHER";
const DASHBOARD_ARGS_ENV = "CLINE_HUB_DASHBOARD_ARGS";
const DASHBOARD_STOP_TIMEOUT_MS = 3_000;
const DASHBOARD_STOP_POLL_MS = 100;
/**
* Set on a replacement hub daemon when the dashboard initiating the restart
* must remain alive and attached to the new daemon.
*/
export const CLINE_HUB_PRESERVE_DASHBOARD_ENV = "CLINE_HUB_PRESERVE_DASHBOARD";
async function waitForPidToExit(pid: number): Promise<boolean> {
const deadline = Date.now() + DASHBOARD_STOP_TIMEOUT_MS;
while (Date.now() < deadline) {
if (!isHubDashboardPidAlive(pid)) {
return true;
}
await new Promise((resolve) => setTimeout(resolve, DASHBOARD_STOP_POLL_MS));
}
return !isHubDashboardPidAlive(pid);
}
function parseDashboardArgs(env: NodeJS.ProcessEnv): string[] | undefined {
const raw = env[DASHBOARD_ARGS_ENV]?.trim();
if (!raw) {
return undefined;
}
try {
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) {
return undefined;
}
const args = parsed.filter((value): value is string => {
return typeof value === "string";
});
return args.length > 0 ? args : undefined;
} catch {
return undefined;
}
}
async function clearDashboardDiscoveryIfOwned(
discoveryPath: string,
expected: HubDashboardDiscoveryRecord,
): Promise<void> {
const current = await readHubDashboardDiscovery(discoveryPath);
if (
current?.pid === expected.pid &&
current.startedAt === expected.startedAt
) {
await clearHubDashboardDiscovery(discoveryPath).catch(() => undefined);
}
}
export async function stopManagedHubDashboardProcess(
discoveryPath: string,
): Promise<boolean> {
const discovered = await readHubDashboardDiscovery(discoveryPath);
if (!discovered?.pid) {
await clearHubDashboardDiscovery(discoveryPath).catch(() => undefined);
return false;
}
try {
process.kill(discovered.pid, "SIGTERM");
} catch (error) {
if (isHubDashboardPidAlive(discovered.pid)) {
throw error;
}
await clearDashboardDiscoveryIfOwned(discoveryPath, discovered);
return false;
}
const stopped = await waitForPidToExit(discovered.pid);
if (!stopped) {
throw new Error(
`Timed out waiting for dashboard process ${discovered.pid} to stop.`,
);
}
await clearDashboardDiscoveryIfOwned(discoveryPath, discovered);
return true;
}
export async function restartManagedHubDashboardProcess(options: {
discoveryPath: string;
cwd: string;
env?: NodeJS.ProcessEnv;
}): Promise<void> {
const env = options.env ?? process.env;
if (env[CLINE_HUB_PRESERVE_DASHBOARD_ENV]?.trim() === "1") {
return;
}
const launcher = env[DASHBOARD_LAUNCHER_ENV]?.trim();
const args = parseDashboardArgs(env);
if (!launcher || !args) {
return;
}
await stopManagedHubDashboardProcess(options.discoveryPath);
const childEnv: NodeJS.ProcessEnv = {
...env,
[CLINE_HUB_DASHBOARD_DISCOVERY_PATH_ENV]: options.discoveryPath,
CLINE_NO_INTERACTIVE: "1",
};
delete childEnv[CLINE_RUN_AS_HUB_DAEMON_ENV];
const child = spawn(launcher, args, {
cwd: options.cwd,
detached: true,
stdio: "ignore",
env: childEnv,
windowsHide: true,
});
child.unref();
}
@@ -2,14 +2,18 @@ import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { CLINE_HUB_DASHBOARD_DISCOVERY_PATH_ENV } from "../dashboard-discovery";
const {
mockCreateLocalHubScheduleRuntimeHandlers,
mockInitVcr,
mockResolveDefaultHubOwnerContext,
mockResolveHubEndpointOptions,
mockResolveProductionHubOwnerContext,
mockResolveSharedHubOwnerContext,
mockRestartManagedHubDashboardProcess,
mockStartHubWebSocketServer,
mockStopManagedHubDashboardProcess,
} = vi.hoisted(() => ({
mockCreateLocalHubScheduleRuntimeHandlers: vi.fn(() => ({
startSession: vi.fn(),
@@ -25,6 +29,10 @@ const {
pathname: options.pathname ?? "/hub",
}),
),
mockResolveDefaultHubOwnerContext: vi.fn(() => ({
ownerId: "production",
discoveryPath: "/tmp/cline-data/locks/hub/production.json",
})),
mockResolveProductionHubOwnerContext: vi.fn(() => ({
ownerId: "production",
discoveryPath: "/tmp/cline-data/locks/hub/production.json",
@@ -33,9 +41,14 @@ const {
ownerId: "shared",
discoveryPath: "/tmp/cline-data/locks/hub/owners/shared.json",
})),
mockRestartManagedHubDashboardProcess: vi.fn(async () => undefined),
mockStartHubWebSocketServer: vi.fn(async () => ({
close: vi.fn(async () => undefined),
shutdownRequested: new Promise<{ preserveDashboard: boolean }>(
() => undefined,
),
})),
mockStopManagedHubDashboardProcess: vi.fn(async () => true),
}));
const {
@@ -65,11 +78,18 @@ vi.mock("../daemon/runtime-handlers", () => ({
mockCreateLocalHubScheduleRuntimeHandlers,
}));
vi.mock("../daemon/dashboard-process", () => ({
CLINE_HUB_PRESERVE_DASHBOARD_ENV: "CLINE_HUB_PRESERVE_DASHBOARD",
restartManagedHubDashboardProcess: mockRestartManagedHubDashboardProcess,
stopManagedHubDashboardProcess: mockStopManagedHubDashboardProcess,
}));
vi.mock("../discovery/defaults", () => ({
resolveHubEndpointOptions: mockResolveHubEndpointOptions,
}));
vi.mock("../discovery/workspace", () => ({
resolveDefaultHubOwnerContext: mockResolveDefaultHubOwnerContext,
resolveProductionHubOwnerContext: mockResolveProductionHubOwnerContext,
resolveSharedHubOwnerContext: mockResolveSharedHubOwnerContext,
}));
@@ -84,6 +104,8 @@ vi.mock("./telemetry", () => ({
const originalArgv = [...process.argv];
const originalCwd = process.cwd();
const originalDashboardDiscoveryPath =
process.env[CLINE_HUB_DASHBOARD_DISCOVERY_PATH_ENV];
describe("hub daemon entry", () => {
const tempDirs: string[] = [];
@@ -91,14 +113,23 @@ describe("hub daemon entry", () => {
afterEach(() => {
process.argv = [...originalArgv];
process.chdir(originalCwd);
if (originalDashboardDiscoveryPath === undefined) {
delete process.env[CLINE_HUB_DASHBOARD_DISCOVERY_PATH_ENV];
} else {
process.env[CLINE_HUB_DASHBOARD_DISCOVERY_PATH_ENV] =
originalDashboardDiscoveryPath;
}
vi.restoreAllMocks();
vi.resetModules();
mockCreateLocalHubScheduleRuntimeHandlers.mockClear();
mockInitVcr.mockClear();
mockResolveDefaultHubOwnerContext.mockClear();
mockResolveHubEndpointOptions.mockClear();
mockResolveProductionHubOwnerContext.mockClear();
mockResolveSharedHubOwnerContext.mockClear();
mockRestartManagedHubDashboardProcess.mockClear();
mockStartHubWebSocketServer.mockClear();
mockStopManagedHubDashboardProcess.mockClear();
mockCreateHubDaemonTelemetry.mockClear();
mockDaemonTelemetryDispose.mockClear();
for (const dir of tempDirs.splice(0)) {
@@ -136,6 +167,7 @@ describe("hub daemon entry", () => {
owner: expect.objectContaining({ ownerId: "production" }),
telemetry: mockDaemonTelemetryService,
cronOptions: { workspaceRoot: cwd },
prepareShutdown: expect.any(Function),
}),
);
expect(mockCreateLocalHubScheduleRuntimeHandlers).toHaveBeenCalledOnce();
@@ -144,6 +176,55 @@ describe("hub daemon entry", () => {
});
});
it("uses the dashboard discovery path override for managed lifecycle", async () => {
const cwd = mkdtempSync(join(tmpdir(), "cline-hub-entry-test-"));
tempDirs.push(cwd);
process.argv = ["node", "entry.js", "--cwd", cwd];
process.env[CLINE_HUB_DASHBOARD_DISCOVERY_PATH_ENV] =
"/tmp/overridden-dashboard.json";
vi.spyOn(process, "on").mockImplementation(() => process);
await import("./entry");
await vi.waitFor(() => {
expect(mockRestartManagedHubDashboardProcess).toHaveBeenCalledWith({
discoveryPath: "/tmp/overridden-dashboard.json",
cwd,
});
});
});
it("registers shutdown handlers before restarting the managed dashboard", async () => {
const cwd = mkdtempSync(join(tmpdir(), "cline-hub-entry-test-"));
tempDirs.push(cwd);
process.argv = ["node", "entry.js", "--cwd", cwd];
let finishDashboardRestart: (() => void) | undefined;
mockRestartManagedHubDashboardProcess.mockReturnValueOnce(
new Promise<undefined>((resolve) => {
finishDashboardRestart = () => resolve(undefined);
}),
);
const processOn = vi.spyOn(process, "on").mockImplementation(() => process);
await import("./entry");
await vi.waitFor(() => {
expect(mockRestartManagedHubDashboardProcess).toHaveBeenCalledOnce();
});
for (const signal of [
"SIGINT",
"SIGTERM",
"uncaughtException",
"unhandledRejection",
]) {
expect(processOn).toHaveBeenCalledWith(signal, expect.any(Function));
}
const firstHandlerRegistration = processOn.mock.invocationCallOrder[0];
const dashboardRestart =
mockRestartManagedHubDashboardProcess.mock.invocationCallOrder[0];
expect(firstHandlerRegistration).toBeLessThan(dashboardRestart);
finishDashboardRestart?.();
});
it("disposes telemetry and exits when server startup fails", async () => {
const cwd = mkdtempSync(join(tmpdir(), "cline-hub-entry-test-"));
tempDirs.push(cwd);
@@ -163,4 +244,103 @@ describe("hub daemon entry", () => {
});
expect(mockDaemonTelemetryDispose).toHaveBeenCalled();
});
it("stops the managed dashboard after an HTTP shutdown request", async () => {
const cwd = mkdtempSync(join(tmpdir(), "cline-hub-entry-test-"));
tempDirs.push(cwd);
process.argv = ["node", "entry.js", "--cwd", cwd];
vi.spyOn(process, "on").mockImplementation(() => process);
const exitSpy = vi
.spyOn(process, "exit")
.mockImplementation(() => undefined as never);
const close = vi.fn(async () => undefined);
mockStartHubWebSocketServer.mockResolvedValueOnce({
close,
shutdownRequested: Promise.resolve({ preserveDashboard: false }),
} as never);
await import("./entry");
await vi.waitFor(() => {
expect(exitSpy).toHaveBeenCalledWith(0);
});
expect(mockStopManagedHubDashboardProcess).toHaveBeenCalledWith(
join("/tmp/cline-data/locks/hub", "dashboard.json"),
);
expect(close).toHaveBeenCalledOnce();
expect(mockDaemonTelemetryDispose).toHaveBeenCalled();
});
it("preserves the managed dashboard for a dashboard-initiated hub restart", async () => {
const cwd = mkdtempSync(join(tmpdir(), "cline-hub-entry-test-"));
tempDirs.push(cwd);
process.argv = ["node", "entry.js", "--cwd", cwd];
vi.spyOn(process, "on").mockImplementation(() => process);
const exitSpy = vi
.spyOn(process, "exit")
.mockImplementation(() => undefined as never);
const close = vi.fn(async () => undefined);
mockStartHubWebSocketServer.mockResolvedValueOnce({
close,
shutdownRequested: Promise.resolve({ preserveDashboard: true }),
} as never);
await import("./entry");
await vi.waitFor(() => {
expect(exitSpy).toHaveBeenCalledWith(0);
});
expect(mockStopManagedHubDashboardProcess).not.toHaveBeenCalled();
expect(close).toHaveBeenCalledOnce();
expect(mockDaemonTelemetryDispose).toHaveBeenCalled();
});
it("preserves the dashboard if fatal shutdown starts during a preserved shutdown", async () => {
const cwd = mkdtempSync(join(tmpdir(), "cline-hub-entry-test-"));
tempDirs.push(cwd);
process.argv = ["node", "entry.js", "--cwd", cwd];
const handlers = new Map<string, (...args: unknown[]) => void>();
vi.spyOn(process, "on").mockImplementation((event, listener) => {
handlers.set(String(event), listener as (...args: unknown[]) => void);
return process;
});
vi.spyOn(process.stderr, "write").mockImplementation(() => true);
const exitSpy = vi
.spyOn(process, "exit")
.mockImplementation(() => undefined as never);
let requestShutdown:
| ((request: { preserveDashboard: boolean }) => void)
| undefined;
const shutdownRequested = new Promise<{ preserveDashboard: boolean }>(
(resolve) => {
requestShutdown = resolve;
},
);
const normalClosePending = new Promise<void>(() => undefined);
const close = vi
.fn()
.mockReturnValueOnce(normalClosePending)
.mockResolvedValueOnce(undefined);
mockStartHubWebSocketServer.mockResolvedValueOnce({
close,
shutdownRequested,
} as never);
await import("./entry");
await vi.waitFor(() => {
expect(handlers.has("unhandledRejection")).toBe(true);
});
requestShutdown?.({ preserveDashboard: true });
await vi.waitFor(() => {
expect(close).toHaveBeenCalledOnce();
});
handlers.get("unhandledRejection")?.(new Error("teardown failed"));
await vi.waitFor(() => {
expect(exitSpy).toHaveBeenCalledWith(1);
});
expect(close).toHaveBeenCalledTimes(2);
expect(mockStopManagedHubDashboardProcess).not.toHaveBeenCalled();
});
});
+87 -16
View File
@@ -1,11 +1,17 @@
import { AgentRuntimeAbortError } from "@cline/agents";
import { initVcr, resolveClineBuildEnv } from "@cline/shared";
import { createLocalHubScheduleRuntimeHandlers } from "../daemon/runtime-handlers";
import { resolveHubEndpointOptions } from "../discovery/defaults";
import { initVcr } from "@cline/shared";
import {
resolveProductionHubOwnerContext,
resolveSharedHubOwnerContext,
} from "../discovery/workspace";
CLINE_HUB_PRESERVE_DASHBOARD_ENV,
restartManagedHubDashboardProcess,
stopManagedHubDashboardProcess,
} from "../daemon/dashboard-process";
import { createLocalHubScheduleRuntimeHandlers } from "../daemon/runtime-handlers";
import {
CLINE_HUB_DASHBOARD_DISCOVERY_PATH_ENV,
resolveHubDashboardDiscoveryPath,
} from "../dashboard-discovery";
import { resolveHubEndpointOptions } from "../discovery/defaults";
import { resolveDefaultHubOwnerContext } from "../discovery/workspace";
import { startHubWebSocketServer } from "../server";
import { createHubDaemonTelemetry } from "./telemetry";
@@ -62,23 +68,60 @@ async function main(): Promise<void> {
pathname: options.pathname,
});
const owner = resolveDefaultHubOwnerContext();
const dashboardDiscoveryPath =
process.env[CLINE_HUB_DASHBOARD_DISCOVERY_PATH_ENV]?.trim() ||
resolveHubDashboardDiscoveryPath(owner);
const daemonTelemetry = createHubDaemonTelemetry();
const preserveDashboardDuringStartup =
process.env[CLINE_HUB_PRESERVE_DASHBOARD_ENV]?.trim() === "1";
let dashboardStartupSettled = false;
let preserveDashboardOnShutdown = false;
let dashboardRestartPromise: Promise<void> | undefined;
let resolveDashboardRestartScheduled: (() => void) | undefined;
const dashboardRestartScheduled = new Promise<void>((resolve) => {
resolveDashboardRestartScheduled = resolve;
});
const rememberPreserveDashboard = (preserve: boolean): void => {
if (
preserve ||
(!dashboardStartupSettled && preserveDashboardDuringStartup)
) {
preserveDashboardOnShutdown = true;
}
};
let dashboardShutdownPromise: Promise<void> | undefined;
const prepareDashboardShutdown = (
shutdownOptions: { preserveDashboard?: boolean } = {},
): Promise<void> => {
rememberPreserveDashboard(shutdownOptions.preserveDashboard === true);
dashboardShutdownPromise ??= (async () => {
await dashboardRestartScheduled;
await dashboardRestartPromise;
if (!preserveDashboardOnShutdown) {
await stopManagedHubDashboardProcess(dashboardDiscoveryPath).catch(
() => undefined,
);
}
})();
return dashboardShutdownPromise;
};
let server: Awaited<ReturnType<typeof startHubWebSocketServer>>;
try {
server = await startHubWebSocketServer({
host: endpoint.host,
port: endpoint.port,
pathname: endpoint.pathname,
owner:
resolveClineBuildEnv() === "production"
? resolveProductionHubOwnerContext()
: resolveSharedHubOwnerContext(),
owner,
telemetry: daemonTelemetry.telemetry,
runtimeHandlers: createLocalHubScheduleRuntimeHandlers({
telemetry: daemonTelemetry.telemetry,
}),
cronOptions: { workspaceRoot: options.cwd },
prepareShutdown: prepareDashboardShutdown,
});
} catch (error) {
// Flush before the top-level catch exits so failed daemon starts are
@@ -87,7 +130,18 @@ async function main(): Promise<void> {
throw error;
}
const shutdown = async (): Promise<void> => {
const shutdownRequestPromise = server.shutdownRequested;
let shutdownStarted = false;
const shutdown = async (
shutdownOptions: { preserveDashboard?: boolean } = {},
): Promise<void> => {
rememberPreserveDashboard(shutdownOptions.preserveDashboard === true);
if (shutdownStarted) {
return;
}
shutdownStarted = true;
await prepareDashboardShutdown(shutdownOptions);
await server.close();
await daemonTelemetry.dispose().catch(() => undefined);
process.exit(0);
@@ -99,11 +153,12 @@ async function main(): Promise<void> {
return;
}
fatalShutdownStarted = true;
rememberPreserveDashboard(false);
const message =
error instanceof Error ? error.stack || error.message : String(error);
process.stderr.write(`[hub-daemon] ${label}: ${message}\n`);
void server
.close()
void prepareDashboardShutdown()
.then(() => server.close())
.catch((closeError) => {
const closeMessage =
closeError instanceof Error
@@ -142,9 +197,25 @@ async function main(): Promise<void> {
shutdownFatal("unhandledRejection", reason);
});
await new Promise<void>(() => {
// keep daemon process alive
});
dashboardRestartPromise = restartManagedHubDashboardProcess({
discoveryPath: dashboardDiscoveryPath,
cwd: options.cwd,
})
.catch((error) => {
const message =
error instanceof Error ? error.stack || error.message : String(error);
process.stderr.write(
`[hub-daemon] dashboard restart failed: ${message}\n`,
);
})
.finally(() => {
dashboardStartupSettled = true;
});
resolveDashboardRestartScheduled?.();
await dashboardRestartPromise;
const shutdownRequest = await shutdownRequestPromise;
await shutdown(shutdownRequest);
}
void main().catch((error) => {
@@ -7,6 +7,7 @@ const {
openSync,
rememberRecoverableLocalHubUrl,
verifyHubConnection,
resolveDefaultHubOwnerContext,
resolveProductionHubOwnerContext,
resolveSharedHubOwnerContext,
createHubServerUrl,
@@ -25,6 +26,9 @@ const {
openSync: vi.fn(() => 17),
rememberRecoverableLocalHubUrl: vi.fn((url: string) => url),
verifyHubConnection: vi.fn(),
resolveDefaultHubOwnerContext: vi.fn(() => ({
discoveryPath: "/tmp/hub-discovery.json",
})),
resolveProductionHubOwnerContext: vi.fn(() => ({
discoveryPath: "/tmp/hub-discovery.json",
})),
@@ -77,6 +81,7 @@ vi.mock("../client", () => ({
}));
vi.mock("../discovery/workspace", () => ({
resolveDefaultHubOwnerContext,
resolveProductionHubOwnerContext,
resolveSharedHubOwnerContext,
}));
@@ -209,6 +214,41 @@ describe("ensureDetachedHubServer", () => {
expect(openSync).not.toHaveBeenCalled();
});
it("marks a replacement daemon to preserve the active dashboard", async () => {
const { CLINE_HUB_PRESERVE_DASHBOARD_ENV, spawnDetachedHubServer } =
await import(".");
spawnDetachedHubServer("/workspace", { preserveDashboard: true });
const spawnOptions = (spawn as unknown as { mock: { calls: unknown[][] } })
.mock.calls[0]?.[2] as { env?: NodeJS.ProcessEnv } | undefined;
expect(spawnOptions?.env?.[CLINE_HUB_PRESERVE_DASHBOARD_ENV]).toBe("1");
});
it("clears an inherited preserve marker for ordinary hub starts", async () => {
const previous = process.env.CLINE_HUB_PRESERVE_DASHBOARD;
process.env.CLINE_HUB_PRESERVE_DASHBOARD = "1";
try {
const { CLINE_HUB_PRESERVE_DASHBOARD_ENV, spawnDetachedHubServer } =
await import(".");
spawnDetachedHubServer("/workspace");
const spawnOptions = (
spawn as unknown as { mock: { calls: unknown[][] } }
).mock.calls[0]?.[2] as { env?: NodeJS.ProcessEnv } | undefined;
expect(
spawnOptions?.env?.[CLINE_HUB_PRESERVE_DASHBOARD_ENV],
).toBeUndefined();
} finally {
if (previous === undefined) {
delete process.env.CLINE_HUB_PRESERVE_DASHBOARD;
} else {
process.env.CLINE_HUB_PRESERVE_DASHBOARD = previous;
}
}
});
it("does not prewarm another detached daemon from inside the hub daemon process", async () => {
process.env[CLINE_RUN_AS_HUB_DAEMON_ENV] = "1";
+35 -21
View File
@@ -28,9 +28,16 @@ import {
resolveHubEndpointOptions,
} from "../discovery/defaults";
import {
resolveProductionHubOwnerContext,
resolveDefaultHubOwnerContext,
resolveSharedHubOwnerContext,
} from "../discovery/workspace";
import { CLINE_HUB_PRESERVE_DASHBOARD_ENV } from "./dashboard-process";
export {
CLINE_HUB_PRESERVE_DASHBOARD_ENV,
restartManagedHubDashboardProcess,
stopManagedHubDashboardProcess,
} from "./dashboard-process";
const HUB_STARTUP_TIMEOUT_MS = 8_000;
const HUB_STARTUP_POLL_MS = 200;
@@ -59,12 +66,6 @@ function openDetachedHubLogFile(): { fd: number; logPath: string } | undefined {
}
}
function resolveDefaultHubOwnerContext() {
return resolveClineBuildEnv() === "production"
? resolveProductionHubOwnerContext()
: resolveSharedHubOwnerContext();
}
function isCompatibleHubRecord(record: HubServerProbeRecord): boolean {
return isHubProtocolCompatible(record).compatible;
}
@@ -168,7 +169,7 @@ function resolveDaemonEntryPath(): string {
function resolveLaunchCommand(
workspaceRoot: string,
endpoint: HubEndpointOverrides,
options: DetachedHubServerOptions,
): {
launcher: string;
args: string[];
@@ -176,6 +177,7 @@ function resolveLaunchCommand(
env: NodeJS.ProcessEnv;
} {
const daemonEntryPath = resolveDaemonEntryPath();
const { preserveDashboard, ...endpoint } = options;
const execPath = process.execPath?.trim();
if (!execPath) {
throw new Error("unable to resolve runtime executable for hub daemon");
@@ -190,15 +192,21 @@ function resolveLaunchCommand(
...(useDevelopmentConditions ? ["--conditions=development"] : []),
daemonEntryPath,
];
const env: NodeJS.ProcessEnv = {
...withResolvedClineBuildEnv(process.env),
CLINE_NO_INTERACTIVE: "1",
[CLINE_RUN_AS_HUB_DAEMON_ENV]: "1",
};
if (preserveDashboard) {
env[CLINE_HUB_PRESERVE_DASHBOARD_ENV] = "1";
} else {
delete env[CLINE_HUB_PRESERVE_DASHBOARD_ENV];
}
return {
launcher: execPath,
args: [...entryArgs, "--cwd", workspaceRoot, ...endpointArgs(endpoint)],
cwd: workspaceRoot,
env: {
...withResolvedClineBuildEnv(process.env),
CLINE_NO_INTERACTIVE: "1",
[CLINE_RUN_AS_HUB_DAEMON_ENV]: "1",
},
env,
};
}
@@ -216,12 +224,12 @@ function isTextFileBusyError(error: unknown): boolean {
export function spawnDetachedHubServer(
workspaceRoot: string,
endpoint: HubEndpointOverrides = {},
options: DetachedHubServerOptions = {},
): void {
if (isHubDaemonProcess()) {
return;
}
const command = resolveLaunchCommand(workspaceRoot, endpoint);
const command = resolveLaunchCommand(workspaceRoot, options);
const logFile = openDetachedHubLogFile();
try {
const child = spawn(command.launcher, command.args, {
@@ -243,11 +251,11 @@ export function spawnDetachedHubServer(
export async function spawnDetachedHubServerWithRetry(
workspaceRoot: string,
endpoint: HubEndpointOverrides = {},
options: DetachedHubServerOptions = {},
): Promise<void> {
for (let attempt = 0; ; attempt++) {
try {
spawnDetachedHubServer(workspaceRoot, endpoint);
spawnDetachedHubServer(workspaceRoot, options);
return;
} catch (error) {
const delay = HUB_SPAWN_RETRY_DELAYS_MS[attempt];
@@ -345,11 +353,14 @@ export interface DetachedHubResolution {
authToken: string;
}
export interface DetachedHubServerOptions extends HubEndpointOverrides {
allowPortFallback?: boolean;
preserveDashboard?: boolean;
}
export async function ensureDetachedHubServer(
workspaceRoot: string,
endpointOverrides: HubEndpointOverrides & {
allowPortFallback?: boolean;
} = {},
endpointOverrides: DetachedHubServerOptions = {},
): Promise<DetachedHubResolution> {
const owner = resolveDefaultHubOwnerContext();
const hasExplicitEndpoint =
@@ -440,7 +451,10 @@ export async function ensureDetachedHubServer(
const spawnEndpoint = shouldUseFallbackPort
? { ...endpoint, port: 0 }
: endpoint;
await spawnDetachedHubServerWithRetry(workspaceRoot, spawnEndpoint);
await spawnDetachedHubServerWithRetry(workspaceRoot, {
...spawnEndpoint,
preserveDashboard: endpointOverrides.preserveDashboard,
});
const deadline = Date.now() + HUB_STARTUP_TIMEOUT_MS;
while (Date.now() < deadline) {
const nextDiscovery = await readHubDiscovery(owner.discoveryPath);
@@ -5,6 +5,7 @@ const {
mockEnsureHubWebSocketServer,
mockResolveHubEndpointOptions,
mockResolveClineBuildEnv,
mockResolveDefaultHubOwnerContext,
mockResolveProductionHubOwnerContext,
mockResolveSharedHubOwnerContext,
mockStartHubWebSocketServer,
@@ -22,6 +23,11 @@ const {
}),
),
mockResolveClineBuildEnv: vi.fn(() => "production"),
mockResolveDefaultHubOwnerContext: vi.fn(() =>
mockResolveClineBuildEnv() === "production"
? mockResolveProductionHubOwnerContext()
: mockResolveSharedHubOwnerContext(),
),
mockResolveProductionHubOwnerContext: vi.fn(() => ({
ownerId: "production",
discoveryPath: "/tmp/cline-data/locks/hub/production.json",
@@ -42,6 +48,7 @@ vi.mock("../discovery/defaults", () => ({
}));
vi.mock("../discovery/workspace", () => ({
resolveDefaultHubOwnerContext: mockResolveDefaultHubOwnerContext,
resolveProductionHubOwnerContext: mockResolveProductionHubOwnerContext,
resolveSharedHubOwnerContext: mockResolveSharedHubOwnerContext,
}));
@@ -61,6 +68,7 @@ describe("ensureHubServer", () => {
mockResolveHubEndpointOptions.mockClear();
mockResolveClineBuildEnv.mockClear();
mockResolveClineBuildEnv.mockReturnValue("production");
mockResolveDefaultHubOwnerContext.mockClear();
mockResolveProductionHubOwnerContext.mockClear();
mockResolveSharedHubOwnerContext.mockClear();
mockStartHubWebSocketServer.mockClear();
@@ -1,9 +1,6 @@
import { resolveClineBuildEnv } from "@cline/shared";
import { resolveHubEndpointOptions } from "../discovery/defaults";
import {
resolveProductionHubOwnerContext,
resolveSharedHubOwnerContext,
} from "../discovery/workspace";
import { resolveDefaultHubOwnerContext } from "../discovery/workspace";
import {
type EnsuredHubWebSocketServerResult,
type EnsureHubWebSocketServerOptions,
@@ -22,12 +19,6 @@ export interface StartHubServerOptions
export interface EnsureHubServerOptions
extends Omit<EnsureHubWebSocketServerOptions, "owner"> {}
function resolveDefaultHubOwnerContext() {
return resolveClineBuildEnv() === "production"
? resolveProductionHubOwnerContext()
: resolveSharedHubOwnerContext();
}
function shouldAllowDefaultPortFallback(hasExplicitPort: boolean): boolean {
return resolveClineBuildEnv() !== "production" && !hasExplicitPort;
}
@@ -0,0 +1,46 @@
import { mkdtemp, readdir, readFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import {
isHubDashboardPidAlive,
readHubDashboardDiscovery,
writeHubDashboardDiscovery,
} from "./dashboard-discovery";
describe("hub dashboard discovery", () => {
it("atomically replaces discovery records through a private temp file", async () => {
const dir = await mkdtemp(join(tmpdir(), "cline-dashboard-discovery-"));
const discoveryPath = join(dir, "dashboard.json");
const first = {
pid: 111,
listenUrl: "http://127.0.0.1:8787/",
publicUrl: "http://127.0.0.1:8787",
inviteUrl: "http://127.0.0.1:8787",
startedAt: "2026-06-22T20:00:00.000Z",
updatedAt: "2026-06-22T20:00:00.000Z",
};
const second = {
...first,
pid: 222,
updatedAt: "2026-06-22T20:00:01.000Z",
};
await writeHubDashboardDiscovery(discoveryPath, first);
await writeHubDashboardDiscovery(discoveryPath, second);
await expect(readHubDashboardDiscovery(discoveryPath)).resolves.toEqual(
second,
);
expect(await readFile(discoveryPath, "utf8")).toContain('"pid": 222');
expect(
(await readdir(dir)).filter((entry) => entry.endsWith(".tmp")),
).toEqual([]);
});
it("reports invalid dashboard pids as not alive", () => {
expect(isHubDashboardPidAlive(undefined)).toBe(false);
expect(isHubDashboardPidAlive(0)).toBe(false);
expect(isHubDashboardPidAlive(-1)).toBe(false);
});
});
@@ -0,0 +1,104 @@
import {
chmod,
mkdir,
readFile,
rename,
rm,
writeFile,
} from "node:fs/promises";
import { basename, dirname, join } from "node:path";
import type { HubOwnerContext } from "./discovery";
const DASHBOARD_DISCOVERY_FILENAME = "dashboard.json";
export const CLINE_HUB_DASHBOARD_DISCOVERY_PATH_ENV =
"CLINE_HUB_DASHBOARD_DISCOVERY_PATH";
export interface HubDashboardDiscoveryRecord {
pid: number;
listenUrl: string;
publicUrl: string;
inviteUrl: string;
hubUrl?: string;
startedAt: string;
updatedAt: string;
}
export function resolveHubDashboardDiscoveryPath(
owner: HubOwnerContext,
): string {
return join(dirname(owner.discoveryPath), DASHBOARD_DISCOVERY_FILENAME);
}
export function isHubDashboardPidAlive(pid: number | undefined): boolean {
if (!Number.isInteger(pid) || !pid || pid <= 0) {
return false;
}
try {
process.kill(pid, 0);
return true;
} catch (error) {
return error instanceof Error && "code" in error
? String((error as NodeJS.ErrnoException).code) === "EPERM"
: false;
}
}
export async function readHubDashboardDiscovery(
discoveryPath: string,
): Promise<HubDashboardDiscoveryRecord | undefined> {
try {
const parsed = JSON.parse(
await readFile(discoveryPath, "utf8"),
) as Partial<HubDashboardDiscoveryRecord>;
if (
typeof parsed.pid !== "number" ||
typeof parsed.listenUrl !== "string" ||
typeof parsed.publicUrl !== "string" ||
typeof parsed.inviteUrl !== "string" ||
typeof parsed.startedAt !== "string" ||
typeof parsed.updatedAt !== "string"
) {
return undefined;
}
return {
pid: parsed.pid,
listenUrl: parsed.listenUrl,
publicUrl: parsed.publicUrl,
inviteUrl: parsed.inviteUrl,
hubUrl: typeof parsed.hubUrl === "string" ? parsed.hubUrl : undefined,
startedAt: parsed.startedAt,
updatedAt: parsed.updatedAt,
};
} catch {
return undefined;
}
}
export async function writeHubDashboardDiscovery(
discoveryPath: string,
record: HubDashboardDiscoveryRecord,
): Promise<void> {
const discoveryDir = dirname(discoveryPath);
const tempPath = join(
discoveryDir,
`.${basename(discoveryPath)}.${process.pid}.${Date.now()}.tmp`,
);
await mkdir(discoveryDir, { recursive: true });
try {
await writeFile(tempPath, `${JSON.stringify(record, null, 2)}\n`, {
encoding: "utf8",
mode: 0o600,
});
await chmod(tempPath, 0o600);
await rename(tempPath, discoveryPath);
} catch (error) {
await rm(tempPath, { force: true }).catch(() => undefined);
throw error;
}
}
export async function clearHubDashboardDiscovery(
discoveryPath: string,
): Promise<void> {
await rm(discoveryPath, { force: true }).catch(() => undefined);
}
@@ -0,0 +1,69 @@
import { CLINE_BUILD_ENV_ENV } from "@cline/shared";
import { afterEach, describe, expect, it } from "vitest";
import {
resolveDefaultHubOwnerContext,
resolveProductionHubOwnerContext,
resolveSharedHubOwnerContext,
} from "./workspace";
type EnvSnapshot = {
CLINE_BUILD_ENV: string | undefined;
CLINE_DATA_DIR: string | undefined;
CLINE_HUB_DISCOVERY_PATH: string | undefined;
};
function captureEnv(): EnvSnapshot {
return {
CLINE_BUILD_ENV: process.env[CLINE_BUILD_ENV_ENV],
CLINE_DATA_DIR: process.env.CLINE_DATA_DIR,
CLINE_HUB_DISCOVERY_PATH: process.env.CLINE_HUB_DISCOVERY_PATH,
};
}
function restoreEnv(snapshot: EnvSnapshot): void {
if (snapshot.CLINE_BUILD_ENV === undefined) {
delete process.env[CLINE_BUILD_ENV_ENV];
} else {
process.env[CLINE_BUILD_ENV_ENV] = snapshot.CLINE_BUILD_ENV;
}
if (snapshot.CLINE_DATA_DIR === undefined) {
delete process.env.CLINE_DATA_DIR;
} else {
process.env.CLINE_DATA_DIR = snapshot.CLINE_DATA_DIR;
}
if (snapshot.CLINE_HUB_DISCOVERY_PATH === undefined) {
delete process.env.CLINE_HUB_DISCOVERY_PATH;
} else {
process.env.CLINE_HUB_DISCOVERY_PATH = snapshot.CLINE_HUB_DISCOVERY_PATH;
}
}
describe("resolveDefaultHubOwnerContext", () => {
let snapshot: EnvSnapshot = captureEnv();
afterEach(() => {
restoreEnv(snapshot);
});
it("uses the production singleton owner in production builds", () => {
snapshot = captureEnv();
process.env[CLINE_BUILD_ENV_ENV] = "production";
process.env.CLINE_DATA_DIR = "/tmp/cline-data";
delete process.env.CLINE_HUB_DISCOVERY_PATH;
expect(resolveDefaultHubOwnerContext()).toEqual(
resolveProductionHubOwnerContext(),
);
});
it("uses the shared owner in development builds", () => {
snapshot = captureEnv();
process.env[CLINE_BUILD_ENV_ENV] = "development";
process.env.CLINE_DATA_DIR = "/tmp/cline-data";
delete process.env.CLINE_HUB_DISCOVERY_PATH;
expect(resolveDefaultHubOwnerContext()).toEqual(
resolveSharedHubOwnerContext(),
);
});
});
@@ -1,4 +1,5 @@
import { join } from "node:path";
import { resolveClineBuildEnv } from "@cline/shared";
import { normalizeWorkspacePath } from "../../services/workspace/workspace-manifest";
import {
type HubOwnerContext,
@@ -33,3 +34,9 @@ export function resolveProductionHubOwnerContext(): HubOwnerContext {
join(resolveClineDataDir(), "locks", "hub", "production.json"),
};
}
export function resolveDefaultHubOwnerContext(): HubOwnerContext {
return resolveClineBuildEnv() === "production"
? resolveProductionHubOwnerContext()
: resolveSharedHubOwnerContext();
}
+1
View File
@@ -28,6 +28,7 @@ export * from "./client/ui-client";
export * from "./daemon";
export * from "./daemon/runtime-handlers";
export * from "./daemon/start-shared-server";
export * from "./dashboard-discovery";
export * from "./discovery";
export * from "./discovery/defaults";
export * from "./discovery/workspace";
@@ -11,6 +11,10 @@ import type {
import type { CoreSettingsService } from "../../settings";
import type { HubOwnerContext } from "../discovery";
export interface HubShutdownRequest {
preserveDashboard: boolean;
}
export interface HubWebSocketServerOptions {
host?: string;
port?: number;
@@ -48,6 +52,11 @@ export interface HubWebSocketServerOptions {
* Ignored when `sessionHost` is supplied.
*/
logger?: BasicLogger;
/**
* Host-owned cleanup that must finish before an authenticated shutdown
* request retires the server listener and discovery record.
*/
prepareShutdown?: (request: HubShutdownRequest) => Promise<void> | void;
}
export interface HubWebSocketServer {
@@ -55,6 +64,8 @@ export interface HubWebSocketServer {
port: number;
url: string;
authToken: string;
/** Resolves when the authenticated HTTP shutdown endpoint is invoked. */
shutdownRequested: Promise<HubShutdownRequest>;
close(): Promise<void>;
}
@@ -311,12 +311,22 @@ export async function startHubWebSocketServer(
const sockets = new Set<TrackedNodeWebSocket>();
let heartbeatTimer: ReturnType<typeof setInterval> | undefined;
let closePromise: Promise<void> | undefined;
let shutdownPreparationPromise: Promise<void> | undefined;
let resolveShutdownRequested:
| ((request: { preserveDashboard: boolean }) => void)
| undefined;
const shutdownRequested = new Promise<{ preserveDashboard: boolean }>(
(resolve) => {
resolveShutdownRequested = resolve;
},
);
const closeServer = async (): Promise<void> => {
if (closePromise) {
return closePromise;
}
closePromise = (async () => {
await shutdownPreparationPromise?.catch(() => undefined);
if (heartbeatTimer) {
clearInterval(heartbeatTimer);
heartbeatTimer = undefined;
@@ -419,6 +429,14 @@ export async function startHubWebSocketServer(
res.statusCode = 202;
res.setHeader("content-type", "application/json");
res.end(JSON.stringify({ ok: true }));
const shutdownRequest = {
preserveDashboard: req.headers["x-cline-preserve-dashboard"] === "1",
};
shutdownPreparationPromise ??= Promise.resolve().then(() =>
options.prepareShutdown?.(shutdownRequest),
);
resolveShutdownRequested?.(shutdownRequest);
resolveShutdownRequested = undefined;
queueMicrotask(() => {
void closeServer();
});
@@ -554,6 +572,7 @@ export async function startHubWebSocketServer(
port,
url,
authToken,
shutdownRequested,
close: closeServer,
};
}
+30 -1
View File
@@ -207,12 +207,18 @@ describe("hub server startup", () => {
it("shuts down active server through the shutdown endpoint", async () => {
const owner = createInMemoryHubOwnerContext("hub-server-test-shutdown");
let releaseShutdownPreparation: (() => void) | undefined;
const shutdownPreparation = new Promise<void>((resolve) => {
releaseShutdownPreparation = resolve;
});
const prepareShutdown = vi.fn(() => shutdownPreparation);
const result = await ensureHubWebSocketServer({
owner,
host: "127.0.0.1",
port: 0,
pathname: "/hub",
runtimeHandlers: createLocalHubScheduleRuntimeHandlers(),
prepareShutdown,
});
const server = requireServer(result.server);
servers.add(server);
@@ -225,11 +231,34 @@ describe("hub server startup", () => {
}
expect(discovery.authToken).toMatch(/^[a-f0-9]{64}$/);
const authToken = discovery.authToken;
const shutdownRequested = expect(server.shutdownRequested).resolves.toEqual(
{
preserveDashboard: true,
},
);
const response = await fetch(shutdownUrl, {
method: "POST",
headers: { authorization: `Bearer ${authToken}` },
headers: {
authorization: `Bearer ${authToken}`,
"x-cline-preserve-dashboard": "1",
},
});
expect(response.status).toBe(202);
await shutdownRequested;
try {
await vi.waitFor(() => {
expect(prepareShutdown).toHaveBeenCalledWith({
preserveDashboard: true,
});
});
expect(await readHubDiscovery(owner.discoveryPath)).toBeDefined();
const health = await fetch(
new URL("/health", toHubHealthUrl(result.url)),
);
expect(health.status).toBe(200);
} finally {
releaseShutdownPreparation?.();
}
for (let index = 0; index < 50; index += 1) {
if ((await readHubDiscovery(owner.discoveryPath)) === undefined) {
+1 -1
View File
@@ -3,8 +3,8 @@ export {
ClineOrgIndividualInferenceSubscriptionError,
ClinePassLimitError,
extractClinePassLimitMessage,
getClineOrgIndividualInferenceSubscriptionMessage,
getClineNotSubscribedMessage,
getClineOrgIndividualInferenceSubscriptionMessage,
getClinePassSubscriptionUrl,
isClineNotSubscribedError,
isClineNotSubscribedMessage,
+1 -1
View File
@@ -34,8 +34,8 @@ export {
ClineOrgIndividualInferenceSubscriptionError,
ClinePassLimitError,
extractClinePassLimitMessage,
getClineOrgIndividualInferenceSubscriptionMessage,
getClineNotSubscribedMessage,
getClineOrgIndividualInferenceSubscriptionMessage,
getClinePassSubscriptionUrl,
isClineNotSubscribedError,
isClineNotSubscribedMessage,