mirror of
https://github.com/cline/cline.git
synced 2026-09-13 09:50:12 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ef39b05ca | ||
|
|
f059656ca5 | ||
|
|
acf7fde625 | ||
|
|
c725f3da42 | ||
|
|
874b495abf | ||
|
|
ccc8e5759d | ||
|
|
6138bdfe40 | ||
|
|
8b9590e1ea | ||
|
|
9b4aa6307b | ||
|
|
adb7f014cf | ||
|
|
ebefaa68c4 | ||
|
|
0e240ed329 | ||
|
|
2a4e33105c | ||
|
|
7e8a0df023 | ||
|
|
df7124d561 | ||
|
|
7d119351b1 |
@@ -19,12 +19,8 @@ const {
|
||||
mockReadHubDiscovery,
|
||||
mockProbeHubServer,
|
||||
mockClearHubDiscovery,
|
||||
mockCreateHubServerUrl,
|
||||
mockEnsureDetachedHubServer,
|
||||
mockStopLocalHubServerGracefully,
|
||||
mockStopConnectorsForHubs,
|
||||
mockEnsureFileExists,
|
||||
mockResolveHubEndpointOptions,
|
||||
mockStopAllConnectors,
|
||||
} = vi.hoisted(() => ({
|
||||
mockSpawnSync: vi.fn(),
|
||||
@@ -51,22 +47,8 @@ const {
|
||||
mockReadHubDiscovery: vi.fn(),
|
||||
mockProbeHubServer: vi.fn(),
|
||||
mockClearHubDiscovery: vi.fn(),
|
||||
mockCreateHubServerUrl: vi.fn(
|
||||
(host: string, port: number, pathname: string) =>
|
||||
`ws://${host}:${port}${pathname}`,
|
||||
),
|
||||
mockEnsureDetachedHubServer: vi.fn(),
|
||||
mockStopLocalHubServerGracefully: vi.fn(async () => false),
|
||||
mockStopConnectorsForHubs: vi.fn(async () => ({
|
||||
stoppedProcesses: 0,
|
||||
queuedRestarts: 0,
|
||||
})),
|
||||
mockEnsureFileExists: vi.fn(),
|
||||
mockResolveHubEndpointOptions: vi.fn(() => ({
|
||||
host: "127.0.0.1",
|
||||
port: 25466,
|
||||
pathname: "/hub",
|
||||
})),
|
||||
mockStopAllConnectors: vi.fn(async () => ({
|
||||
stoppedProcesses: 0,
|
||||
stoppedSessions: 0,
|
||||
@@ -83,11 +65,8 @@ vi.mock("@cline/core", () => ({
|
||||
resolveProductionHubOwnerContext: mockResolveProductionHubOwnerContext,
|
||||
resolveSharedHubOwnerContext: mockResolveSharedHubOwnerContext,
|
||||
clearHubDiscovery: mockClearHubDiscovery,
|
||||
createHubServerUrl: mockCreateHubServerUrl,
|
||||
ensureDetachedHubServer: mockEnsureDetachedHubServer,
|
||||
probeHubServer: mockProbeHubServer,
|
||||
readHubDiscovery: mockReadHubDiscovery,
|
||||
resolveHubEndpointOptions: mockResolveHubEndpointOptions,
|
||||
stopLocalHubServerGracefully: mockStopLocalHubServerGracefully,
|
||||
ensureFileExists: mockEnsureFileExists,
|
||||
}));
|
||||
@@ -96,10 +75,6 @@ vi.mock("../connectors/common", () => ({
|
||||
isProcessRunning: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
vi.mock("../connectors/restart", () => ({
|
||||
stopConnectorsForHubs: mockStopConnectorsForHubs,
|
||||
}));
|
||||
|
||||
vi.mock("./connect", () => ({
|
||||
stopAllConnectors: mockStopAllConnectors,
|
||||
}));
|
||||
@@ -122,10 +97,6 @@ describe("runDoctorCommand", () => {
|
||||
),
|
||||
});
|
||||
mockStopLocalHubServerGracefully.mockResolvedValue(false);
|
||||
mockStopConnectorsForHubs.mockResolvedValue({
|
||||
stoppedProcesses: 0,
|
||||
queuedRestarts: 0,
|
||||
});
|
||||
mockStopAllConnectors.mockResolvedValue({
|
||||
stoppedProcesses: 0,
|
||||
stoppedSessions: 0,
|
||||
@@ -302,118 +273,6 @@ describe("runDoctorCommand", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("doctor --fix queues active connectors for restart when killing hubs", async () => {
|
||||
const cwd = "/workspace";
|
||||
mockReadHubDiscovery.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25466/hub",
|
||||
port: 25466,
|
||||
pid: 70001,
|
||||
});
|
||||
mockProbeHubServer.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25466/hub",
|
||||
port: 25466,
|
||||
pid: 70001,
|
||||
});
|
||||
mockStopLocalHubServerGracefully.mockResolvedValue(true);
|
||||
mockStopConnectorsForHubs.mockResolvedValue({
|
||||
stoppedProcesses: 2,
|
||||
queuedRestarts: 1,
|
||||
});
|
||||
mockSpawnSync.mockReturnValue({ status: 1, stdout: "" });
|
||||
|
||||
const output: string[] = [];
|
||||
const code = await runDoctorCommand(
|
||||
{ cwd, json: true, fix: true },
|
||||
{
|
||||
writeln: (text) => {
|
||||
output.push(text ?? "");
|
||||
},
|
||||
writeErr: () => {},
|
||||
},
|
||||
);
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(mockStopConnectorsForHubs).toHaveBeenCalledWith(
|
||||
expect.any(Array),
|
||||
expect.any(Object),
|
||||
{ targetHubUrl: "ws://127.0.0.1:25466/hub" },
|
||||
);
|
||||
// Connectors must be stopped and queued while the hub is still up.
|
||||
expect(mockStopConnectorsForHubs.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mockStopLocalHubServerGracefully.mock.invocationCallOrder[0] ?? Infinity,
|
||||
);
|
||||
expect(JSON.parse(output[0] || "")).toMatchObject({
|
||||
killed: {
|
||||
connectorProcesses: 2,
|
||||
connectorRestartsQueued: 1,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("doctor --fix kills stale random-port hub daemons", async () => {
|
||||
const cwd = "/workspace";
|
||||
mockReadHubDiscovery.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25466/hub",
|
||||
port: 25466,
|
||||
pid: 70001,
|
||||
});
|
||||
mockProbeHubServer.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25466/hub",
|
||||
port: 25466,
|
||||
pid: 70001,
|
||||
});
|
||||
mockStopLocalHubServerGracefully.mockResolvedValue(true);
|
||||
mockSpawnSync.mockImplementation((command: string, args?: string[]) => {
|
||||
if (command === "lsof") {
|
||||
return {
|
||||
status: 0,
|
||||
stdout: "70001\n",
|
||||
};
|
||||
}
|
||||
if (
|
||||
command === "pgrep" &&
|
||||
Array.isArray(args) &&
|
||||
args[0] === "-fal" &&
|
||||
args[1] === "--" &&
|
||||
args[2] === "/sdk/packages/core/src/hub/daemon/entry.ts"
|
||||
) {
|
||||
return {
|
||||
status: 0,
|
||||
stdout: [
|
||||
"70001 /Users/example/.bun/bin/bun /repo/sdk/packages/core/src/hub/daemon/entry.ts --cwd /workspace --host 127.0.0.1 --port 25466 --pathname /hub",
|
||||
"70002 /Users/example/.bun/bin/bun /repo/sdk/packages/core/src/hub/daemon/entry.ts --cwd /workspace --host 127.0.0.1 --port 0 --pathname /hub",
|
||||
].join("\n"),
|
||||
};
|
||||
}
|
||||
return { status: 1, stdout: "" };
|
||||
});
|
||||
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true);
|
||||
|
||||
const output: string[] = [];
|
||||
const code = await runDoctorCommand(
|
||||
{ cwd, json: true, fix: true },
|
||||
{
|
||||
writeln: (text) => {
|
||||
output.push(text ?? "");
|
||||
},
|
||||
writeErr: () => {},
|
||||
},
|
||||
);
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(killSpy).toHaveBeenCalledWith(70002, "SIGKILL");
|
||||
expect(killSpy).not.toHaveBeenCalledWith(70001, "SIGKILL");
|
||||
expect(JSON.parse(output[0] || "")).toMatchObject({
|
||||
before: {
|
||||
staleHubPids: [70002],
|
||||
},
|
||||
killed: {
|
||||
staleHubDaemons: 1,
|
||||
},
|
||||
});
|
||||
killSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("doctor --fix kills stale code sidecar processes", async () => {
|
||||
const cwd = "/workspace";
|
||||
mockReadHubDiscovery.mockResolvedValue(undefined);
|
||||
|
||||
@@ -15,13 +15,11 @@ import { formatUptime, resolveClineBuildEnv } from "@cline/shared";
|
||||
import { Command } from "commander";
|
||||
import open from "open";
|
||||
import { isProcessRunning } from "../connectors/common";
|
||||
import { stopConnectorsForHubs } from "../connectors/restart";
|
||||
import {
|
||||
type ActiveConnectorRecord,
|
||||
listActiveConnectors,
|
||||
} from "../connectors/status";
|
||||
import { getCliBuildInfo } from "../utils/common";
|
||||
import { resolveDefaultCliHubUrl } from "../utils/hub-runtime";
|
||||
import { c, writeln } from "../utils/output";
|
||||
import { stopAllConnectors } from "./connect";
|
||||
|
||||
@@ -463,14 +461,6 @@ export async function runDoctorCommand(
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Stop and queue connectors while the hub (and the connectors themselves)
|
||||
// are still running, matching the ordering in hub stop and update. A
|
||||
// connector that disappears once the hub is gone can no longer be queued.
|
||||
const restartAwareStoppedConnectors = await stopConnectorsForHubs(
|
||||
before.activeConnectors.map((record) => record.hubUrl),
|
||||
{ writeln: () => {}, writeErr: () => {} },
|
||||
{ targetHubUrl: resolveDefaultCliHubUrl() },
|
||||
);
|
||||
const gracefullyStoppedHub = before.hubHealthy
|
||||
? await stopLocalHubServerGracefully(resolveCliHubOwnerContext()).catch(
|
||||
() => false,
|
||||
@@ -520,10 +510,7 @@ export async function runDoctorCommand(
|
||||
staleHubDaemons: killedStaleHubs,
|
||||
cliProcesses: killedCli,
|
||||
sidecarProcesses: killedSidecars,
|
||||
connectorProcesses:
|
||||
stoppedConnectors.stoppedProcesses +
|
||||
restartAwareStoppedConnectors.stoppedProcesses,
|
||||
connectorRestartsQueued: restartAwareStoppedConnectors.queuedRestarts,
|
||||
connectorProcesses: stoppedConnectors.stoppedProcesses,
|
||||
connectorSessions: stoppedConnectors.stoppedSessions,
|
||||
hubStartupLocks: clearedArtifacts.startupLocks,
|
||||
hubDiscovery: clearedArtifacts.discovery,
|
||||
@@ -537,16 +524,8 @@ export async function runDoctorCommand(
|
||||
writeln(`killed cli processes ${c.dim}${killedCli}${c.reset}`);
|
||||
writeln(`killed sidecar processes ${c.dim}${killedSidecars}${c.reset}`);
|
||||
writeln(
|
||||
`stopped connector processes ${c.dim}${stoppedConnectors.stoppedProcesses + restartAwareStoppedConnectors.stoppedProcesses}${c.reset}`,
|
||||
`stopped connector processes ${c.dim}${stoppedConnectors.stoppedProcesses}${c.reset}`,
|
||||
);
|
||||
writeln(
|
||||
`queued connector restarts ${c.dim}${restartAwareStoppedConnectors.queuedRestarts}${c.reset}`,
|
||||
);
|
||||
if (restartAwareStoppedConnectors.queuedRestarts > 0) {
|
||||
writeln(
|
||||
`${c.dim}queued connectors relaunch automatically the next time the hub starts (any cline command, or 'cline hub start')${c.reset}`,
|
||||
);
|
||||
}
|
||||
writeln(
|
||||
`stopped connector sessions ${c.dim}${stoppedConnectors.stoppedSessions}${c.reset}`,
|
||||
);
|
||||
|
||||
@@ -7,9 +7,7 @@ const {
|
||||
mockReadHubDiscovery,
|
||||
mockResolveProductionHubOwnerContext,
|
||||
mockResolveSharedHubOwnerContext,
|
||||
mockRestartQueuedConnectorsForHub,
|
||||
mockStopLocalHubServerGracefully,
|
||||
mockStopConnectorsForHubs,
|
||||
} = vi.hoisted(() => ({
|
||||
mockClearHubDiscovery: vi.fn(),
|
||||
mockEnsureDetachedHubServer: vi.fn(),
|
||||
@@ -23,15 +21,7 @@ const {
|
||||
ownerId: "hub-owner",
|
||||
discoveryPath: "/tmp/cline-data/locks/hub/owners/hub-owner.json",
|
||||
})),
|
||||
mockRestartQueuedConnectorsForHub: vi.fn(async () => ({
|
||||
restarted: 0,
|
||||
remaining: 0,
|
||||
})),
|
||||
mockStopLocalHubServerGracefully: vi.fn(),
|
||||
mockStopConnectorsForHubs: vi.fn(async () => ({
|
||||
stoppedProcesses: 0,
|
||||
queuedRestarts: 0,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@cline/core", () => ({
|
||||
@@ -44,15 +34,6 @@ vi.mock("@cline/core", () => ({
|
||||
stopLocalHubServerGracefully: mockStopLocalHubServerGracefully,
|
||||
}));
|
||||
|
||||
vi.mock("../connectors/restart", () => ({
|
||||
restartQueuedConnectorsForHub: mockRestartQueuedConnectorsForHub,
|
||||
stopConnectorsForHubs: mockStopConnectorsForHubs,
|
||||
}));
|
||||
|
||||
vi.mock("../utils/hub-runtime", () => ({
|
||||
resolveDefaultCliHubUrl: () => "ws://127.0.0.1:25463/hub",
|
||||
}));
|
||||
|
||||
import { createHubCommand } from "./hub";
|
||||
|
||||
const originalBuildEnv = process.env.CLINE_BUILD_ENV;
|
||||
@@ -110,77 +91,6 @@ describe("createHubCommand", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("queues associated connectors on stop", async () => {
|
||||
mockReadHubDiscovery.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
port: 25463,
|
||||
pid: 50174,
|
||||
});
|
||||
mockStopLocalHubServerGracefully.mockResolvedValue(true);
|
||||
mockStopConnectorsForHubs.mockResolvedValue({
|
||||
stoppedProcesses: 2,
|
||||
queuedRestarts: 2,
|
||||
});
|
||||
|
||||
const output: string[] = [];
|
||||
let exitCode = 0;
|
||||
const cmd = createHubCommand(
|
||||
{
|
||||
writeln: (text) => {
|
||||
output.push(text ?? "");
|
||||
},
|
||||
writeErr: () => {},
|
||||
},
|
||||
(code) => {
|
||||
exitCode = code;
|
||||
},
|
||||
);
|
||||
|
||||
await cmd.parseAsync(["stop"], { from: "user" });
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(mockStopConnectorsForHubs).toHaveBeenCalledWith(
|
||||
["ws://127.0.0.1:25463/hub"],
|
||||
expect.any(Object),
|
||||
{ targetHubUrl: "ws://127.0.0.1:25463/hub" },
|
||||
);
|
||||
expect(JSON.parse(output.at(-1) || "")).toMatchObject({
|
||||
stopped: true,
|
||||
stoppedConnectorProcesses: 2,
|
||||
queuedConnectorRestarts: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("restarts queued connectors on start", async () => {
|
||||
mockEnsureDetachedHubServer.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "token",
|
||||
});
|
||||
|
||||
const output: string[] = [];
|
||||
let exitCode = 0;
|
||||
const cmd = createHubCommand(
|
||||
{
|
||||
writeln: (text) => {
|
||||
output.push(text ?? "");
|
||||
},
|
||||
writeErr: () => {},
|
||||
},
|
||||
(code) => {
|
||||
exitCode = code;
|
||||
},
|
||||
);
|
||||
|
||||
await cmd.parseAsync(["start"], { from: "user" });
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(mockRestartQueuedConnectorsForHub).toHaveBeenCalledWith(
|
||||
"ws://127.0.0.1:25463/hub",
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(output.at(-1)).toBe("ws://127.0.0.1:25463/hub");
|
||||
});
|
||||
|
||||
it("passes the selected owner to graceful stop", async () => {
|
||||
process.env.CLINE_BUILD_ENV = "development";
|
||||
mockReadHubDiscovery.mockResolvedValue({
|
||||
@@ -211,6 +121,6 @@ describe("createHubCommand", () => {
|
||||
ownerId: "hub-owner",
|
||||
discoveryPath: "/tmp/cline-data/locks/hub/owners/hub-owner.json",
|
||||
});
|
||||
expect(JSON.parse(output[0] || "")).toMatchObject({ stopped: true });
|
||||
expect(JSON.parse(output[0] || "")).toEqual({ stopped: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,39 +9,18 @@ import {
|
||||
} from "@cline/core";
|
||||
import { formatUptime, resolveClineBuildEnv } from "@cline/shared";
|
||||
import { Command } from "commander";
|
||||
import {
|
||||
restartQueuedConnectorsForHub,
|
||||
stopConnectorsForHubs,
|
||||
} from "../connectors/restart";
|
||||
import { resolveDefaultCliHubUrl } from "../utils/hub-runtime";
|
||||
|
||||
interface HubCommandIo {
|
||||
writeln: (text?: string) => void;
|
||||
writeErr: (text: string) => void;
|
||||
}
|
||||
|
||||
async function stopHubServer(
|
||||
_workspaceRoot: string,
|
||||
io: HubCommandIo,
|
||||
): Promise<{
|
||||
stopped: boolean;
|
||||
stoppedConnectorProcesses: number;
|
||||
queuedConnectorRestarts: number;
|
||||
}> {
|
||||
async function stopHubServer(_workspaceRoot: string): Promise<boolean> {
|
||||
const owner = resolveCliHubOwnerContext();
|
||||
const discovery = await readHubDiscovery(owner.discoveryPath);
|
||||
const stoppedConnectors = discovery?.url
|
||||
? await stopConnectorsForHubs([discovery.url], io, {
|
||||
targetHubUrl: resolveDefaultCliHubUrl(),
|
||||
})
|
||||
: { stoppedProcesses: 0, queuedRestarts: 0 };
|
||||
if (await stopLocalHubServerGracefully(owner)) {
|
||||
await clearHubDiscovery(owner.discoveryPath);
|
||||
return {
|
||||
stopped: true,
|
||||
stoppedConnectorProcesses: stoppedConnectors.stoppedProcesses,
|
||||
queuedConnectorRestarts: stoppedConnectors.queuedRestarts,
|
||||
};
|
||||
return true;
|
||||
}
|
||||
const pid = discovery?.pid;
|
||||
if (pid) {
|
||||
@@ -52,11 +31,7 @@ async function stopHubServer(
|
||||
}
|
||||
}
|
||||
await clearHubDiscovery(owner.discoveryPath);
|
||||
return {
|
||||
stopped: !!pid,
|
||||
stoppedConnectorProcesses: stoppedConnectors.stoppedProcesses,
|
||||
queuedConnectorRestarts: stoppedConnectors.queuedRestarts,
|
||||
};
|
||||
return !!pid;
|
||||
}
|
||||
|
||||
function formatHubUptimeFromStartedAt(
|
||||
@@ -121,7 +96,6 @@ export function createHubCommand(
|
||||
port: opts.port,
|
||||
pathname: opts.pathname,
|
||||
});
|
||||
await restartQueuedConnectorsForHub(url, io);
|
||||
io.writeln(url);
|
||||
}),
|
||||
);
|
||||
@@ -139,7 +113,6 @@ export function createHubCommand(
|
||||
port: opts.port,
|
||||
pathname: opts.pathname,
|
||||
});
|
||||
await restartQueuedConnectorsForHub(url, io);
|
||||
io.writeln(url);
|
||||
}),
|
||||
);
|
||||
@@ -169,7 +142,8 @@ export function createHubCommand(
|
||||
hub.command("stop").action(
|
||||
action(async () => {
|
||||
const opts = hub.opts<{ cwd: string }>();
|
||||
io.writeln(JSON.stringify(await stopHubServer(opts.cwd, io)));
|
||||
const stopped = await stopHubServer(opts.cwd);
|
||||
io.writeln(JSON.stringify({ stopped }));
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -168,6 +168,8 @@ export function buildKanbanSpawnOptions(
|
||||
detached: shouldDetachKanbanProcess(platform),
|
||||
...(platform === "win32" ? { shell: true } : {}),
|
||||
...options,
|
||||
// Prevent a console window from flashing on Windows.
|
||||
windowsHide: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -178,6 +180,8 @@ function buildKanbanInstallSpawnOptions(
|
||||
return {
|
||||
detached: false,
|
||||
stdio: "inherit",
|
||||
// Prevent a console window from flashing on Windows.
|
||||
windowsHide: true,
|
||||
...(platform === "win32" ? { shell: true } : {}),
|
||||
...options,
|
||||
};
|
||||
@@ -203,6 +207,8 @@ export function getInstalledKanbanVersion(): string | null {
|
||||
const result = spawnSync(getKanbanCommand(), ["--version"], {
|
||||
encoding: "utf8",
|
||||
shell: process.platform === "win32",
|
||||
// Prevent a console window from flashing on Windows.
|
||||
windowsHide: true,
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
return null;
|
||||
|
||||
@@ -506,6 +506,8 @@ async function runCommand(
|
||||
cwd: options.cwd,
|
||||
stdio: ["ignore", "ignore", "pipe"],
|
||||
env: process.env,
|
||||
// Prevent a console window from flashing on Windows.
|
||||
windowsHide: true,
|
||||
});
|
||||
let stderr = "";
|
||||
child.stderr.on("data", (chunk) => {
|
||||
|
||||
@@ -51,6 +51,10 @@ export function addRootOptions(cmd: Command): Command {
|
||||
"-s, --system <system-prompt>",
|
||||
"Override the default system prompt",
|
||||
)
|
||||
.option(
|
||||
"--agent <name>",
|
||||
"Use an agent profile from .cline/agents for this session",
|
||||
)
|
||||
.option("-z, --zen", "Start a session that runs in the background hub")
|
||||
.option(
|
||||
"--retries [value]",
|
||||
@@ -225,6 +229,7 @@ export function commanderToParsedArgs(program: Command): ParsedArgs {
|
||||
if (opts.cwd !== undefined) result.cwd = opts.cwd;
|
||||
if (opts.teamName !== undefined) result.teamName = opts.teamName;
|
||||
if (opts.system !== undefined) result.systemPrompt = opts.system;
|
||||
if (opts.agent !== undefined) result.agent = opts.agent;
|
||||
if (opts.model !== undefined) result.model = opts.model;
|
||||
if (opts.provider !== undefined) result.provider = opts.provider;
|
||||
if (opts.key !== undefined) result.key = opts.key;
|
||||
|
||||
@@ -11,11 +11,7 @@ import {
|
||||
} from "@cline/core";
|
||||
import { resolveClineBuildEnv } from "@cline/shared";
|
||||
import { version } from "../../package.json";
|
||||
import { stopConnectorsForHubs } from "../connectors/restart";
|
||||
import {
|
||||
ensureCliHubServer,
|
||||
resolveDefaultCliHubUrl,
|
||||
} from "../utils/hub-runtime";
|
||||
import { ensureCliHubServer } from "../utils/hub-runtime";
|
||||
import { c, writeErr, writeln } from "../utils/output";
|
||||
import {
|
||||
getInstalledKanbanVersion,
|
||||
@@ -317,16 +313,6 @@ async function restartHubServerIfRunning(): Promise<void> {
|
||||
|
||||
const pid = discovery?.pid;
|
||||
writeln(`${c.dim}[hub] restarting server…${c.reset}`);
|
||||
await stopConnectorsForHubs(
|
||||
[health.url],
|
||||
{
|
||||
writeln: () => {},
|
||||
writeErr: () => {},
|
||||
},
|
||||
{
|
||||
targetHubUrl: resolveDefaultCliHubUrl(),
|
||||
},
|
||||
);
|
||||
|
||||
let stopped = await stopLocalHubServerGracefully(owner).catch(() => false);
|
||||
if (!stopped && pid) {
|
||||
@@ -349,10 +335,9 @@ async function restartHubServerIfRunning(): Promise<void> {
|
||||
|
||||
await clearHubDiscovery(owner.discoveryPath).catch(() => undefined);
|
||||
|
||||
// Re-ensure a fresh hub instance is spawned. ensureCliHubServer also
|
||||
// drains the connector restart queue for the new hub.
|
||||
// Re-ensure a fresh hub instance is spawned.
|
||||
try {
|
||||
await ensureCliHubServer(process.cwd());
|
||||
await ensureCliHubServer(process.cwd()); // return value intentionally unused here
|
||||
writeln(`${c.green}✓${c.reset} ${c.dim}[hub] server restarted${c.reset}`);
|
||||
} catch (err) {
|
||||
writeErr(
|
||||
@@ -390,6 +375,9 @@ export function autoUpdateOnStartup(): void {
|
||||
env: autoUpdateCommand.env
|
||||
? { ...process.env, ...autoUpdateCommand.env }
|
||||
: process.env,
|
||||
// Prevent a console window from flashing on Windows; detached
|
||||
// processes otherwise allocate a new visible console.
|
||||
windowsHide: true,
|
||||
});
|
||||
const exitCode = await waitForProcessExit(child);
|
||||
if (exitCode === 0) {
|
||||
|
||||
@@ -3,7 +3,6 @@ import { join } from "node:path";
|
||||
import { resolveClineDataDir } from "@cline/core";
|
||||
import { Command, CommanderError } from "commander";
|
||||
import {
|
||||
CLINE_CONNECTOR_RESTART_SPEC_ENV,
|
||||
isProcessRunning,
|
||||
readJsonFile,
|
||||
removeFile,
|
||||
@@ -14,7 +13,6 @@ import {
|
||||
import type {
|
||||
ConnectCommandDefinition,
|
||||
ConnectIo,
|
||||
ConnectorRestartSpec,
|
||||
ConnectStopResult,
|
||||
} from "./types";
|
||||
|
||||
@@ -115,19 +113,6 @@ export abstract class ConnectorBase<Options, State>
|
||||
}
|
||||
|
||||
protected writeStateFile(statePath: string, state: unknown): void {
|
||||
const restart = this.readRestartSpecFromEnv();
|
||||
if (
|
||||
restart &&
|
||||
state &&
|
||||
typeof state === "object" &&
|
||||
!Array.isArray(state)
|
||||
) {
|
||||
writeJsonFile(statePath, {
|
||||
...(state as Record<string, unknown>),
|
||||
restart,
|
||||
});
|
||||
return;
|
||||
}
|
||||
writeJsonFile(statePath, state);
|
||||
}
|
||||
|
||||
@@ -135,33 +120,6 @@ export abstract class ConnectorBase<Options, State>
|
||||
removeFile(statePath);
|
||||
}
|
||||
|
||||
private readRestartSpecFromEnv(): ConnectorRestartSpec | undefined {
|
||||
const raw = process.env[CLINE_CONNECTOR_RESTART_SPEC_ENV]?.trim();
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Partial<ConnectorRestartSpec>;
|
||||
if (
|
||||
parsed.connector === this.name &&
|
||||
Array.isArray(parsed.args) &&
|
||||
parsed.args.every((arg) => typeof arg === "string")
|
||||
) {
|
||||
return {
|
||||
connector: parsed.connector,
|
||||
args: parsed.args,
|
||||
cwd:
|
||||
typeof parsed.cwd === "string" && parsed.cwd.trim()
|
||||
? parsed.cwd
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed restart metadata from the environment.
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
protected removeStaleState(
|
||||
statePath: string,
|
||||
readState: (path: string) => State | undefined,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import {
|
||||
chmodSync,
|
||||
closeSync,
|
||||
existsSync,
|
||||
openSync,
|
||||
@@ -16,8 +15,6 @@ import { createCliLoggerAdapter } from "../logging/adapter";
|
||||
import { logSpawnedProcess } from "../logging/process";
|
||||
import { resolveCliLaunchSpec } from "../utils/internal-launch";
|
||||
|
||||
export const CLINE_CONNECTOR_RESTART_SPEC_ENV = "CLINE_CONNECTOR_RESTART_SPEC";
|
||||
|
||||
export function parseBooleanFlag(rawArgs: string[], flag: string): boolean {
|
||||
return rawArgs.includes(flag);
|
||||
}
|
||||
@@ -186,8 +183,6 @@ export function spawnDetachedConnector(
|
||||
}
|
||||
const detachedLogFd = tryOpenDetachedLogFd(options?.logPath);
|
||||
try {
|
||||
const connectorName =
|
||||
commandPrefixArgs[0] === "connect" ? commandPrefixArgs[1] : undefined;
|
||||
const child = spawn(command.launcher, command.childArgs, {
|
||||
cwd: process.cwd(),
|
||||
detached: true,
|
||||
@@ -198,16 +193,10 @@ export function spawnDetachedConnector(
|
||||
env: {
|
||||
...withResolvedClineBuildEnv(process.env),
|
||||
[childEnvKey]: "1",
|
||||
...(connectorName
|
||||
? {
|
||||
[CLINE_CONNECTOR_RESTART_SPEC_ENV]: JSON.stringify({
|
||||
connector: connectorName,
|
||||
args: rawArgs,
|
||||
cwd: process.cwd(),
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
// Prevent a console window from appearing on Windows; detached
|
||||
// processes otherwise allocate a new visible console.
|
||||
windowsHide: true,
|
||||
});
|
||||
logSpawnedProcess({
|
||||
component: options?.component ?? "connectors",
|
||||
@@ -273,20 +262,7 @@ export function readJsonFile<T>(path: string, fallback: T): T {
|
||||
|
||||
export function writeJsonFile(path: string, value: unknown): void {
|
||||
ensureParentDir(path);
|
||||
// Connector state and the restart queue persist raw CLI args, which can
|
||||
// include secrets like bot tokens. Recreate the file owner-only, matching
|
||||
// the discipline used for hub discovery records. The mode option only
|
||||
// applies on create, so remove any existing file first.
|
||||
rmSync(path, { force: true });
|
||||
writeFileSync(path, JSON.stringify(value, null, 2), {
|
||||
encoding: "utf8",
|
||||
mode: 0o600,
|
||||
});
|
||||
try {
|
||||
chmodSync(path, 0o600);
|
||||
} catch {
|
||||
// Best-effort tightening on filesystems without chmod support.
|
||||
}
|
||||
writeFileSync(path, JSON.stringify(value, null, 2), "utf8");
|
||||
}
|
||||
|
||||
export function removeFile(path: string): void {
|
||||
|
||||
@@ -1,639 +0,0 @@
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { mockResolveClineDataDir, mockGetConnector } = vi.hoisted(() => ({
|
||||
mockResolveClineDataDir: vi.fn(),
|
||||
mockGetConnector: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@cline/core", () => ({
|
||||
resolveClineDataDir: mockResolveClineDataDir,
|
||||
ensureParentDir: (path: string) => {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./registry", () => ({
|
||||
getConnector: mockGetConnector,
|
||||
}));
|
||||
|
||||
import {
|
||||
restartQueuedConnectorsForHub,
|
||||
stopConnectorsForHubs,
|
||||
} from "./restart";
|
||||
|
||||
describe("connector restart queue", () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
mockGetConnector.mockReset();
|
||||
mockResolveClineDataDir.mockReset();
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("queues connector restart metadata when stopping connectors for a killed hub", async () => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), "connector-restart-test-"));
|
||||
tempDirs.push(dataDir);
|
||||
mockResolveClineDataDir.mockReturnValue(dataDir);
|
||||
const statePath = join(dataDir, "connectors", "telegram", "bot.json");
|
||||
const queuePath = join(dataDir, "connectors", "restart-queue.json");
|
||||
mkdirSync(join(dataDir, "connectors", "telegram"), { recursive: true });
|
||||
writeFileSync(
|
||||
statePath,
|
||||
JSON.stringify({
|
||||
botUsername: "bot",
|
||||
pid: 12345,
|
||||
rpcAddress: "ws://127.0.0.1:57648/hub",
|
||||
startedAt: new Date().toISOString(),
|
||||
restart: {
|
||||
connector: "telegram",
|
||||
args: ["-m", "bot", "--rpc-address", "ws://127.0.0.1:57648/hub"],
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
const alive = new Set([12345]);
|
||||
const killSpy = vi
|
||||
.spyOn(process, "kill")
|
||||
.mockImplementation((pid, signal) => {
|
||||
if (signal === 0 || signal === undefined) {
|
||||
if (alive.has(Number(pid))) {
|
||||
return true;
|
||||
}
|
||||
throw Object.assign(new Error("missing"), { code: "ESRCH" });
|
||||
}
|
||||
alive.delete(Number(pid));
|
||||
return true;
|
||||
});
|
||||
|
||||
const stopped = await stopConnectorsForHubs(
|
||||
["ws://127.0.0.1:57648/hub"],
|
||||
{
|
||||
writeln: () => {},
|
||||
writeErr: () => {},
|
||||
},
|
||||
{
|
||||
targetHubUrl: "ws://127.0.0.1:25466/hub",
|
||||
},
|
||||
);
|
||||
|
||||
expect(stopped).toEqual({ stoppedProcesses: 1, queuedRestarts: 1 });
|
||||
expect(killSpy).toHaveBeenCalledWith(12345, "SIGTERM");
|
||||
expect(existsSync(statePath)).toBe(false);
|
||||
expect(JSON.parse(readFileSync(queuePath, "utf8"))).toMatchObject([
|
||||
{
|
||||
connector: "telegram",
|
||||
hubUrl: "ws://127.0.0.1:57648/hub",
|
||||
targetHubUrl: "ws://127.0.0.1:25466/hub",
|
||||
pid: 12345,
|
||||
},
|
||||
]);
|
||||
|
||||
const run = vi.fn(async () => 0);
|
||||
mockGetConnector.mockResolvedValue({ name: "telegram", run });
|
||||
const restarted = await restartQueuedConnectorsForHub(
|
||||
"ws://127.0.0.1:25466/hub",
|
||||
{ writeln: () => {}, writeErr: () => {} },
|
||||
);
|
||||
|
||||
expect(restarted).toEqual({ restarted: 1, remaining: 0 });
|
||||
expect(run).toHaveBeenCalledWith(
|
||||
["-m", "bot", "--rpc-address", "ws://127.0.0.1:25466/hub"],
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(existsSync(queuePath)).toBe(false);
|
||||
});
|
||||
|
||||
it("rewrites equals-form rpc address args when restarting queued connectors", async () => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), "connector-restart-test-"));
|
||||
tempDirs.push(dataDir);
|
||||
mockResolveClineDataDir.mockReturnValue(dataDir);
|
||||
const queuePath = join(dataDir, "connectors", "restart-queue.json");
|
||||
mkdirSync(join(dataDir, "connectors"), { recursive: true });
|
||||
writeFileSync(
|
||||
queuePath,
|
||||
JSON.stringify([
|
||||
{
|
||||
connector: "telegram",
|
||||
args: ["-m", "bot", "--rpc-address=ws://127.0.0.1:57648/hub"],
|
||||
hubUrl: "ws://127.0.0.1:57648/hub",
|
||||
targetHubUrl: "ws://127.0.0.1:25466/hub",
|
||||
statePath: join(dataDir, "connectors", "telegram", "bot.json"),
|
||||
pid: 12345,
|
||||
stoppedAt: new Date().toISOString(),
|
||||
},
|
||||
]),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const run = vi.fn(async () => 0);
|
||||
mockGetConnector.mockResolvedValue({ name: "telegram", run });
|
||||
|
||||
const restarted = await restartQueuedConnectorsForHub(
|
||||
"ws://127.0.0.1:25466/hub",
|
||||
{ writeln: () => {}, writeErr: () => {} },
|
||||
);
|
||||
|
||||
expect(restarted).toEqual({ restarted: 1, remaining: 0 });
|
||||
expect(run).toHaveBeenCalledWith(
|
||||
["-m", "bot", "--rpc-address=ws://127.0.0.1:25466/hub"],
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("replays queued connector cwd when restarting without an explicit cwd arg", async () => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), "connector-restart-test-"));
|
||||
tempDirs.push(dataDir);
|
||||
mockResolveClineDataDir.mockReturnValue(dataDir);
|
||||
const queuePath = join(dataDir, "connectors", "restart-queue.json");
|
||||
mkdirSync(join(dataDir, "connectors"), { recursive: true });
|
||||
writeFileSync(
|
||||
queuePath,
|
||||
JSON.stringify([
|
||||
{
|
||||
connector: "telegram",
|
||||
args: ["-m", "bot"],
|
||||
cwd: "/workspace/original",
|
||||
hubUrl: "ws://127.0.0.1:57648/hub",
|
||||
targetHubUrl: "ws://127.0.0.1:25466/hub",
|
||||
statePath: join(dataDir, "connectors", "telegram", "bot.json"),
|
||||
pid: 12345,
|
||||
stoppedAt: new Date().toISOString(),
|
||||
},
|
||||
]),
|
||||
"utf8",
|
||||
);
|
||||
const run = vi.fn(async () => 0);
|
||||
mockGetConnector.mockResolvedValue({ name: "telegram", run });
|
||||
|
||||
const restarted = await restartQueuedConnectorsForHub(
|
||||
"ws://127.0.0.1:25466/hub",
|
||||
{ writeln: () => {}, writeErr: () => {} },
|
||||
);
|
||||
|
||||
expect(restarted).toEqual({ restarted: 1, remaining: 0 });
|
||||
expect(run).toHaveBeenCalledWith(
|
||||
[
|
||||
"-m",
|
||||
"bot",
|
||||
"--rpc-address",
|
||||
"ws://127.0.0.1:25466/hub",
|
||||
"--cwd",
|
||||
"/workspace/original",
|
||||
],
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps an explicit cwd arg when restarting queued connectors", async () => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), "connector-restart-test-"));
|
||||
tempDirs.push(dataDir);
|
||||
mockResolveClineDataDir.mockReturnValue(dataDir);
|
||||
const queuePath = join(dataDir, "connectors", "restart-queue.json");
|
||||
mkdirSync(join(dataDir, "connectors"), { recursive: true });
|
||||
writeFileSync(
|
||||
queuePath,
|
||||
JSON.stringify([
|
||||
{
|
||||
connector: "telegram",
|
||||
args: ["-m", "bot", "--cwd", "/workspace/from-args"],
|
||||
cwd: "/workspace/original",
|
||||
hubUrl: "ws://127.0.0.1:57648/hub",
|
||||
targetHubUrl: "ws://127.0.0.1:25466/hub",
|
||||
statePath: join(dataDir, "connectors", "telegram", "bot.json"),
|
||||
pid: 12345,
|
||||
stoppedAt: new Date().toISOString(),
|
||||
},
|
||||
]),
|
||||
"utf8",
|
||||
);
|
||||
const run = vi.fn(async () => 0);
|
||||
mockGetConnector.mockResolvedValue({ name: "telegram", run });
|
||||
|
||||
const restarted = await restartQueuedConnectorsForHub(
|
||||
"ws://127.0.0.1:25466/hub",
|
||||
{ writeln: () => {}, writeErr: () => {} },
|
||||
);
|
||||
|
||||
expect(restarted).toEqual({ restarted: 1, remaining: 0 });
|
||||
expect(run).toHaveBeenCalledWith(
|
||||
[
|
||||
"-m",
|
||||
"bot",
|
||||
"--cwd",
|
||||
"/workspace/from-args",
|
||||
"--rpc-address",
|
||||
"ws://127.0.0.1:25466/hub",
|
||||
],
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("only restarts queue entries targeted at the started hub", async () => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), "connector-restart-test-"));
|
||||
tempDirs.push(dataDir);
|
||||
mockResolveClineDataDir.mockReturnValue(dataDir);
|
||||
const queuePath = join(dataDir, "connectors", "restart-queue.json");
|
||||
mkdirSync(join(dataDir, "connectors"), { recursive: true });
|
||||
writeFileSync(
|
||||
queuePath,
|
||||
JSON.stringify([
|
||||
{
|
||||
connector: "telegram",
|
||||
args: ["-m", "bot-a"],
|
||||
hubUrl: "ws://127.0.0.1:57648/hub",
|
||||
targetHubUrl: "ws://127.0.0.1:25466/hub",
|
||||
statePath: join(dataDir, "connectors", "telegram", "bot-a.json"),
|
||||
pid: 12345,
|
||||
stoppedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
connector: "telegram",
|
||||
args: ["-m", "bot-b"],
|
||||
hubUrl: "ws://127.0.0.1:57649/hub",
|
||||
targetHubUrl: "ws://127.0.0.1:25467/hub",
|
||||
statePath: join(dataDir, "connectors", "telegram", "bot-b.json"),
|
||||
pid: 12346,
|
||||
stoppedAt: new Date().toISOString(),
|
||||
},
|
||||
]),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const run = vi.fn(async () => 0);
|
||||
mockGetConnector.mockResolvedValue({ name: "telegram", run });
|
||||
|
||||
const restarted = await restartQueuedConnectorsForHub(
|
||||
"ws://127.0.0.1:25466/hub",
|
||||
{ writeln: () => {}, writeErr: () => {} },
|
||||
);
|
||||
|
||||
expect(restarted).toEqual({ restarted: 1, remaining: 1 });
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
expect(run).toHaveBeenCalledWith(
|
||||
["-m", "bot-a", "--rpc-address", "ws://127.0.0.1:25466/hub"],
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(JSON.parse(readFileSync(queuePath, "utf8"))).toMatchObject([
|
||||
{
|
||||
args: ["-m", "bot-b"],
|
||||
targetHubUrl: "ws://127.0.0.1:25467/hub",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"writes the restart queue owner-only",
|
||||
async () => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), "connector-restart-test-"));
|
||||
tempDirs.push(dataDir);
|
||||
mockResolveClineDataDir.mockReturnValue(dataDir);
|
||||
const statePath = join(dataDir, "connectors", "telegram", "bot.json");
|
||||
const queuePath = join(dataDir, "connectors", "restart-queue.json");
|
||||
mkdirSync(join(dataDir, "connectors", "telegram"), { recursive: true });
|
||||
writeFileSync(
|
||||
statePath,
|
||||
JSON.stringify({
|
||||
pid: 12345,
|
||||
rpcAddress: "ws://127.0.0.1:57648/hub",
|
||||
restart: {
|
||||
connector: "telegram",
|
||||
args: ["--bot-token", "secret"],
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
const alive = new Set([12345]);
|
||||
vi.spyOn(process, "kill").mockImplementation((pid, signal) => {
|
||||
if (signal === 0 || signal === undefined) {
|
||||
if (alive.has(Number(pid))) {
|
||||
return true;
|
||||
}
|
||||
throw Object.assign(new Error("missing"), { code: "ESRCH" });
|
||||
}
|
||||
alive.delete(Number(pid));
|
||||
return true;
|
||||
});
|
||||
|
||||
await stopConnectorsForHubs(["ws://127.0.0.1:57648/hub"], {
|
||||
writeln: () => {},
|
||||
writeErr: () => {},
|
||||
});
|
||||
|
||||
expect(statSync(queuePath).mode & 0o777).toBe(0o600);
|
||||
},
|
||||
);
|
||||
|
||||
it("claims queue entries before launching connectors", async () => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), "connector-restart-test-"));
|
||||
tempDirs.push(dataDir);
|
||||
mockResolveClineDataDir.mockReturnValue(dataDir);
|
||||
const queuePath = join(dataDir, "connectors", "restart-queue.json");
|
||||
mkdirSync(join(dataDir, "connectors"), { recursive: true });
|
||||
writeFileSync(
|
||||
queuePath,
|
||||
JSON.stringify([
|
||||
{
|
||||
connector: "telegram",
|
||||
args: ["-m", "bot"],
|
||||
hubUrl: "ws://127.0.0.1:57648/hub",
|
||||
targetHubUrl: "ws://127.0.0.1:25466/hub",
|
||||
statePath: join(dataDir, "connectors", "telegram", "bot.json"),
|
||||
pid: 12345,
|
||||
stoppedAt: new Date().toISOString(),
|
||||
},
|
||||
]),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
let queueExistedDuringRun: boolean | undefined;
|
||||
const run = vi.fn(async () => {
|
||||
queueExistedDuringRun = existsSync(queuePath);
|
||||
return 0;
|
||||
});
|
||||
mockGetConnector.mockResolvedValue({ name: "telegram", run });
|
||||
|
||||
const restarted = await restartQueuedConnectorsForHub(
|
||||
"ws://127.0.0.1:25466/hub",
|
||||
{ writeln: () => {}, writeErr: () => {} },
|
||||
);
|
||||
|
||||
expect(restarted).toEqual({ restarted: 1, remaining: 0 });
|
||||
expect(queueExistedDuringRun).toBe(false);
|
||||
});
|
||||
|
||||
it("drops queue entries for unknown connectors", async () => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), "connector-restart-test-"));
|
||||
tempDirs.push(dataDir);
|
||||
mockResolveClineDataDir.mockReturnValue(dataDir);
|
||||
const queuePath = join(dataDir, "connectors", "restart-queue.json");
|
||||
mkdirSync(join(dataDir, "connectors"), { recursive: true });
|
||||
writeFileSync(
|
||||
queuePath,
|
||||
JSON.stringify([
|
||||
{
|
||||
connector: "renamed-connector",
|
||||
args: ["-m", "bot"],
|
||||
hubUrl: "ws://127.0.0.1:57648/hub",
|
||||
targetHubUrl: "ws://127.0.0.1:25466/hub",
|
||||
statePath: join(dataDir, "connectors", "telegram", "bot.json"),
|
||||
pid: 12345,
|
||||
stoppedAt: new Date().toISOString(),
|
||||
},
|
||||
]),
|
||||
"utf8",
|
||||
);
|
||||
mockGetConnector.mockResolvedValue(undefined);
|
||||
const errors: string[] = [];
|
||||
|
||||
const restarted = await restartQueuedConnectorsForHub(
|
||||
"ws://127.0.0.1:25466/hub",
|
||||
{
|
||||
writeln: () => {},
|
||||
writeErr: (text) => {
|
||||
errors.push(text);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(restarted).toEqual({ restarted: 0, remaining: 0 });
|
||||
expect(existsSync(queuePath)).toBe(false);
|
||||
expect(errors).toEqual([
|
||||
'[connect] dropping queued restart for unknown connector "renamed-connector"',
|
||||
]);
|
||||
});
|
||||
|
||||
it("requeues failed restarts with an attempt count and drops them at the cap", async () => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), "connector-restart-test-"));
|
||||
tempDirs.push(dataDir);
|
||||
mockResolveClineDataDir.mockReturnValue(dataDir);
|
||||
const queuePath = join(dataDir, "connectors", "restart-queue.json");
|
||||
mkdirSync(join(dataDir, "connectors"), { recursive: true });
|
||||
const entry = {
|
||||
connector: "telegram",
|
||||
args: ["-m", "bot"],
|
||||
hubUrl: "ws://127.0.0.1:57648/hub",
|
||||
targetHubUrl: "ws://127.0.0.1:25466/hub",
|
||||
statePath: join(dataDir, "connectors", "telegram", "bot.json"),
|
||||
pid: 12345,
|
||||
stoppedAt: new Date().toISOString(),
|
||||
};
|
||||
writeFileSync(queuePath, JSON.stringify([entry]), "utf8");
|
||||
const run = vi.fn(async () => 1);
|
||||
mockGetConnector.mockResolvedValue({ name: "telegram", run });
|
||||
const io = { writeln: () => {}, writeErr: () => {} };
|
||||
|
||||
const first = await restartQueuedConnectorsForHub(
|
||||
"ws://127.0.0.1:25466/hub",
|
||||
io,
|
||||
);
|
||||
expect(first).toEqual({ restarted: 0, remaining: 1 });
|
||||
expect(JSON.parse(readFileSync(queuePath, "utf8"))).toMatchObject([
|
||||
{ connector: "telegram", attempts: 1 },
|
||||
]);
|
||||
|
||||
const second = await restartQueuedConnectorsForHub(
|
||||
"ws://127.0.0.1:25466/hub",
|
||||
io,
|
||||
);
|
||||
expect(second).toEqual({ restarted: 0, remaining: 1 });
|
||||
expect(JSON.parse(readFileSync(queuePath, "utf8"))).toMatchObject([
|
||||
{ connector: "telegram", attempts: 2 },
|
||||
]);
|
||||
|
||||
const errors: string[] = [];
|
||||
const third = await restartQueuedConnectorsForHub(
|
||||
"ws://127.0.0.1:25466/hub",
|
||||
{
|
||||
writeln: () => {},
|
||||
writeErr: (text) => {
|
||||
errors.push(text);
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(third).toEqual({ restarted: 0, remaining: 0 });
|
||||
expect(existsSync(queuePath)).toBe(false);
|
||||
expect(errors).toEqual([
|
||||
'[connect] dropping queued restart for connector "telegram" after 3 failed attempts',
|
||||
]);
|
||||
});
|
||||
|
||||
it("requeues entries when the connector run throws", async () => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), "connector-restart-test-"));
|
||||
tempDirs.push(dataDir);
|
||||
mockResolveClineDataDir.mockReturnValue(dataDir);
|
||||
const queuePath = join(dataDir, "connectors", "restart-queue.json");
|
||||
mkdirSync(join(dataDir, "connectors"), { recursive: true });
|
||||
writeFileSync(
|
||||
queuePath,
|
||||
JSON.stringify([
|
||||
{
|
||||
connector: "telegram",
|
||||
args: ["-m", "bot"],
|
||||
hubUrl: "ws://127.0.0.1:57648/hub",
|
||||
targetHubUrl: "ws://127.0.0.1:25466/hub",
|
||||
statePath: join(dataDir, "connectors", "telegram", "bot.json"),
|
||||
pid: 12345,
|
||||
stoppedAt: new Date().toISOString(),
|
||||
},
|
||||
]),
|
||||
"utf8",
|
||||
);
|
||||
const run = vi.fn(async () => {
|
||||
throw new Error("spawn failed");
|
||||
});
|
||||
mockGetConnector.mockResolvedValue({ name: "telegram", run });
|
||||
|
||||
const restarted = await restartQueuedConnectorsForHub(
|
||||
"ws://127.0.0.1:25466/hub",
|
||||
{ writeln: () => {}, writeErr: () => {} },
|
||||
);
|
||||
|
||||
expect(restarted).toEqual({ restarted: 0, remaining: 1 });
|
||||
expect(JSON.parse(readFileSync(queuePath, "utf8"))).toMatchObject([
|
||||
{ connector: "telegram", attempts: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("requeues entries when connector loading throws", async () => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), "connector-restart-test-"));
|
||||
tempDirs.push(dataDir);
|
||||
mockResolveClineDataDir.mockReturnValue(dataDir);
|
||||
const queuePath = join(dataDir, "connectors", "restart-queue.json");
|
||||
mkdirSync(join(dataDir, "connectors"), { recursive: true });
|
||||
writeFileSync(
|
||||
queuePath,
|
||||
JSON.stringify([
|
||||
{
|
||||
connector: "telegram",
|
||||
args: ["-m", "bot"],
|
||||
hubUrl: "ws://127.0.0.1:57648/hub",
|
||||
targetHubUrl: "ws://127.0.0.1:25466/hub",
|
||||
statePath: join(dataDir, "connectors", "telegram", "bot.json"),
|
||||
pid: 12345,
|
||||
stoppedAt: new Date().toISOString(),
|
||||
},
|
||||
]),
|
||||
"utf8",
|
||||
);
|
||||
mockGetConnector.mockRejectedValue(new Error("import failed"));
|
||||
|
||||
const restarted = await restartQueuedConnectorsForHub(
|
||||
"ws://127.0.0.1:25466/hub",
|
||||
{ writeln: () => {}, writeErr: () => {} },
|
||||
);
|
||||
|
||||
expect(restarted).toEqual({ restarted: 0, remaining: 1 });
|
||||
expect(JSON.parse(readFileSync(queuePath, "utf8"))).toMatchObject([
|
||||
{ connector: "telegram", attempts: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps state and skips restart queue when connector termination fails", async () => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), "connector-restart-test-"));
|
||||
tempDirs.push(dataDir);
|
||||
mockResolveClineDataDir.mockReturnValue(dataDir);
|
||||
const statePath = join(dataDir, "connectors", "telegram", "bot.json");
|
||||
const queuePath = join(dataDir, "connectors", "restart-queue.json");
|
||||
mkdirSync(join(dataDir, "connectors", "telegram"), { recursive: true });
|
||||
writeFileSync(
|
||||
statePath,
|
||||
JSON.stringify({
|
||||
botUsername: "bot",
|
||||
pid: 12345,
|
||||
rpcAddress: "ws://127.0.0.1:57648/hub",
|
||||
startedAt: new Date().toISOString(),
|
||||
restart: {
|
||||
connector: "telegram",
|
||||
args: ["-m", "bot"],
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
vi.spyOn(process, "kill").mockImplementation((pid, signal) => {
|
||||
if (signal === 0 || signal === undefined) {
|
||||
if (Number(pid) === 12345) {
|
||||
return true;
|
||||
}
|
||||
throw Object.assign(new Error("missing"), { code: "ESRCH" });
|
||||
}
|
||||
return true;
|
||||
});
|
||||
const errors: string[] = [];
|
||||
|
||||
const stopped = await stopConnectorsForHubs(["ws://127.0.0.1:57648/hub"], {
|
||||
writeln: () => {},
|
||||
writeErr: (text) => {
|
||||
errors.push(text);
|
||||
},
|
||||
});
|
||||
|
||||
expect(stopped).toEqual({ stoppedProcesses: 0, queuedRestarts: 0 });
|
||||
expect(existsSync(statePath)).toBe(true);
|
||||
expect(existsSync(queuePath)).toBe(false);
|
||||
expect(errors).toEqual([
|
||||
"[connect] failed to stop connector pid=12345 hub=ws://127.0.0.1:57648/hub",
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores non-directory entries while scanning connector state", async () => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), "connector-restart-test-"));
|
||||
tempDirs.push(dataDir);
|
||||
mockResolveClineDataDir.mockReturnValue(dataDir);
|
||||
const statePath = join(dataDir, "connectors", "telegram", "bot.json");
|
||||
const queuePath = join(dataDir, "connectors", "restart-queue.json");
|
||||
mkdirSync(join(dataDir, "connectors", "telegram"), { recursive: true });
|
||||
writeFileSync(queuePath, "[]", "utf8");
|
||||
writeFileSync(
|
||||
statePath,
|
||||
JSON.stringify({
|
||||
botUsername: "bot",
|
||||
pid: 12345,
|
||||
rpcAddress: "ws://127.0.0.1:57648/hub",
|
||||
startedAt: new Date().toISOString(),
|
||||
restart: {
|
||||
connector: "telegram",
|
||||
args: ["-m", "bot"],
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
const alive = new Set([12345]);
|
||||
vi.spyOn(process, "kill").mockImplementation((pid, signal) => {
|
||||
if (signal === 0 || signal === undefined) {
|
||||
if (alive.has(Number(pid))) {
|
||||
return true;
|
||||
}
|
||||
throw Object.assign(new Error("missing"), { code: "ESRCH" });
|
||||
}
|
||||
alive.delete(Number(pid));
|
||||
return true;
|
||||
});
|
||||
|
||||
const stopped = await stopConnectorsForHubs(["ws://127.0.0.1:57648/hub"], {
|
||||
writeln: () => {},
|
||||
writeErr: () => {},
|
||||
});
|
||||
|
||||
expect(stopped).toEqual({ stoppedProcesses: 1, queuedRestarts: 1 });
|
||||
expect(JSON.parse(readFileSync(queuePath, "utf8"))).toMatchObject([
|
||||
{
|
||||
connector: "telegram",
|
||||
targetHubUrl: "ws://127.0.0.1:57648/hub",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,353 +0,0 @@
|
||||
import { existsSync, readdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { resolveClineDataDir } from "@cline/core";
|
||||
import {
|
||||
isProcessRunning,
|
||||
readJsonFile,
|
||||
removeFile,
|
||||
terminateProcess,
|
||||
writeJsonFile,
|
||||
} from "./common";
|
||||
import { getConnector } from "./registry";
|
||||
import type {
|
||||
ConnectCommandDefinition,
|
||||
ConnectIo,
|
||||
ConnectorRestartSpec,
|
||||
} from "./types";
|
||||
|
||||
type ConnectorStateForRestart = {
|
||||
statePath: string;
|
||||
pid: number;
|
||||
hubUrl: string;
|
||||
restart?: ConnectorRestartSpec;
|
||||
};
|
||||
|
||||
type QueuedConnectorRestart = ConnectorRestartSpec & {
|
||||
hubUrl: string;
|
||||
targetHubUrl: string;
|
||||
statePath: string;
|
||||
pid: number;
|
||||
stoppedAt: string;
|
||||
attempts?: number;
|
||||
};
|
||||
|
||||
const MAX_RESTART_ATTEMPTS = 3;
|
||||
|
||||
export type StopConnectorsForHubsOptions = {
|
||||
targetHubUrl?: string;
|
||||
};
|
||||
|
||||
export type StopConnectorsForHubsResult = {
|
||||
stoppedProcesses: number;
|
||||
queuedRestarts: number;
|
||||
};
|
||||
|
||||
export type RestartQueuedConnectorsResult = {
|
||||
restarted: number;
|
||||
remaining: number;
|
||||
};
|
||||
|
||||
function restartQueuePath(): string {
|
||||
return join(resolveClineDataDir(), "connectors", "restart-queue.json");
|
||||
}
|
||||
|
||||
function normalizeHubUrl(url: string): string {
|
||||
try {
|
||||
const parsed = new URL(url.includes("://") ? url : `ws://${url}`);
|
||||
if (parsed.protocol === "http:") {
|
||||
parsed.protocol = "ws:";
|
||||
} else if (parsed.protocol === "https:") {
|
||||
parsed.protocol = "wss:";
|
||||
}
|
||||
parsed.search = "";
|
||||
parsed.hash = "";
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
return url.trim();
|
||||
}
|
||||
}
|
||||
|
||||
function readQueue(): QueuedConnectorRestart[] {
|
||||
const parsed = readJsonFile<unknown>(restartQueuePath(), []);
|
||||
if (!Array.isArray(parsed)) {
|
||||
return [];
|
||||
}
|
||||
return parsed.filter((entry): entry is QueuedConnectorRestart => {
|
||||
if (!entry || typeof entry !== "object") {
|
||||
return false;
|
||||
}
|
||||
const record = entry as Partial<QueuedConnectorRestart>;
|
||||
return (
|
||||
typeof record.connector === "string" &&
|
||||
Array.isArray(record.args) &&
|
||||
record.args.every((arg) => typeof arg === "string") &&
|
||||
typeof record.hubUrl === "string" &&
|
||||
typeof record.targetHubUrl === "string" &&
|
||||
typeof record.statePath === "string" &&
|
||||
typeof record.pid === "number" &&
|
||||
typeof record.stoppedAt === "string" &&
|
||||
(record.cwd === undefined || typeof record.cwd === "string") &&
|
||||
(record.attempts === undefined || typeof record.attempts === "number")
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function writeQueue(queue: QueuedConnectorRestart[]): void {
|
||||
if (queue.length === 0) {
|
||||
removeFile(restartQueuePath());
|
||||
return;
|
||||
}
|
||||
writeJsonFile(restartQueuePath(), queue);
|
||||
}
|
||||
|
||||
function listConnectorStatePaths(): string[] {
|
||||
const root = join(resolveClineDataDir(), "connectors");
|
||||
if (!existsSync(root)) {
|
||||
return [];
|
||||
}
|
||||
const paths: string[] = [];
|
||||
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
const dir = join(root, entry.name);
|
||||
try {
|
||||
for (const name of readdirSync(dir)) {
|
||||
if (name.endsWith(".json") && !name.endsWith(".threads.json")) {
|
||||
paths.push(join(dir, name));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore connector directories that disappear while scanning.
|
||||
}
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
function readConnectorStateForRestart(
|
||||
statePath: string,
|
||||
): ConnectorStateForRestart | undefined {
|
||||
const parsed = readJsonFile<Record<string, unknown> | undefined>(
|
||||
statePath,
|
||||
undefined,
|
||||
);
|
||||
if (!parsed) {
|
||||
return undefined;
|
||||
}
|
||||
const pid = typeof parsed.pid === "number" ? parsed.pid : undefined;
|
||||
const hubUrl =
|
||||
typeof parsed.hubUrl === "string"
|
||||
? parsed.hubUrl
|
||||
: typeof parsed.rpcAddress === "string"
|
||||
? parsed.rpcAddress
|
||||
: undefined;
|
||||
if (!pid || !hubUrl || !isProcessRunning(pid)) {
|
||||
return undefined;
|
||||
}
|
||||
const restart =
|
||||
parsed.restart &&
|
||||
typeof parsed.restart === "object" &&
|
||||
!Array.isArray(parsed.restart)
|
||||
? (parsed.restart as Partial<ConnectorRestartSpec>)
|
||||
: undefined;
|
||||
return {
|
||||
statePath,
|
||||
pid,
|
||||
hubUrl,
|
||||
restart:
|
||||
typeof restart?.connector === "string" &&
|
||||
Array.isArray(restart.args) &&
|
||||
restart.args.every((arg) => typeof arg === "string")
|
||||
? {
|
||||
connector: restart.connector,
|
||||
args: restart.args,
|
||||
cwd:
|
||||
typeof restart.cwd === "string" && restart.cwd.trim()
|
||||
? restart.cwd
|
||||
: undefined,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function queueConnectorRestart(
|
||||
state: ConnectorStateForRestart,
|
||||
targetHubUrl: string,
|
||||
): boolean {
|
||||
if (!state.restart) {
|
||||
return false;
|
||||
}
|
||||
const queue = readQueue().filter(
|
||||
(entry) =>
|
||||
entry.statePath !== state.statePath &&
|
||||
!(
|
||||
entry.connector === state.restart?.connector && entry.pid === state.pid
|
||||
),
|
||||
);
|
||||
queue.push({
|
||||
...state.restart,
|
||||
hubUrl: state.hubUrl,
|
||||
targetHubUrl,
|
||||
statePath: state.statePath,
|
||||
pid: state.pid,
|
||||
stoppedAt: new Date().toISOString(),
|
||||
});
|
||||
writeQueue(queue);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function stopConnectorsForHubs(
|
||||
hubUrls: string[],
|
||||
io: ConnectIo,
|
||||
options: StopConnectorsForHubsOptions = {},
|
||||
): Promise<StopConnectorsForHubsResult> {
|
||||
const targetHubUrls = new Set(hubUrls.map(normalizeHubUrl));
|
||||
if (targetHubUrls.size === 0) {
|
||||
return { stoppedProcesses: 0, queuedRestarts: 0 };
|
||||
}
|
||||
const restartTargetHubUrl = options.targetHubUrl
|
||||
? normalizeHubUrl(options.targetHubUrl)
|
||||
: undefined;
|
||||
let stoppedProcesses = 0;
|
||||
let queuedRestarts = 0;
|
||||
for (const statePath of listConnectorStatePaths()) {
|
||||
const state = readConnectorStateForRestart(statePath);
|
||||
if (!state || !targetHubUrls.has(normalizeHubUrl(state.hubUrl))) {
|
||||
continue;
|
||||
}
|
||||
if (!(await terminateProcess(state.pid))) {
|
||||
io.writeErr(
|
||||
`[connect] failed to stop connector pid=${state.pid} hub=${state.hubUrl}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
stoppedProcesses += 1;
|
||||
io.writeln(
|
||||
`[connect] stopped connector pid=${state.pid} hub=${state.hubUrl}`,
|
||||
);
|
||||
if (
|
||||
queueConnectorRestart(
|
||||
state,
|
||||
restartTargetHubUrl ?? normalizeHubUrl(state.hubUrl),
|
||||
)
|
||||
) {
|
||||
queuedRestarts += 1;
|
||||
}
|
||||
removeFile(statePath);
|
||||
}
|
||||
return { stoppedProcesses, queuedRestarts };
|
||||
}
|
||||
|
||||
function hasCwdArg(args: string[]): boolean {
|
||||
return args.some((arg) => arg === "--cwd" || arg.startsWith("--cwd="));
|
||||
}
|
||||
|
||||
function withHubRpcAddress(args: string[], hubUrl: string): string[] {
|
||||
// Drains run from background contexts (hub start, doctor, update), so an
|
||||
// interactive flag in the saved args would block the drain waiting on a
|
||||
// terminal that does not exist. Relaunch in detached mode unconditionally;
|
||||
// the spec normally never carries these flags since interactive runs do
|
||||
// not persist a restart spec.
|
||||
const next = args.filter((arg) => arg !== "-i" && arg !== "--interactive");
|
||||
for (let index = 0; index < next.length; index += 1) {
|
||||
if (next[index]?.startsWith("--rpc-address=")) {
|
||||
next[index] = `--rpc-address=${hubUrl}`;
|
||||
return next;
|
||||
}
|
||||
if (next[index] === "--rpc-address" && next[index + 1]) {
|
||||
next[index + 1] = hubUrl;
|
||||
return next;
|
||||
}
|
||||
}
|
||||
return [...next, "--rpc-address", hubUrl];
|
||||
}
|
||||
|
||||
function withRestartLaunchArgs(entry: QueuedConnectorRestart, hubUrl: string) {
|
||||
const args = withHubRpcAddress(entry.args, hubUrl);
|
||||
return entry.cwd && !hasCwdArg(args) ? [...args, "--cwd", entry.cwd] : args;
|
||||
}
|
||||
|
||||
function recordFailedRestartAttempt(
|
||||
entry: QueuedConnectorRestart,
|
||||
failed: QueuedConnectorRestart[],
|
||||
io: ConnectIo,
|
||||
): void {
|
||||
const attempts = (entry.attempts ?? 0) + 1;
|
||||
if (attempts >= MAX_RESTART_ATTEMPTS) {
|
||||
io.writeErr(
|
||||
`[connect] dropping queued restart for connector "${entry.connector}" after ${attempts} failed attempts`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
failed.push({ ...entry, attempts });
|
||||
}
|
||||
|
||||
// Restarting a connector re-runs its connect command, which ensures the hub
|
||||
// and drains this queue again. The guard turns those nested drains into
|
||||
// no-ops so a queue entry is never picked up twice within one process.
|
||||
let drainInProgress = false;
|
||||
|
||||
export async function restartQueuedConnectorsForHub(
|
||||
hubUrl: string,
|
||||
io: ConnectIo,
|
||||
): Promise<RestartQueuedConnectorsResult> {
|
||||
if (drainInProgress) {
|
||||
return { restarted: 0, remaining: readQueue().length };
|
||||
}
|
||||
const queue = readQueue();
|
||||
if (queue.length === 0) {
|
||||
return { restarted: 0, remaining: 0 };
|
||||
}
|
||||
const targetHubUrl = normalizeHubUrl(hubUrl);
|
||||
const matched: QueuedConnectorRestart[] = [];
|
||||
const remaining: QueuedConnectorRestart[] = [];
|
||||
for (const entry of queue) {
|
||||
if (normalizeHubUrl(entry.targetHubUrl) === targetHubUrl) {
|
||||
matched.push(entry);
|
||||
} else {
|
||||
remaining.push(entry);
|
||||
}
|
||||
}
|
||||
if (matched.length === 0) {
|
||||
return { restarted: 0, remaining: remaining.length };
|
||||
}
|
||||
// Claim matched entries before running them so a crash mid-restart (or a
|
||||
// concurrent drain in another process) cannot replay entries that already
|
||||
// launched a connector.
|
||||
writeQueue(remaining);
|
||||
let restarted = 0;
|
||||
const failed: QueuedConnectorRestart[] = [];
|
||||
drainInProgress = true;
|
||||
try {
|
||||
for (const entry of matched) {
|
||||
let connector: ConnectCommandDefinition | undefined;
|
||||
try {
|
||||
connector = await getConnector(entry.connector);
|
||||
} catch {
|
||||
recordFailedRestartAttempt(entry, failed, io);
|
||||
continue;
|
||||
}
|
||||
if (!connector) {
|
||||
io.writeErr(
|
||||
`[connect] dropping queued restart for unknown connector "${entry.connector}"`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const exitCode = await connector
|
||||
.run(withRestartLaunchArgs(entry, hubUrl), io)
|
||||
.catch(() => 1);
|
||||
if (exitCode === 0) {
|
||||
restarted += 1;
|
||||
continue;
|
||||
}
|
||||
recordFailedRestartAttempt(entry, failed, io);
|
||||
}
|
||||
} finally {
|
||||
drainInProgress = false;
|
||||
}
|
||||
if (failed.length > 0) {
|
||||
// Re-read before appending so entries queued while restarting survive.
|
||||
writeQueue([...readQueue(), ...failed]);
|
||||
}
|
||||
return { restarted, remaining: readQueue().length };
|
||||
}
|
||||
@@ -8,12 +8,6 @@ export type ConnectStopResult = {
|
||||
stoppedSessions: number;
|
||||
};
|
||||
|
||||
export type ConnectorRestartSpec = {
|
||||
connector: string;
|
||||
args: string[];
|
||||
cwd?: string;
|
||||
};
|
||||
|
||||
export interface ConnectCommandDefinition {
|
||||
name: string;
|
||||
description: string;
|
||||
|
||||
@@ -2,6 +2,23 @@ import { fstatSync } from "node:fs";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CliMigrationNotice } from "./kanban-migration/notice";
|
||||
|
||||
interface MockConfiguredAgentConfig {
|
||||
name: string;
|
||||
description: string;
|
||||
systemPrompt: string;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
interface MockConfiguredAgentReadError {
|
||||
path: string;
|
||||
error: Error;
|
||||
}
|
||||
|
||||
interface MockConfiguredAgentLoadResult {
|
||||
configs: MockConfiguredAgentConfig[];
|
||||
errors: MockConfiguredAgentReadError[];
|
||||
}
|
||||
|
||||
/** Real `fstatSync`: used when tests stub only stdin (fd 0); throwing for every fd breaks imports and session I/O. */
|
||||
const fsActual = vi.hoisted(() => ({
|
||||
realFstatSync: null as null | typeof import("node:fs").fstatSync,
|
||||
@@ -47,6 +64,14 @@ const sessionMocks = vi.hoisted(() => ({
|
||||
const llmMocks = vi.hoisted(() => ({
|
||||
resolveProviderConfig: vi.fn(async (): Promise<unknown> => undefined),
|
||||
}));
|
||||
const agentConfigMocks = vi.hoisted(() => ({
|
||||
loadConfiguredAgentConfigs: vi.fn<
|
||||
(input: { workspaceRoot?: string }) => MockConfiguredAgentLoadResult
|
||||
>(() => ({
|
||||
configs: [],
|
||||
errors: [],
|
||||
})),
|
||||
}));
|
||||
const promptMocks = vi.hoisted(() => ({
|
||||
resolveSystemPrompt: vi.fn(async () => "system prompt"),
|
||||
}));
|
||||
@@ -142,6 +167,7 @@ vi.mock("./session/session", () => sessionMocks);
|
||||
vi.mock("@cline/core", () => {
|
||||
return {
|
||||
resolveProviderConfig: llmMocks.resolveProviderConfig,
|
||||
loadConfiguredAgentConfigs: agentConfigMocks.loadConfiguredAgentConfigs,
|
||||
createTeamName: vi.fn(() => "team-test"),
|
||||
createUserInstructionConfigService: vi.fn(() => ({
|
||||
start: vi.fn(async () => {}),
|
||||
@@ -215,6 +241,11 @@ describe("runCli lightweight command dispatch", () => {
|
||||
});
|
||||
llmMocks.resolveProviderConfig.mockReset();
|
||||
llmMocks.resolveProviderConfig.mockResolvedValue(undefined);
|
||||
agentConfigMocks.loadConfiguredAgentConfigs.mockReset();
|
||||
agentConfigMocks.loadConfiguredAgentConfigs.mockReturnValue({
|
||||
configs: [],
|
||||
errors: [],
|
||||
});
|
||||
authMocks.ensureOAuthProviderApiKey.mockReset();
|
||||
authMocks.getPersistedProviderApiKey.mockReset();
|
||||
authMocks.getPersistedProviderApiKey.mockReturnValue(undefined);
|
||||
@@ -872,6 +903,133 @@ describe("runCli lightweight command dispatch", () => {
|
||||
expect(hubRuntimeMocks.ensureCliHubServer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("applies an agent profile to prompt runs", async () => {
|
||||
agentConfigMocks.loadConfiguredAgentConfigs.mockReturnValue({
|
||||
configs: [
|
||||
{
|
||||
name: "Reviewer",
|
||||
description: "Reviews code",
|
||||
systemPrompt: "You are a reviewer.",
|
||||
path: "/repo/.cline/agents/reviewer.yaml",
|
||||
},
|
||||
],
|
||||
errors: [],
|
||||
});
|
||||
forcePromptModeInput();
|
||||
process.argv = ["bun", "src/index.ts", "--agent", "reviewer", "hello"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(agentConfigMocks.loadConfiguredAgentConfigs).toHaveBeenCalledWith({
|
||||
workspaceRoot: "/workspace/cline",
|
||||
});
|
||||
expect(promptMocks.resolveSystemPrompt).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
agentPersona: "You are a reviewer.",
|
||||
}),
|
||||
);
|
||||
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
|
||||
"hello",
|
||||
expect.objectContaining({
|
||||
agentProfile: {
|
||||
name: "Reviewer",
|
||||
systemPrompt: "You are a reviewer.",
|
||||
},
|
||||
systemPrompt: "system prompt",
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("lets --system override --agent without storing the profile", async () => {
|
||||
agentConfigMocks.loadConfiguredAgentConfigs.mockReturnValue({
|
||||
configs: [
|
||||
{
|
||||
name: "Reviewer",
|
||||
description: "Reviews code",
|
||||
systemPrompt: "You are a reviewer.",
|
||||
path: "/repo/.cline/agents/reviewer.yaml",
|
||||
},
|
||||
],
|
||||
errors: [],
|
||||
});
|
||||
forcePromptModeInput();
|
||||
process.argv = [
|
||||
"bun",
|
||||
"src/index.ts",
|
||||
"--agent",
|
||||
"reviewer",
|
||||
"--system",
|
||||
"Custom system.",
|
||||
"hello",
|
||||
];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(agentConfigMocks.loadConfiguredAgentConfigs).not.toHaveBeenCalled();
|
||||
expect(promptMocks.resolveSystemPrompt).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
explicitSystemPrompt: "Custom system.",
|
||||
}),
|
||||
);
|
||||
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
|
||||
"hello",
|
||||
expect.objectContaining({
|
||||
agentProfile: undefined,
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects missing agent profiles before starting the runtime", async () => {
|
||||
agentConfigMocks.loadConfiguredAgentConfigs.mockReturnValue({
|
||||
configs: [
|
||||
{
|
||||
name: "Planner",
|
||||
description: "Plans work",
|
||||
systemPrompt: "You are a planner.",
|
||||
path: "/repo/.cline/agents/planner.yaml",
|
||||
},
|
||||
],
|
||||
errors: [
|
||||
{
|
||||
path: "/repo/.cline/agents/broken.yaml",
|
||||
error: new Error("Missing system prompt body"),
|
||||
},
|
||||
],
|
||||
});
|
||||
forcePromptModeInput();
|
||||
process.argv = ["bun", "src/index.ts", "--agent", "reviewer", "hello"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(runtimeMocks.runAgent).not.toHaveBeenCalled();
|
||||
expect(runtimeMocks.runInteractive).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects --agent in yolo mode before loading profiles", async () => {
|
||||
forcePromptModeInput();
|
||||
process.argv = [
|
||||
"bun",
|
||||
"src/index.ts",
|
||||
"--yolo",
|
||||
"--agent",
|
||||
"reviewer",
|
||||
"hello",
|
||||
];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(agentConfigMocks.loadConfiguredAgentConfigs).not.toHaveBeenCalled();
|
||||
expect(runtimeMocks.runAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rewrites /team prompts and enables teams in single-prompt mode", async () => {
|
||||
runtimeMocks.runAgent.mockClear();
|
||||
|
||||
|
||||
+46
-1
@@ -42,7 +42,7 @@ import {
|
||||
captureCliExtensionActivated,
|
||||
getCliTelemetryService,
|
||||
} from "./utils/telemetry";
|
||||
import type { Config } from "./utils/types";
|
||||
import type { ActiveAgentProfile, Config } from "./utils/types";
|
||||
import { runConnectWizard } from "./wizards/connect";
|
||||
import { runMcpWizard } from "./wizards/mcp";
|
||||
import { runScheduleWizard } from "./wizards/schedule";
|
||||
@@ -928,6 +928,49 @@ export async function runCli(): Promise<void> {
|
||||
cwd,
|
||||
});
|
||||
|
||||
let activeAgentProfile: ActiveAgentProfile | undefined;
|
||||
const requestedAgentName = args.agent?.trim();
|
||||
if (requestedAgentName) {
|
||||
if (isYoloMode) {
|
||||
writeErr("--agent is not supported in yolo mode");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (args.systemPrompt) {
|
||||
// Don't store an unused profile: it would resurface on plan/act toggles.
|
||||
writeln(
|
||||
`${c.dim}[warn] --system overrides --agent; ignoring agent profile "${requestedAgentName}"${c.reset}`,
|
||||
);
|
||||
} else {
|
||||
const { loadConfiguredAgentConfigs } = await import("@cline/core");
|
||||
const { configs, errors } = loadConfiguredAgentConfigs({
|
||||
workspaceRoot,
|
||||
});
|
||||
const profile = configs.find(
|
||||
(candidate) =>
|
||||
candidate.name.trim().toLowerCase() ===
|
||||
requestedAgentName.toLowerCase(),
|
||||
);
|
||||
if (!profile) {
|
||||
const availableNames = configs.map((candidate) => candidate.name);
|
||||
writeErr(
|
||||
availableNames.length > 0
|
||||
? `agent profile "${requestedAgentName}" not found (available: ${availableNames.join(", ")})`
|
||||
: `agent profile "${requestedAgentName}" not found (no agent profiles in .cline/agents)`,
|
||||
);
|
||||
for (const error of errors) {
|
||||
writeErr(`failed to load ${error.path}: ${error.error.message}`);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
activeAgentProfile = {
|
||||
name: profile.name,
|
||||
systemPrompt: profile.systemPrompt,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const config: Config = {
|
||||
providerId: provider,
|
||||
modelId:
|
||||
@@ -942,6 +985,7 @@ export async function runCli(): Promise<void> {
|
||||
explicitSystemPrompt: args.systemPrompt,
|
||||
providerId: provider,
|
||||
mode: args.mode ?? "act",
|
||||
agentPersona: activeAgentProfile?.systemPrompt,
|
||||
}),
|
||||
execution: {
|
||||
maxConsecutiveMistakes: args.retries ?? 3,
|
||||
@@ -964,6 +1008,7 @@ export async function runCli(): Promise<void> {
|
||||
telemetry: getCliTelemetryService(loggerAdapter.core),
|
||||
defaultToolAutoApprove,
|
||||
toolPolicies,
|
||||
agentProfile: activeAgentProfile,
|
||||
enableSpawnAgent: !isYoloMode,
|
||||
enableAgentTeams: !isYoloMode,
|
||||
enableTools: true,
|
||||
|
||||
@@ -78,4 +78,36 @@ describe("applyInteractiveModeConfig", () => {
|
||||
expect(config.extraTools).toEqual([]);
|
||||
expect(config.systemPrompt).toBe("system prompt for act");
|
||||
});
|
||||
|
||||
it("threads the active agent profile persona across mode switches", async () => {
|
||||
const config = makeConfig();
|
||||
config.agentProfile = {
|
||||
name: "reviewer",
|
||||
systemPrompt: "You are a reviewer.",
|
||||
};
|
||||
|
||||
await applyInteractiveModeConfig({
|
||||
config,
|
||||
mode: "plan",
|
||||
switchToActModeTool,
|
||||
});
|
||||
expect(resolveSystemPrompt).toHaveBeenLastCalledWith({
|
||||
cwd: config.cwd,
|
||||
providerId: config.providerId,
|
||||
mode: "plan",
|
||||
agentPersona: "You are a reviewer.",
|
||||
});
|
||||
|
||||
await applyInteractiveModeConfig({
|
||||
config,
|
||||
mode: "act",
|
||||
switchToActModeTool,
|
||||
});
|
||||
expect(resolveSystemPrompt).toHaveBeenLastCalledWith({
|
||||
cwd: config.cwd,
|
||||
providerId: config.providerId,
|
||||
mode: "act",
|
||||
agentPersona: "You are a reviewer.",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,5 +43,6 @@ export async function applyInteractiveModeConfig(input: {
|
||||
cwd: input.config.cwd,
|
||||
providerId: input.config.providerId,
|
||||
mode: input.mode,
|
||||
agentPersona: input.config.agentProfile?.systemPrompt,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ import type {
|
||||
import { createRuntimeHooks } from "../../utils/hooks";
|
||||
import { setActiveCliSession } from "../../utils/output";
|
||||
import { loadInteractiveResumeMessages } from "../../utils/resume";
|
||||
import type { Config } from "../../utils/types";
|
||||
import type { ActiveAgentProfile, Config } from "../../utils/types";
|
||||
import { markAbortInProgress } from "../active-runtime";
|
||||
import type {
|
||||
PendingPromptSnapshot,
|
||||
@@ -359,6 +359,19 @@ export function createInteractiveSessionRuntime(input: {
|
||||
await restartWithCurrentMessages();
|
||||
};
|
||||
|
||||
const applyAgentProfile = async (
|
||||
profile: ActiveAgentProfile | undefined,
|
||||
): Promise<void> => {
|
||||
input.config.agentProfile = profile;
|
||||
// Re-apply the current mode so the system prompt picks up the persona.
|
||||
await applyInteractiveModeConfig({
|
||||
config: input.config,
|
||||
mode: input.config.mode === "plan" ? "plan" : "act",
|
||||
switchToActModeTool: input.switchToActModeTool,
|
||||
});
|
||||
await restartWithCurrentMessages();
|
||||
};
|
||||
|
||||
const sendCurrentTurn = async (
|
||||
turnInput: CurrentTurnInput,
|
||||
): Promise<CurrentTurnResult> => {
|
||||
@@ -639,6 +652,7 @@ export function createInteractiveSessionRuntime(input: {
|
||||
getCheckpointData,
|
||||
restoreCheckpoint,
|
||||
applyMode,
|
||||
applyAgentProfile,
|
||||
resetAbortRequest,
|
||||
abortAll,
|
||||
cleanup,
|
||||
|
||||
@@ -28,6 +28,8 @@ export async function resolveSystemPrompt(input: {
|
||||
providerId?: string;
|
||||
rules?: string;
|
||||
mode?: AgentMode;
|
||||
/** Agent profile body that replaces the persona slot of the base prompt */
|
||||
agentPersona?: string;
|
||||
}): Promise<string> {
|
||||
const metadata = await buildWorkspaceMetadata(input.cwd);
|
||||
let rules = mergeRulesForSystemPrompt(undefined, input.rules);
|
||||
@@ -45,6 +47,7 @@ export async function resolveSystemPrompt(input: {
|
||||
mode: input.mode,
|
||||
providerId: input.providerId,
|
||||
overridePrompt: input.explicitSystemPrompt,
|
||||
personaPrompt: input.agentPersona,
|
||||
platform:
|
||||
(typeof process !== "undefined" && process?.platform) || "unknown",
|
||||
});
|
||||
|
||||
@@ -598,6 +598,10 @@ export async function runInteractive(
|
||||
}
|
||||
await applyModeChange(mode);
|
||||
},
|
||||
onAgentProfileChange: async (profile) => {
|
||||
await sessionRuntime.ensureReady();
|
||||
await sessionRuntime.applyAgentProfile(profile ?? undefined);
|
||||
},
|
||||
onNewSession: async () => {
|
||||
await sessionRuntime.resetForNewSession();
|
||||
},
|
||||
|
||||
@@ -271,6 +271,35 @@ describe("slash command registry", () => {
|
||||
).toContain("settings");
|
||||
});
|
||||
|
||||
it("exposes agents as a local command with a hidden agent alias", () => {
|
||||
const registry = buildSlashCommandRegistry({});
|
||||
const commandNames = getVisibleSystemSlashCommands(registry).map(
|
||||
(command) => command.name,
|
||||
);
|
||||
|
||||
expect(resolveSlashCommand(registry, "agents")).toMatchObject({
|
||||
source: "tui",
|
||||
execution: "local",
|
||||
description: "Switch agent profile",
|
||||
visible: true,
|
||||
selectable: true,
|
||||
});
|
||||
expect(resolveSlashCommand(registry, "agent")).toMatchObject({
|
||||
source: "tui",
|
||||
execution: "local",
|
||||
visible: false,
|
||||
selectable: false,
|
||||
});
|
||||
expect(commandNames).toContain("agents");
|
||||
expect(commandNames).not.toContain("agent");
|
||||
expect(commandNames.indexOf("agents")).toBeGreaterThan(
|
||||
commandNames.indexOf("model"),
|
||||
);
|
||||
expect(commandNames.indexOf("agents")).toBeLessThan(
|
||||
commandNames.indexOf("account"),
|
||||
);
|
||||
});
|
||||
|
||||
it("always exposes the account command", () => {
|
||||
const registry = buildSlashCommandRegistry({});
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@ export type LocalSlashCommandName =
|
||||
| "plugins"
|
||||
| "account"
|
||||
| "model"
|
||||
| "agents"
|
||||
| "agent"
|
||||
| "compact"
|
||||
| "skills"
|
||||
| "fork"
|
||||
@@ -62,6 +64,15 @@ const TUI_LOCAL_COMMANDS: Array<{
|
||||
name: "model",
|
||||
description: "Switch model or provider",
|
||||
},
|
||||
{
|
||||
name: "agents",
|
||||
description: "Switch agent profile",
|
||||
},
|
||||
{
|
||||
name: "agent",
|
||||
description: "Switch agent profile",
|
||||
visible: false,
|
||||
},
|
||||
{
|
||||
name: "account",
|
||||
description: "View Cline account",
|
||||
@@ -112,6 +123,7 @@ const TUI_LOCAL_COMMANDS: Array<{
|
||||
const SYSTEM_COMMAND_ORDER = [
|
||||
"settings",
|
||||
"model",
|
||||
"agents",
|
||||
"account",
|
||||
"mcp",
|
||||
"plugins",
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { basename } from "node:path";
|
||||
import type { ChoiceContext } from "@opentui-ui/dialog";
|
||||
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
type SearchableItem,
|
||||
SearchableList,
|
||||
useSearchableList,
|
||||
} from "../searchable-list";
|
||||
|
||||
/** Sentinel resolved when the user picks the default Cline agent. */
|
||||
export const DEFAULT_AGENT_ACTION = "__default_agent__";
|
||||
|
||||
export interface AgentProfileOption {
|
||||
name: string;
|
||||
description?: string;
|
||||
systemPrompt: string;
|
||||
source: "workspace" | "global";
|
||||
}
|
||||
|
||||
export interface AgentProfileLoadError {
|
||||
path: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function AgentSelectorContent(
|
||||
props: ChoiceContext<string> & {
|
||||
currentAgentName: string | null;
|
||||
agents: AgentProfileOption[];
|
||||
loadErrors: AgentProfileLoadError[];
|
||||
},
|
||||
) {
|
||||
const { resolve, dismiss, dialogId, currentAgentName, agents, loadErrors } =
|
||||
props;
|
||||
|
||||
const items: SearchableItem[] = useMemo(() => {
|
||||
const normalizedCurrent = currentAgentName?.trim().toLowerCase() ?? null;
|
||||
const defaultItem: SearchableItem = {
|
||||
key: DEFAULT_AGENT_ACTION,
|
||||
label: "Cline (default)",
|
||||
detail: "Standard Cline agent",
|
||||
section: "Agents",
|
||||
rightLabel: normalizedCurrent === null ? "(current)" : undefined,
|
||||
};
|
||||
const profileItems = agents.map((agent) => ({
|
||||
key: agent.name.toLowerCase(),
|
||||
label: agent.name,
|
||||
detail: agent.description,
|
||||
section:
|
||||
agent.source === "workspace" ? "Workspace agents" : "Global agents",
|
||||
rightLabel:
|
||||
normalizedCurrent === agent.name.trim().toLowerCase()
|
||||
? "(current)"
|
||||
: undefined,
|
||||
}));
|
||||
return [defaultItem, ...profileItems];
|
||||
}, [agents, currentAgentName]);
|
||||
|
||||
const list = useSearchableList(items);
|
||||
|
||||
useDialogKeyboard((key) => {
|
||||
if (key.name === "escape") {
|
||||
dismiss();
|
||||
return;
|
||||
}
|
||||
if (key.name === "return") {
|
||||
const item = list.selectedItem;
|
||||
if (item) resolve(item.key);
|
||||
return;
|
||||
}
|
||||
if (key.name === "up") {
|
||||
list.moveUp();
|
||||
return;
|
||||
}
|
||||
if (key.name === "down") {
|
||||
list.moveDown();
|
||||
}
|
||||
}, dialogId);
|
||||
|
||||
return (
|
||||
<box flexDirection="column" gap={1}>
|
||||
<text>Select Agent</text>
|
||||
|
||||
<SearchableList
|
||||
items={list.filtered}
|
||||
selected={list.safeSelected}
|
||||
placeholder="Search agents..."
|
||||
onSearchChange={list.setSearch}
|
||||
onItemSelect={(item) => resolve(item.key)}
|
||||
emptyText="No agents match"
|
||||
/>
|
||||
|
||||
{loadErrors.length > 0 && (
|
||||
<box flexDirection="column">
|
||||
{loadErrors.map((error) => (
|
||||
<text key={error.path} fg="red">
|
||||
{basename(error.path)}: {error.message}
|
||||
</text>
|
||||
))}
|
||||
</box>
|
||||
)}
|
||||
|
||||
<text fg="gray">
|
||||
Type to search, ↑/↓ navigate, Enter to select, Esc to go back
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ export type CommandPaletteAction =
|
||||
| "settings"
|
||||
| "change-model"
|
||||
| "change-provider"
|
||||
| "agents"
|
||||
| "account"
|
||||
| "mcp"
|
||||
| "plugins"
|
||||
@@ -57,6 +58,13 @@ const ACTION_ITEMS: Array<{
|
||||
description: "Switch provider and configure credentials",
|
||||
keywords: ["provider", "api key", "account", "auth"],
|
||||
},
|
||||
{
|
||||
action: "agents",
|
||||
label: "Switch Agent",
|
||||
shortcut: "Opt+T",
|
||||
description: "Use an agent profile from .cline/agents",
|
||||
keywords: ["agent", "agents", "profile", "persona", "subagent"],
|
||||
},
|
||||
{
|
||||
action: "mcp",
|
||||
label: "Manage MCP Servers",
|
||||
|
||||
@@ -120,6 +120,12 @@ const HELP_ROWS: HelpRow[] = [
|
||||
key: "/model",
|
||||
desc: "Switch model or provider",
|
||||
},
|
||||
{
|
||||
kind: "entry",
|
||||
id: "c-agents",
|
||||
key: "/agents",
|
||||
desc: "Switch agent profile",
|
||||
},
|
||||
{
|
||||
kind: "entry",
|
||||
id: "c-settings",
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createContextBar,
|
||||
formatStatusBarAgentLabel,
|
||||
formatStatusBarAgentName,
|
||||
formatStatusBarUsageText,
|
||||
resolveContextBarFilledForeground,
|
||||
} from "./status-bar";
|
||||
@@ -49,6 +51,48 @@ describe("createContextBar", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatStatusBarAgentName", () => {
|
||||
it("keeps short names intact", () => {
|
||||
expect(formatStatusBarAgentName("reviewer")).toBe("reviewer");
|
||||
expect(formatStatusBarAgentName(" reviewer ")).toBe("reviewer");
|
||||
});
|
||||
|
||||
it("truncates long names with an ellipsis", () => {
|
||||
expect(formatStatusBarAgentName("documentation-specialist")).toBe(
|
||||
"documentation...",
|
||||
);
|
||||
expect(formatStatusBarAgentName("documentation-specialist").length).toBe(
|
||||
16,
|
||||
);
|
||||
});
|
||||
|
||||
it("handles very narrow limits without negative slicing", () => {
|
||||
expect(formatStatusBarAgentName("reviewer", 3)).toBe("...");
|
||||
expect(formatStatusBarAgentName("reviewer", 0)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatStatusBarAgentLabel", () => {
|
||||
it("returns the trimmed active agent name", () => {
|
||||
expect(formatStatusBarAgentLabel("reviewer")).toBe("reviewer");
|
||||
expect(formatStatusBarAgentLabel(" reviewer ")).toBe("reviewer");
|
||||
});
|
||||
|
||||
it("truncates to fit the label width", () => {
|
||||
expect(formatStatusBarAgentLabel("documentation-specialist", 18)).toBe(
|
||||
"documentation-s...",
|
||||
);
|
||||
expect(
|
||||
formatStatusBarAgentLabel("documentation-specialist", 18)?.length,
|
||||
).toBe(18);
|
||||
});
|
||||
|
||||
it("hides blank or too-narrow labels", () => {
|
||||
expect(formatStatusBarAgentLabel(" ")).toBeUndefined();
|
||||
expect(formatStatusBarAgentLabel("reviewer", 0)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatStatusBarUsageText", () => {
|
||||
it("includes cost when usage cost is visible", () => {
|
||||
expect(
|
||||
|
||||
@@ -104,6 +104,23 @@ export function resolveModelMaxInputTokens(config: {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function formatStatusBarAgentName(name: string, maxLen = 16): string {
|
||||
const normalized = name.trim();
|
||||
if (maxLen <= 0) return "";
|
||||
if (normalized.length <= maxLen) return normalized;
|
||||
if (maxLen <= 3) return ".".repeat(maxLen);
|
||||
return `${normalized.slice(0, maxLen - 3)}...`;
|
||||
}
|
||||
|
||||
export function formatStatusBarAgentLabel(
|
||||
name: string,
|
||||
maxLen = 18,
|
||||
): string | undefined {
|
||||
const normalized = name.trim();
|
||||
if (!normalized || maxLen <= 0) return undefined;
|
||||
return formatStatusBarAgentName(normalized, maxLen);
|
||||
}
|
||||
|
||||
export interface StatusBarProps {
|
||||
providerId: string;
|
||||
modelId: string;
|
||||
@@ -120,6 +137,9 @@ export interface StatusBarProps {
|
||||
deletions: number;
|
||||
} | null;
|
||||
onToggleMode?: () => void;
|
||||
/** Active agent profile name; the indicator is hidden when unset */
|
||||
agentName?: string | null;
|
||||
onOpenAgent?: () => void;
|
||||
variant?: "home" | "chat";
|
||||
}
|
||||
|
||||
@@ -135,6 +155,8 @@ export function StatusBar(props: StatusBarProps) {
|
||||
gitBranch,
|
||||
gitDiffStats,
|
||||
onToggleMode,
|
||||
agentName,
|
||||
onOpenAgent,
|
||||
} = props;
|
||||
|
||||
const { width } = useTerminalDimensions();
|
||||
@@ -162,20 +184,26 @@ export function StatusBar(props: StatusBarProps) {
|
||||
? Math.min(width, HOME_VIEW_MAX_WIDTH) - 2
|
||||
: width - 2;
|
||||
|
||||
// Row 1 layout: [model + context info] .... [Plan/Act toggle]
|
||||
// Row 1 layout: [model + context info] .... [agent label] [Plan/Act toggle]
|
||||
// When the full row doesn't fit, context info drops to its own row 2.
|
||||
// Model ID truncates with "..." before wrapping; toggle stays right-aligned.
|
||||
const toggleWidth = 20;
|
||||
const fullAgentLabel = agentName
|
||||
? formatStatusBarAgentLabel(agentName)
|
||||
: undefined;
|
||||
const agentLabelWidth = fullAgentLabel ? fullAgentLabel.length + 3 : 0;
|
||||
const usageText = formatStatusBarUsageText({
|
||||
totalTokens,
|
||||
totalCost,
|
||||
showCost: showUsageCost,
|
||||
});
|
||||
const contextText = bar
|
||||
? ` ${bar.filled}${bar.empty} ${usageText}`
|
||||
: ` ${usageText}`;
|
||||
const contextInlineText = bar
|
||||
? `${bar.filled}${bar.empty} ${usageText}`
|
||||
: usageText;
|
||||
const contextText = ` ${contextInlineText}`;
|
||||
const firstRowFits =
|
||||
modelId.length + contextText.length + toggleWidth + 1 <= avail;
|
||||
modelId.length + contextText.length + toggleWidth + agentLabelWidth + 1 <=
|
||||
avail;
|
||||
const renderContextText = (withLeadingSpace: boolean) => (
|
||||
<>
|
||||
{withLeadingSpace && " "}
|
||||
@@ -191,7 +219,11 @@ export function StatusBar(props: StatusBarProps) {
|
||||
|
||||
const modelMaxLen = Math.max(
|
||||
10,
|
||||
avail - toggleWidth - (firstRowFits ? contextText.length : 0) - 1,
|
||||
avail -
|
||||
toggleWidth -
|
||||
agentLabelWidth -
|
||||
(firstRowFits ? contextText.length : 0) -
|
||||
1,
|
||||
);
|
||||
const truncatedModel =
|
||||
modelId.length > modelMaxLen
|
||||
@@ -210,6 +242,14 @@ export function StatusBar(props: StatusBarProps) {
|
||||
pathPart.length > pathMax
|
||||
? `${pathPart.slice(0, pathMax - 3)}...`
|
||||
: pathPart;
|
||||
const firstRowAgentMaxLen = Math.min(
|
||||
18,
|
||||
Math.max(0, avail - toggleWidth - 3),
|
||||
);
|
||||
const agentLabel = agentName
|
||||
? formatStatusBarAgentLabel(agentName, firstRowAgentMaxLen)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<box flexDirection="column" paddingX={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
@@ -223,6 +263,14 @@ export function StatusBar(props: StatusBarProps) {
|
||||
flexShrink={0}
|
||||
onMouseDown={onToggleMode}
|
||||
>
|
||||
{agentLabel && (
|
||||
<>
|
||||
<box flexShrink={0} onMouseDown={onOpenAgent}>
|
||||
<text fg={defaultFg}>{agentLabel}</text>
|
||||
</box>
|
||||
<text fg="gray">|</text>
|
||||
</>
|
||||
)}
|
||||
<text fg={uiMode === "plan" ? planAccent : "gray"}>
|
||||
{uiMode === "plan" ? "●" : "○"} Plan
|
||||
</text>
|
||||
|
||||
@@ -21,6 +21,7 @@ interface SessionContextValue {
|
||||
uiMode: AgentMode;
|
||||
autoApproveAll: boolean;
|
||||
compactionMode: CliCompactionMode;
|
||||
activeAgentName: string | null;
|
||||
lastTotalTokens: number;
|
||||
lastTotalCost: number;
|
||||
isExitRequested: boolean;
|
||||
@@ -41,6 +42,7 @@ interface SessionContextValue {
|
||||
setUiMode: (mode: AgentMode) => void;
|
||||
toggleMode: () => void;
|
||||
toggleAutoApprove: () => void;
|
||||
setActiveAgentName: (name: string | null) => void;
|
||||
setCompactionMode: (mode: CliCompactionMode) => void;
|
||||
requestExit: () => void;
|
||||
clearEntries: () => void;
|
||||
@@ -112,6 +114,9 @@ export function SessionProvider(props: {
|
||||
const [compactionMode, _setCompactionMode] = useState<CliCompactionMode>(() =>
|
||||
getCliCompactionMode(config),
|
||||
);
|
||||
const [activeAgentName, setActiveAgentName] = useState<string | null>(
|
||||
config.agentProfile?.name ?? null,
|
||||
);
|
||||
const [lastTotalTokens, setLastTotalTokens] = useState(
|
||||
() => initialUsage?.totalTokens ?? 0,
|
||||
);
|
||||
@@ -250,6 +255,7 @@ export function SessionProvider(props: {
|
||||
uiMode,
|
||||
autoApproveAll,
|
||||
compactionMode,
|
||||
activeAgentName,
|
||||
lastTotalTokens,
|
||||
lastTotalCost,
|
||||
isExitRequested,
|
||||
@@ -268,6 +274,7 @@ export function SessionProvider(props: {
|
||||
setUiMode,
|
||||
toggleMode,
|
||||
toggleAutoApprove,
|
||||
setActiveAgentName,
|
||||
setCompactionMode,
|
||||
requestExit,
|
||||
clearEntries,
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface LocalSlashCommandActionInput {
|
||||
openConfig: (options?: OpenConfigOptions) => void;
|
||||
openMcpManager: () => Promise<boolean>;
|
||||
openModelSelector: () => void;
|
||||
openAgentSelector: () => void;
|
||||
openSkills: (invocation?: LocalSlashCommandInvocation) => void;
|
||||
invocation?: LocalSlashCommandInvocation;
|
||||
runCompact: () => void;
|
||||
@@ -45,6 +46,10 @@ export function runLocalSlashCommandAction(
|
||||
input.openModelSelector();
|
||||
return true;
|
||||
}
|
||||
if (normalized === "agents" || normalized === "agent") {
|
||||
input.openAgentSelector();
|
||||
return true;
|
||||
}
|
||||
if (normalized === "compact") {
|
||||
input.runCompact();
|
||||
return true;
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { sep } from "node:path";
|
||||
import { loadConfiguredAgentConfigs } from "@cline/core";
|
||||
import type { ChoiceContext } from "@opentui-ui/dialog";
|
||||
import type { DialogActions } from "@opentui-ui/dialog/react";
|
||||
import { useCallback } from "react";
|
||||
import type { Config } from "../../utils/types";
|
||||
import {
|
||||
type AgentProfileLoadError,
|
||||
type AgentProfileOption,
|
||||
AgentSelectorContent,
|
||||
DEFAULT_AGENT_ACTION,
|
||||
} from "../components/dialogs/agent-selector";
|
||||
import { withLoadingDialog } from "../components/dialogs/loading-dialog";
|
||||
import { useSession } from "../contexts/session-context";
|
||||
import type { TuiProps } from "../types";
|
||||
|
||||
function loadAgentProfileEntries(config: Config): {
|
||||
agents: AgentProfileOption[];
|
||||
loadErrors: AgentProfileLoadError[];
|
||||
} {
|
||||
const workspaceRoot = config.workspaceRoot?.trim() || config.cwd;
|
||||
const { configs, errors } = loadConfiguredAgentConfigs({ workspaceRoot });
|
||||
return {
|
||||
agents: configs.map((profile) => ({
|
||||
name: profile.name,
|
||||
description: profile.description,
|
||||
systemPrompt: profile.systemPrompt,
|
||||
source:
|
||||
workspaceRoot && profile.path?.startsWith(`${workspaceRoot}${sep}`)
|
||||
? ("workspace" as const)
|
||||
: ("global" as const),
|
||||
})),
|
||||
loadErrors: errors.map((error) => ({
|
||||
path: error.path,
|
||||
message: error.error.message,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function useAgentSelector(opts: {
|
||||
dialog: DialogActions;
|
||||
config: Config;
|
||||
termHeight: number;
|
||||
onAgentProfileChange: TuiProps["onAgentProfileChange"];
|
||||
refocusTextarea: () => void;
|
||||
}): () => Promise<void> {
|
||||
const { dialog, config, termHeight, onAgentProfileChange, refocusTextarea } =
|
||||
opts;
|
||||
const session = useSession();
|
||||
|
||||
const openAgentSelector = useCallback(async () => {
|
||||
// Applying a profile restarts the session in place, so refuse while a
|
||||
// turn is running instead of yanking the live stream out from under it.
|
||||
if (session.isRunning) {
|
||||
session.appendEntry({
|
||||
kind: "status",
|
||||
text: "Finish or abort the current task before switching agents.",
|
||||
});
|
||||
refocusTextarea();
|
||||
return;
|
||||
}
|
||||
const { agents, loadErrors } = loadAgentProfileEntries(config);
|
||||
const currentAgentName = config.agentProfile?.name ?? null;
|
||||
|
||||
const selectedKey = await dialog.choice<string>({
|
||||
style: { maxHeight: termHeight - 2 },
|
||||
content: (ctx: ChoiceContext<string>) => (
|
||||
<AgentSelectorContent
|
||||
{...ctx}
|
||||
currentAgentName={currentAgentName}
|
||||
agents={agents}
|
||||
loadErrors={loadErrors}
|
||||
/>
|
||||
),
|
||||
});
|
||||
if (!selectedKey) {
|
||||
refocusTextarea();
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedKey === DEFAULT_AGENT_ACTION) {
|
||||
if (config.agentProfile) {
|
||||
await withLoadingDialog(dialog, "Applying agent...", async () => {
|
||||
await onAgentProfileChange(null);
|
||||
});
|
||||
session.setActiveAgentName(null);
|
||||
}
|
||||
refocusTextarea();
|
||||
return;
|
||||
}
|
||||
|
||||
const profile = agents.find(
|
||||
(agent) => agent.name.toLowerCase() === selectedKey,
|
||||
);
|
||||
if (!profile) {
|
||||
refocusTextarea();
|
||||
return;
|
||||
}
|
||||
if (profile.name !== currentAgentName) {
|
||||
await withLoadingDialog(dialog, "Applying agent...", async () => {
|
||||
await onAgentProfileChange({
|
||||
name: profile.name,
|
||||
systemPrompt: profile.systemPrompt,
|
||||
});
|
||||
});
|
||||
session.setActiveAgentName(profile.name);
|
||||
}
|
||||
refocusTextarea();
|
||||
}, [
|
||||
dialog,
|
||||
config,
|
||||
termHeight,
|
||||
onAgentProfileChange,
|
||||
refocusTextarea,
|
||||
session,
|
||||
]);
|
||||
|
||||
return openAgentSelector;
|
||||
}
|
||||
@@ -13,6 +13,7 @@ function makeActions(
|
||||
openConfig: vi.fn(),
|
||||
openMcpManager: vi.fn(async () => false),
|
||||
openModelSelector: vi.fn(),
|
||||
openAgentSelector: vi.fn(),
|
||||
openSkills: vi.fn(),
|
||||
runCompact: vi.fn(),
|
||||
runFork: vi.fn(),
|
||||
@@ -45,6 +46,21 @@ describe("runLocalSlashCommandAction", () => {
|
||||
expect(openSkills).toHaveBeenCalledWith(invocation);
|
||||
});
|
||||
|
||||
it("opens the agent selector with agents and the agent alias", () => {
|
||||
for (const name of ["agents", "agent"]) {
|
||||
const openAgentSelector = vi.fn();
|
||||
const actions = makeActions({ openAgentSelector });
|
||||
|
||||
const handled = runLocalSlashCommandAction({
|
||||
name,
|
||||
...actions,
|
||||
});
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(openAgentSelector).toHaveBeenCalledOnce();
|
||||
}
|
||||
});
|
||||
|
||||
it("opens settings to the plugins tab with plugins", () => {
|
||||
const openConfig = vi.fn();
|
||||
const actions = makeActions({ openConfig });
|
||||
|
||||
@@ -23,6 +23,7 @@ export function useLocalCommandActions(input: {
|
||||
openConfig: (options?: OpenConfigOptions) => void;
|
||||
openMcpManager: () => Promise<boolean>;
|
||||
openModelSelector: () => void;
|
||||
openAgentSelector: () => void;
|
||||
openSkills: (invocation?: LocalSlashCommandInvocation) => void;
|
||||
refocusTextarea: () => void;
|
||||
setAppView: (view: AppView) => void;
|
||||
@@ -43,6 +44,7 @@ export function useLocalCommandActions(input: {
|
||||
openConfig,
|
||||
openMcpManager,
|
||||
openModelSelector,
|
||||
openAgentSelector,
|
||||
openSkills,
|
||||
refocusTextarea,
|
||||
setAppView,
|
||||
@@ -185,6 +187,7 @@ export function useLocalCommandActions(input: {
|
||||
openConfig,
|
||||
openMcpManager,
|
||||
openModelSelector,
|
||||
openAgentSelector,
|
||||
openSkills,
|
||||
runCompact,
|
||||
runFork,
|
||||
@@ -205,6 +208,7 @@ export function useLocalCommandActions(input: {
|
||||
openHelp,
|
||||
openHistory,
|
||||
openModelSelector,
|
||||
openAgentSelector,
|
||||
openSkills,
|
||||
runCompact,
|
||||
runFork,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import {
|
||||
basename,
|
||||
dirname,
|
||||
@@ -14,11 +14,11 @@ import {
|
||||
hasMcpSettingsFile,
|
||||
listHookConfigFiles,
|
||||
listPluginToolsWithDiagnostics,
|
||||
loadConfiguredAgentConfigs,
|
||||
type McpServerRegistration,
|
||||
type PluginInitializationFailure,
|
||||
type RuleConfig,
|
||||
readGlobalSettings,
|
||||
resolveAgentConfigSearchPaths,
|
||||
resolveDefaultMcpSettingsPath,
|
||||
resolveMcpServerRegistrations,
|
||||
resolvePluginConfigSearchPaths,
|
||||
@@ -175,58 +175,30 @@ function getMcpDescription(registration: McpServerRegistration): string {
|
||||
}
|
||||
|
||||
function loadAgentConfigItems(workspaceRoot: string): InteractiveConfigItem[] {
|
||||
const agentsById = new Map<string, InteractiveConfigItem>();
|
||||
const directories = resolveAgentConfigSearchPaths(workspaceRoot).filter(
|
||||
(directory) => existsSync(directory),
|
||||
);
|
||||
|
||||
for (const directory of directories) {
|
||||
try {
|
||||
const entries = readdirSync(directory, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) {
|
||||
continue;
|
||||
}
|
||||
const extension = extname(entry.name).toLowerCase();
|
||||
if (extension !== ".yml" && extension !== ".yaml") {
|
||||
continue;
|
||||
}
|
||||
const filePath = join(directory, entry.name);
|
||||
const raw = readFileSync(filePath, "utf8");
|
||||
const frontmatterMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
||||
const frontmatter = frontmatterMatch?.[1] ?? "";
|
||||
const nameMatch = frontmatter.match(/^\s*name:\s*(.+?)\s*$/m);
|
||||
const descriptionMatch = frontmatter.match(
|
||||
/^\s*description:\s*(.+?)\s*$/m,
|
||||
);
|
||||
const parsedName = nameMatch?.[1]?.replace(/^["']|["']$/g, "").trim();
|
||||
const parsedDescription = descriptionMatch?.[1]
|
||||
?.replace(/^["']|["']$/g, "")
|
||||
.trim();
|
||||
const name =
|
||||
parsedName && parsedName.length > 0
|
||||
? parsedName
|
||||
: basename(entry.name, extension);
|
||||
const id = name.toLowerCase();
|
||||
if (agentsById.has(id)) {
|
||||
continue;
|
||||
}
|
||||
agentsById.set(id, {
|
||||
id,
|
||||
name,
|
||||
path: filePath,
|
||||
enabled: true,
|
||||
kind: "agent",
|
||||
source: detectSource(filePath, workspaceRoot),
|
||||
description: parsedDescription,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Best effort: keep listing other agent config roots.
|
||||
}
|
||||
const { configs, errors } = loadConfiguredAgentConfigs({ workspaceRoot });
|
||||
const items: InteractiveConfigItem[] = configs.map((config) => ({
|
||||
id: config.name.toLowerCase(),
|
||||
name: config.name,
|
||||
path: config.path ?? "",
|
||||
enabled: true,
|
||||
kind: "agent" as const,
|
||||
source: detectSource(config.path ?? "", workspaceRoot),
|
||||
description: config.description,
|
||||
}));
|
||||
// Keep broken profile files visible so users can spot and fix them.
|
||||
for (const error of errors) {
|
||||
items.push({
|
||||
id: error.path,
|
||||
name: basename(error.path, extname(error.path)),
|
||||
path: error.path,
|
||||
enabled: false,
|
||||
kind: "agent" as const,
|
||||
source: detectSource(error.path, workspaceRoot),
|
||||
description: error.error.message,
|
||||
loadError: error.error.message,
|
||||
});
|
||||
}
|
||||
|
||||
return [...agentsById.values()];
|
||||
return items;
|
||||
}
|
||||
|
||||
function readPackageName(packageJsonPath: string): string | undefined {
|
||||
|
||||
@@ -41,6 +41,7 @@ import { EventBridgeProvider } from "./contexts/event-bridge-context";
|
||||
import { SessionProvider, useSession } from "./contexts/session-context";
|
||||
import { useAccountDialog } from "./hooks/use-account-dialog";
|
||||
import { useAgentEventHandlers } from "./hooks/use-agent-events";
|
||||
import { useAgentSelector } from "./hooks/use-agent-selector";
|
||||
import { useAutocomplete } from "./hooks/use-autocomplete";
|
||||
import { useConfigPanel } from "./hooks/use-config-panel";
|
||||
import { useLocalCommandActions } from "./hooks/use-local-command-actions";
|
||||
@@ -187,6 +188,14 @@ function App(props: TuiProps) {
|
||||
refocusTextarea: () => refocusTextareaRef.current(),
|
||||
});
|
||||
|
||||
const openAgentSelector = useAgentSelector({
|
||||
dialog,
|
||||
config: props.config,
|
||||
termHeight,
|
||||
onAgentProfileChange: props.onAgentProfileChange,
|
||||
refocusTextarea: () => refocusTextareaRef.current(),
|
||||
});
|
||||
|
||||
const openMcpManager = useMcpManager({
|
||||
dialog,
|
||||
termHeight,
|
||||
@@ -639,6 +648,7 @@ function App(props: TuiProps) {
|
||||
openConfig,
|
||||
openMcpManager,
|
||||
openModelSelector,
|
||||
openAgentSelector,
|
||||
openSkills,
|
||||
refocusTextarea: () => refocusTextareaRef.current(),
|
||||
setAppView,
|
||||
@@ -886,6 +896,9 @@ function App(props: TuiProps) {
|
||||
void saveQueuedPromptEdit(id, prompt);
|
||||
},
|
||||
onToggleMode: toggleMode,
|
||||
onOpenAgentSelector: () => {
|
||||
void openAgentSelector();
|
||||
},
|
||||
runtimeInteraction,
|
||||
onResolveToolApproval: runtimeBridge.resolveToolApproval,
|
||||
onResolveAskQuestion: runtimeBridge.resolveAskQuestion,
|
||||
|
||||
@@ -15,7 +15,11 @@ import type {
|
||||
PendingPromptSubmittedEvent,
|
||||
} from "../runtime/session-events";
|
||||
import type { RepoStatus } from "../utils/repo-status";
|
||||
import type { CliCompactionMode, Config } from "../utils/types";
|
||||
import type {
|
||||
ActiveAgentProfile,
|
||||
CliCompactionMode,
|
||||
Config,
|
||||
} from "../utils/types";
|
||||
import type { ClineAccountSnapshot } from "./cline-account";
|
||||
import type {
|
||||
InteractiveConfigData,
|
||||
@@ -166,6 +170,7 @@ export interface TuiProps {
|
||||
onCompactionModeChange: (mode: CliCompactionMode) => Promise<void>;
|
||||
onModelChange: () => Promise<void>;
|
||||
onModeChange: (mode: AgentMode) => Promise<void>;
|
||||
onAgentProfileChange: (profile: ActiveAgentProfile | null) => Promise<void>;
|
||||
onNewSession: () => Promise<void>;
|
||||
onSessionRestart: () => Promise<void>;
|
||||
onAccountChange: () => Promise<void>;
|
||||
|
||||
@@ -107,12 +107,13 @@ describe("copyTextToSystemClipboard", () => {
|
||||
|
||||
expect(spawnMock).toHaveBeenNthCalledWith(1, "wl-copy", [], {
|
||||
stdio: ["pipe", "ignore", "ignore"],
|
||||
windowsHide: true,
|
||||
});
|
||||
expect(spawnMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"xclip",
|
||||
["-selection", "clipboard"],
|
||||
{ stdio: ["pipe", "ignore", "ignore"] },
|
||||
{ stdio: ["pipe", "ignore", "ignore"], windowsHide: true },
|
||||
);
|
||||
expect(failed.getInput()).toBe("selected text");
|
||||
expect(succeeded.getInput()).toBe("selected text");
|
||||
@@ -134,6 +135,7 @@ describe("copyTextToSystemClipboard", () => {
|
||||
expect(spawnMock).toHaveBeenCalledTimes(1);
|
||||
expect(spawnMock).toHaveBeenCalledWith("wl-copy", [], {
|
||||
stdio: ["pipe", "ignore", "ignore"],
|
||||
windowsHide: true,
|
||||
});
|
||||
expect(wlcopy.getInput()).toBe("plain linux");
|
||||
});
|
||||
|
||||
@@ -142,6 +142,8 @@ function runClipboardCommand(
|
||||
const child = spawn(command.command, command.args, {
|
||||
stdio: ["pipe", "ignore", "ignore"],
|
||||
...(command.env ? { env: command.env } : {}),
|
||||
// Prevent a console window from flashing on Windows.
|
||||
windowsHide: true,
|
||||
});
|
||||
let settled = false;
|
||||
|
||||
|
||||
@@ -115,6 +115,8 @@ async function runCommand(
|
||||
return await new Promise((resolve) => {
|
||||
const child = spawn(command, args, {
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
// Prevent a console window from flashing on Windows.
|
||||
windowsHide: true,
|
||||
});
|
||||
const chunks: Buffer[] = [];
|
||||
let total = 0;
|
||||
|
||||
@@ -56,6 +56,7 @@ export function ChatView(props: {
|
||||
editingQueuedPrompt?: QueuedPromptItem;
|
||||
onQueuedPromptEditConfirm: (id: string, prompt: string) => void;
|
||||
onToggleMode: () => void;
|
||||
onOpenAgentSelector: () => void;
|
||||
runtimeInteraction?: RuntimeToolInteraction | null;
|
||||
onResolveToolApproval: (id: number, approved: boolean) => void;
|
||||
onResolveAskQuestion: (id: number, answer: string | null) => void;
|
||||
@@ -157,6 +158,8 @@ export function ChatView(props: {
|
||||
gitBranch={repoStatus.branch}
|
||||
gitDiffStats={repoStatus.diffStats}
|
||||
onToggleMode={props.onToggleMode}
|
||||
agentName={session.activeAgentName}
|
||||
onOpenAgent={props.onOpenAgentSelector}
|
||||
variant="chat"
|
||||
/>
|
||||
</box>
|
||||
|
||||
@@ -46,6 +46,7 @@ export function HomeView(props: {
|
||||
textareaRef?: React.MutableRefObject<TextareaHandle | null>;
|
||||
autocomplete?: AutocompleteDropdownProps;
|
||||
onToggleMode: () => void;
|
||||
onOpenAgentSelector: () => void;
|
||||
}) {
|
||||
const {
|
||||
config,
|
||||
@@ -156,6 +157,8 @@ export function HomeView(props: {
|
||||
gitBranch={repoStatus.branch}
|
||||
gitDiffStats={repoStatus.diffStats}
|
||||
onToggleMode={props.onToggleMode}
|
||||
agentName={session.activeAgentName}
|
||||
onOpenAgent={props.onOpenAgentSelector}
|
||||
variant="home"
|
||||
/>
|
||||
</box>
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { mockEnsureDetachedHubServer, mockRestartQueuedConnectorsForHub } =
|
||||
vi.hoisted(() => ({
|
||||
mockEnsureDetachedHubServer: vi.fn(),
|
||||
mockRestartQueuedConnectorsForHub: vi.fn(async () => ({
|
||||
restarted: 0,
|
||||
remaining: 0,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@cline/core", () => ({
|
||||
createHubServerUrl: (host: string, port: number, pathname: string) =>
|
||||
`ws://${host}:${port}${pathname}`,
|
||||
ensureDetachedHubServer: mockEnsureDetachedHubServer,
|
||||
resolveDefaultHubHost: () => "127.0.0.1",
|
||||
resolveDefaultHubPort: () => 25463,
|
||||
resolveHubEndpointOptions: () => ({
|
||||
host: "127.0.0.1",
|
||||
port: 25463,
|
||||
pathname: "/hub",
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../connectors/restart", () => ({
|
||||
restartQueuedConnectorsForHub: mockRestartQueuedConnectorsForHub,
|
||||
}));
|
||||
|
||||
import { ensureCliHubServer } from "./hub-runtime";
|
||||
|
||||
describe("ensureCliHubServer", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("drains the connector restart queue after ensuring the hub", async () => {
|
||||
mockEnsureDetachedHubServer.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "token",
|
||||
});
|
||||
|
||||
const resolution = await ensureCliHubServer("/workspace");
|
||||
|
||||
expect(resolution.url).toBe("ws://127.0.0.1:25463/hub");
|
||||
expect(mockRestartQueuedConnectorsForHub).toHaveBeenCalledWith(
|
||||
"ws://127.0.0.1:25463/hub",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns the hub resolution even when draining the queue fails", async () => {
|
||||
mockEnsureDetachedHubServer.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "token",
|
||||
});
|
||||
mockRestartQueuedConnectorsForHub.mockRejectedValueOnce(
|
||||
new Error("queue unreadable"),
|
||||
);
|
||||
|
||||
const resolution = await ensureCliHubServer("/workspace");
|
||||
|
||||
expect(resolution.url).toBe("ws://127.0.0.1:25463/hub");
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,10 @@
|
||||
import {
|
||||
createHubServerUrl,
|
||||
type DetachedHubResolution,
|
||||
ensureDetachedHubServer,
|
||||
type HubEndpointOverrides,
|
||||
resolveDefaultHubHost,
|
||||
resolveDefaultHubPort,
|
||||
resolveHubEndpointOptions,
|
||||
} from "@cline/core";
|
||||
import { restartQueuedConnectorsForHub } from "../connectors/restart";
|
||||
|
||||
/**
|
||||
* Build a `host:port` rpc address string that respects the current build
|
||||
@@ -18,11 +15,6 @@ export function resolveDefaultCliRpcAddress(): string {
|
||||
return `${resolveDefaultHubHost()}:${resolveDefaultHubPort()}`;
|
||||
}
|
||||
|
||||
export function resolveDefaultCliHubUrl(): string {
|
||||
const endpoint = resolveHubEndpointOptions();
|
||||
return createHubServerUrl(endpoint.host, endpoint.port, endpoint.pathname);
|
||||
}
|
||||
|
||||
export function parseHubEndpointOverride(
|
||||
rawAddress: string | undefined,
|
||||
): HubEndpointOverrides {
|
||||
@@ -51,12 +43,5 @@ export async function ensureCliHubServer(
|
||||
workspaceRoot: string,
|
||||
endpoint: HubEndpointOverrides = {},
|
||||
): Promise<DetachedHubResolution> {
|
||||
const resolution = await ensureDetachedHubServer(workspaceRoot, endpoint);
|
||||
// Connectors queued by hub stop/doctor/update cleanup come back as soon
|
||||
// as any CLI path brings the hub up, not only explicit hub commands.
|
||||
await restartQueuedConnectorsForHub(resolution.url, {
|
||||
writeln: () => {},
|
||||
writeErr: () => {},
|
||||
}).catch(() => undefined);
|
||||
return resolution;
|
||||
return await ensureDetachedHubServer(workspaceRoot, endpoint);
|
||||
}
|
||||
|
||||
@@ -18,9 +18,12 @@ export async function readRepoStatus(cwd: string): Promise<RepoStatus> {
|
||||
const [branchResult, diffResult] = await Promise.allSettled([
|
||||
execFileAsync("git", ["-C", cwd, "rev-parse", "--abbrev-ref", "HEAD"], {
|
||||
encoding: "utf8",
|
||||
// Prevent a console window from flashing on Windows.
|
||||
windowsHide: true,
|
||||
}),
|
||||
execFileAsync("git", ["-C", cwd, "diff", "--shortstat"], {
|
||||
encoding: "utf8",
|
||||
windowsHide: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
|
||||
@@ -17,6 +17,16 @@ export type CliReasoningEffort = NonNullable<
|
||||
>;
|
||||
export type CliCompactionMode = "agentic" | "basic" | "off";
|
||||
|
||||
/**
|
||||
* An agent profile from .cline/agents applied to the main Cline agent for
|
||||
* the current session. Session-only: never persisted to settings.
|
||||
*/
|
||||
export interface ActiveAgentProfile {
|
||||
name: string;
|
||||
/** Profile body, captured at selection time (survives file deletion mid-session) */
|
||||
systemPrompt: string;
|
||||
}
|
||||
|
||||
export interface Config extends Omit<CoreSessionConfig, "apiKey" | "mode"> {
|
||||
apiKey: string;
|
||||
knownModels?: Record<string, Llms.ModelInfo>;
|
||||
@@ -30,6 +40,7 @@ export interface Config extends Omit<CoreSessionConfig, "apiKey" | "mode"> {
|
||||
mode: CliAgentMode;
|
||||
defaultToolAutoApprove: boolean;
|
||||
toolPolicies: Record<string, ToolPolicy>;
|
||||
agentProfile?: ActiveAgentProfile;
|
||||
}
|
||||
|
||||
export interface ActiveCliSession {
|
||||
@@ -96,4 +107,6 @@ export interface ParsedArgs {
|
||||
teamName?: string;
|
||||
defaultToolAutoApprove: boolean;
|
||||
autoApproveOverride?: boolean;
|
||||
/** Agent profile name from .cline/agents to apply to the main agent */
|
||||
agent?: string;
|
||||
}
|
||||
|
||||
@@ -68,6 +68,8 @@ async function runCliConnectCommand(args: string[]): Promise<{
|
||||
CLINE_BUILD_ENV: process.env.CLINE_BUILD_ENV ?? "development",
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
// Prevent a console window from flashing on Windows.
|
||||
windowsHide: true,
|
||||
},
|
||||
);
|
||||
let stdout = "";
|
||||
|
||||
@@ -134,6 +134,12 @@ export function openExternalUrl(url: string): void {
|
||||
const command =
|
||||
platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
|
||||
const args = platform === "win32" ? ["/c", "start", "", url] : [url];
|
||||
const child = spawn(command, args, { stdio: "ignore", detached: true });
|
||||
const child = spawn(command, args, {
|
||||
stdio: "ignore",
|
||||
detached: true,
|
||||
// Prevent a console window from flashing on Windows; the launched
|
||||
// browser/app still opens normally.
|
||||
windowsHide: true,
|
||||
});
|
||||
child.unref();
|
||||
}
|
||||
|
||||
@@ -193,6 +193,8 @@ function startCommand(
|
||||
detached: true,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env: process.env,
|
||||
// Prevent a console window from flashing on Windows.
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
const record: JobRecord = {
|
||||
|
||||
@@ -192,7 +192,11 @@ async function checkIgnoredByWorkspaceGitignore(
|
||||
const child = spawn(
|
||||
"git",
|
||||
["check-ignore", "--stdin", "-z", "-v", "-n", "--no-index"],
|
||||
{ cwd: workspaceRoot, stdio: ["pipe", "pipe", "pipe"] },
|
||||
{
|
||||
cwd: workspaceRoot,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
windowsHide: true,
|
||||
},
|
||||
);
|
||||
|
||||
const stdout: Buffer[] = [];
|
||||
|
||||
@@ -69,6 +69,10 @@ function spawnAndCollect(
|
||||
env: { ...process.env, ...config.env },
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
detached: !isWindows,
|
||||
// Prevent a console window from flashing on Windows when the
|
||||
// parent process has no console (or a different console).
|
||||
// No-op on non-Windows platforms.
|
||||
windowsHide: true,
|
||||
});
|
||||
const childPid = child.pid;
|
||||
|
||||
|
||||
@@ -129,6 +129,8 @@ function checkRipgrepAvailable(): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn("rg", ["--version"], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
// Prevent a console window from flashing on Windows.
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
child.on("close", (code) => {
|
||||
@@ -168,6 +170,8 @@ function searchWithRipgrep(
|
||||
{
|
||||
cwd,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
// Prevent a console window from flashing on Windows.
|
||||
windowsHide: true,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -320,11 +320,17 @@ describe("createSpawnAgentTool", () => {
|
||||
},
|
||||
);
|
||||
|
||||
expect(agentConstructorSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
systemPrompt: inputSystemPrompt,
|
||||
}),
|
||||
const constructedConfig = agentConstructorSpy.mock.calls[0]?.[0] as {
|
||||
systemPrompt: string;
|
||||
};
|
||||
expect(constructedConfig.systemPrompt.startsWith(inputSystemPrompt)).toBe(
|
||||
true,
|
||||
);
|
||||
// The embedded workspace configuration is not injected a second time.
|
||||
const markerCount = constructedConfig.systemPrompt.split(
|
||||
"# Workspace Configuration",
|
||||
).length;
|
||||
expect(markerCount - 1).toBe(1);
|
||||
});
|
||||
|
||||
it("resolves connection settings lazily at execution time", async () => {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { DelegatedAgentRuntimeConfig } from "./delegated-agent";
|
||||
import {
|
||||
buildSubAgentSystemPrompt,
|
||||
buildTeammateSystemPrompt,
|
||||
} from "./subagent-prompts";
|
||||
|
||||
const PROFILE_BODY = "You are a reviewer. Focus on correctness.";
|
||||
|
||||
function makeConfig(
|
||||
overrides: Partial<DelegatedAgentRuntimeConfig> = {},
|
||||
): DelegatedAgentRuntimeConfig {
|
||||
return {
|
||||
providerId: "cline",
|
||||
modelId: "model",
|
||||
cwd: "/repo",
|
||||
apiKey: "key",
|
||||
clineIdeName: "Terminal",
|
||||
clinePlatform: "linux",
|
||||
workspaceMetadata: '{"workspaces":{}}',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildSubAgentSystemPrompt", () => {
|
||||
it("fills the persona slot and keeps the agent harness for cline", () => {
|
||||
const prompt = buildSubAgentSystemPrompt(PROFILE_BODY, makeConfig());
|
||||
expect(prompt.startsWith(PROFILE_BODY)).toBe(true);
|
||||
expect(prompt).toContain("Environment you are running in:");
|
||||
expect(prompt).toContain("4. Working Directory: /repo");
|
||||
expect(prompt).toContain(
|
||||
"IMPORTANT: Always includes tool calls in your response until the task is completed.",
|
||||
);
|
||||
expect(prompt).toContain("# Workspace Configuration");
|
||||
expect(prompt).not.toContain("You are Cline, an AI coding agent.");
|
||||
});
|
||||
|
||||
it("keeps the harness for non-cline providers without cline metadata", () => {
|
||||
const prompt = buildSubAgentSystemPrompt(
|
||||
PROFILE_BODY,
|
||||
makeConfig({ providerId: "openai" }),
|
||||
);
|
||||
expect(prompt.startsWith(PROFILE_BODY)).toBe(true);
|
||||
expect(prompt).toContain("Environment you are running in:");
|
||||
expect(prompt).toContain(
|
||||
"IMPORTANT: Always includes tool calls in your response until the task is completed.",
|
||||
);
|
||||
expect(prompt).not.toContain("# Workspace Configuration");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildTeammateSystemPrompt", () => {
|
||||
it("injects the role prompt as rules under the default persona for cline", () => {
|
||||
const prompt = buildTeammateSystemPrompt(PROFILE_BODY, makeConfig());
|
||||
expect(prompt).toContain("You are Cline, an AI coding agent.");
|
||||
expect(prompt).toContain(`# Team Teammate Role\n${PROFILE_BODY}`);
|
||||
});
|
||||
|
||||
it("returns the raw prompt for non-cline providers", () => {
|
||||
const prompt = buildTeammateSystemPrompt(
|
||||
PROFILE_BODY,
|
||||
makeConfig({ providerId: "openai" }),
|
||||
);
|
||||
expect(prompt).toBe(PROFILE_BODY);
|
||||
});
|
||||
});
|
||||
@@ -26,15 +26,13 @@ export function buildSubAgentSystemPrompt(
|
||||
config: DelegatedAgentRuntimeConfig,
|
||||
): string {
|
||||
const trimmedPrompt = prompt.trim();
|
||||
if (config.providerId.toLowerCase() !== "cline") {
|
||||
return trimmedPrompt;
|
||||
}
|
||||
|
||||
// The spawn prompt fills the persona slot; the provider-agnostic harness
|
||||
// (env block, tool-call loop contract) is kept for every provider.
|
||||
return buildClineSystemPrompt({
|
||||
ide: config.clineIdeName || "Terminal",
|
||||
workspaceRoot: config.cwd?.trim() || "/",
|
||||
providerId: config.providerId,
|
||||
overridePrompt: trimmedPrompt,
|
||||
personaPrompt: trimmedPrompt,
|
||||
metadata: config.workspaceMetadata,
|
||||
platform: config.clinePlatform,
|
||||
});
|
||||
|
||||
@@ -335,6 +335,9 @@ async function runHookCommandOnce(
|
||||
? ["pipe", "ignore", "ignore"]
|
||||
: ["pipe", "pipe", "pipe"],
|
||||
detached: options.detached,
|
||||
// Prevent a console window from flashing on Windows (especially when
|
||||
// detached, which would otherwise allocate a new console).
|
||||
windowsHide: true,
|
||||
});
|
||||
const spawned = new Promise<void>((resolve) => {
|
||||
child.once("spawn", () => resolve());
|
||||
|
||||
@@ -125,6 +125,9 @@ export async function runSubprocessEvent(
|
||||
env: withResolvedClineBuildEnv(options.env),
|
||||
stdio: detached ? ["pipe", "ignore", "ignore"] : ["pipe", "pipe", "pipe"],
|
||||
detached,
|
||||
// Prevent a console window from flashing on Windows (especially when
|
||||
// detached, which would otherwise allocate a new console).
|
||||
windowsHide: true,
|
||||
});
|
||||
const spawned = new Promise<void>((resolve) => {
|
||||
child.once("spawn", () => {
|
||||
|
||||
@@ -229,6 +229,9 @@ export function spawnDetachedHubServer(
|
||||
stdio: logFile ? ["ignore", logFile.fd, logFile.fd] : "ignore",
|
||||
env: command.env,
|
||||
cwd: command.cwd,
|
||||
// Prevent a console window from appearing on Windows; detached
|
||||
// processes otherwise allocate a new visible console.
|
||||
windowsHide: true,
|
||||
});
|
||||
child.unref();
|
||||
} finally {
|
||||
|
||||
@@ -2,8 +2,8 @@ import { EMPTY_CONTENT_TEXT } from "@cline/shared";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
agentMessageToMessageWithMetadata,
|
||||
messageToAgentMessages,
|
||||
messagesToAgentMessages,
|
||||
messageToAgentMessages,
|
||||
} from "./agent-message-codec";
|
||||
|
||||
describe("agent message codec", () => {
|
||||
|
||||
@@ -170,6 +170,8 @@ export class SubprocessSandbox {
|
||||
{
|
||||
stdio: ["ignore", "ignore", "pipe", "ipc"],
|
||||
env: withResolvedClineBuildEnv(process.env),
|
||||
// Prevent a console window from flashing on Windows.
|
||||
windowsHide: true,
|
||||
},
|
||||
);
|
||||
this.process = child;
|
||||
|
||||
@@ -85,6 +85,8 @@ async function listFilesWithRg(cwd: string): Promise<Set<string>> {
|
||||
const child = spawn("rg", ["--files", "--hidden", "-g", "!.git"], {
|
||||
cwd,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
// Prevent a console window from flashing on Windows.
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
const rootDir = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: [
|
||||
{
|
||||
find: /^@cline\/shared$/,
|
||||
replacement: resolve(rootDir, "../shared/src/index.ts"),
|
||||
},
|
||||
{
|
||||
find: /^@cline\/shared\/(.+)$/,
|
||||
replacement: resolve(rootDir, "../shared/src/$1"),
|
||||
},
|
||||
],
|
||||
},
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["src/**/*.test.ts"],
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildClineSystemPrompt } from "./cline";
|
||||
import { DEFAULT_CLINE_PERSONA } from "./system";
|
||||
|
||||
const PERSONA = "You are Reviewer, a meticulous code review agent.";
|
||||
|
||||
describe("buildClineSystemPrompt", () => {
|
||||
it("uses the default persona when no personaPrompt is provided", () => {
|
||||
const prompt = buildClineSystemPrompt({
|
||||
workspaceRoot: "/repo",
|
||||
platform: "linux",
|
||||
});
|
||||
expect(prompt).toContain("You are Cline, an AI coding agent.");
|
||||
expect(prompt).toContain("4. Working Directory: /repo");
|
||||
});
|
||||
|
||||
it("applies personaPrompt while keeping the harness", () => {
|
||||
const prompt = buildClineSystemPrompt({
|
||||
workspaceRoot: "/repo",
|
||||
platform: "linux",
|
||||
ide: "Terminal",
|
||||
personaPrompt: PERSONA,
|
||||
});
|
||||
expect(prompt.startsWith(PERSONA)).toBe(true);
|
||||
expect(prompt).toContain("1. Platform: linux");
|
||||
expect(prompt).toContain("4. Working Directory: /repo");
|
||||
expect(prompt).toContain(
|
||||
"IMPORTANT: Always includes tool calls in your response until the task is completed.",
|
||||
);
|
||||
expect(prompt).not.toContain(DEFAULT_CLINE_PERSONA);
|
||||
});
|
||||
|
||||
it("appends workspace metadata for the cline provider with a persona", () => {
|
||||
const prompt = buildClineSystemPrompt({
|
||||
workspaceRoot: "/repo",
|
||||
providerId: "cline",
|
||||
personaPrompt: PERSONA,
|
||||
metadata: '{"workspaces":{}}',
|
||||
});
|
||||
expect(prompt.startsWith(PERSONA)).toBe(true);
|
||||
expect(prompt).toContain("# Workspace Configuration");
|
||||
});
|
||||
|
||||
it("does not duplicate metadata when the persona already embeds it", () => {
|
||||
const personaWithMetadata = `${PERSONA}\n\n# Workspace Configuration\n{"workspaces":{}}`;
|
||||
const prompt = buildClineSystemPrompt({
|
||||
workspaceRoot: "/repo",
|
||||
providerId: "cline",
|
||||
personaPrompt: personaWithMetadata,
|
||||
metadata: '{"workspaces":{}}',
|
||||
});
|
||||
const markerCount = prompt.split("# Workspace Configuration").length - 1;
|
||||
expect(markerCount).toBe(1);
|
||||
});
|
||||
|
||||
it("omits workspace metadata for non-cline providers with a persona", () => {
|
||||
const prompt = buildClineSystemPrompt({
|
||||
workspaceRoot: "/repo",
|
||||
providerId: "openai",
|
||||
personaPrompt: PERSONA,
|
||||
metadata: '{"workspaces":{}}',
|
||||
});
|
||||
expect(prompt.startsWith(PERSONA)).toBe(true);
|
||||
expect(prompt).not.toContain("# Workspace Configuration");
|
||||
});
|
||||
|
||||
it("lets overridePrompt win over personaPrompt", () => {
|
||||
const prompt = buildClineSystemPrompt({
|
||||
workspaceRoot: "/repo",
|
||||
overridePrompt: "Full override.",
|
||||
personaPrompt: PERSONA,
|
||||
});
|
||||
expect(prompt).toBe("Full override.");
|
||||
});
|
||||
|
||||
it("ignores personaPrompt in yolo mode", () => {
|
||||
const prompt = buildClineSystemPrompt({
|
||||
workspaceRoot: "/repo",
|
||||
mode: "yolo",
|
||||
personaPrompt: PERSONA,
|
||||
});
|
||||
expect(prompt).toContain(
|
||||
"You are Cline, a careful and helpful coding agent that works in the background.",
|
||||
);
|
||||
expect(prompt).not.toContain(PERSONA);
|
||||
});
|
||||
|
||||
it("inserts rules containing replacement patterns literally", () => {
|
||||
const prompt = buildClineSystemPrompt({
|
||||
workspaceRoot: "/repo",
|
||||
rules: "Use $& and $' carefully.",
|
||||
});
|
||||
expect(prompt).toContain("Use $& and $' carefully.");
|
||||
});
|
||||
|
||||
it("keeps template-like tokens inside the persona literal", () => {
|
||||
const persona =
|
||||
"You report {{PLATFORM_NAME}} and honor {{CLINE_RULES}} verbatim.";
|
||||
const prompt = buildClineSystemPrompt({
|
||||
workspaceRoot: "/repo",
|
||||
platform: "linux",
|
||||
personaPrompt: persona,
|
||||
rules: "Real rules here.",
|
||||
});
|
||||
expect(prompt.startsWith(persona)).toBe(true);
|
||||
expect(prompt).toContain("1. Platform: linux");
|
||||
expect(prompt).toContain("Real rules here.");
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { WorkspaceContext } from "../extensions/context";
|
||||
import type { WorkspaceInfo } from "../session/workspace";
|
||||
import {
|
||||
DEFAULT_CLINE_SYSTEM_PROMPT,
|
||||
AGENT_PERSONA_SLOT,
|
||||
composeClineSystemPrompt,
|
||||
YOLO_CLINE_SYSTEM_PROMPT,
|
||||
} from "./system";
|
||||
|
||||
@@ -59,14 +60,20 @@ export interface ClineSystemPromptOptions
|
||||
extends Omit<WorkspaceContext, "rootPath"> {
|
||||
/**
|
||||
* Workspace root path. Accepts either `rootPath` (from WorkspaceContext/WorkspaceInfo)
|
||||
* or `workspaceRoot` (legacy alias) — whichever is provided will be used.
|
||||
* or `workspaceRoot` (legacy alias) - whichever is provided will be used.
|
||||
*/
|
||||
rootPath?: string;
|
||||
/** Alias for rootPath — kept for backwards compatibility with existing call sites */
|
||||
/** Alias for rootPath - kept for backwards compatibility with existing call sites */
|
||||
workspaceRoot?: string;
|
||||
/** Per-request system prompt override */
|
||||
overridePrompt?: string;
|
||||
/** Provider ID — used to gate Cline-specific metadata injection */
|
||||
/**
|
||||
* Agent-profile persona: replaces the default Cline persona (the identity
|
||||
* intro) while keeping the agent harness, including the working guidelines.
|
||||
* Ignored when `overridePrompt` is set or in yolo mode.
|
||||
*/
|
||||
personaPrompt?: string;
|
||||
/** Provider ID - used to gate Cline-specific metadata injection */
|
||||
providerId?: string;
|
||||
}
|
||||
|
||||
@@ -81,6 +88,7 @@ export function buildClineSystemPrompt(
|
||||
metadata,
|
||||
rules,
|
||||
overridePrompt,
|
||||
personaPrompt,
|
||||
providerId,
|
||||
} = options;
|
||||
const workspaceRoot = options.workspaceRoot ?? options.rootPath ?? "";
|
||||
@@ -98,20 +106,33 @@ export function buildClineSystemPrompt(
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
const persona = mode === "yolo" ? undefined : personaPrompt?.trim();
|
||||
// Keep the persona slot in place and fill it last, so `{{...}}` sequences
|
||||
// inside a persona body stay literal.
|
||||
const basePrompt =
|
||||
mode === "yolo" ? YOLO_CLINE_SYSTEM_PROMPT : DEFAULT_CLINE_SYSTEM_PROMPT;
|
||||
mode === "yolo"
|
||||
? YOLO_CLINE_SYSTEM_PROMPT
|
||||
: composeClineSystemPrompt(
|
||||
persona ? { persona: AGENT_PERSONA_SLOT } : {},
|
||||
);
|
||||
// Skip metadata injection when the persona already embeds a workspace
|
||||
// configuration block (e.g. spawn prompts composed by a parent agent).
|
||||
const includeMetadata =
|
||||
isCline && !persona?.includes(WORKSPACE_CONFIGURATION_MARKER);
|
||||
|
||||
// Replacer functions (not replacement strings) so values containing
|
||||
// `$&`-style patterns are inserted literally.
|
||||
return basePrompt
|
||||
.replace("{{PLATFORM_NAME}}", platform)
|
||||
.replace("{{CWD}}", workspaceRoot)
|
||||
.replace("{{CURRENT_DATE}}", new Date().toLocaleDateString())
|
||||
.replace("{{IDE_NAME}}", ide)
|
||||
.replace(
|
||||
"{{CLINE_METADATA}}",
|
||||
isCline
|
||||
.replace("{{PLATFORM_NAME}}", () => platform)
|
||||
.replace("{{CWD}}", () => workspaceRoot)
|
||||
.replace("{{CURRENT_DATE}}", () => new Date().toLocaleDateString())
|
||||
.replace("{{IDE_NAME}}", () => ide)
|
||||
.replace("{{CLINE_METADATA}}", () =>
|
||||
includeMetadata
|
||||
? buildWorkspaceMetadata(workspaceRoot, workspaceName, metadata)
|
||||
: "",
|
||||
)
|
||||
.replace("{{CLINE_RULES}}", rules || "")
|
||||
.replace("{{CLINE_RULES}}", () => rules || "")
|
||||
.replace(AGENT_PERSONA_SLOT, () => persona ?? "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
composeClineSystemPrompt,
|
||||
DEFAULT_CLINE_PERSONA,
|
||||
DEFAULT_CLINE_SYSTEM_PROMPT,
|
||||
} from "./system";
|
||||
|
||||
// The canonical default system prompt. Pinned as a literal (including trailing
|
||||
// whitespace) so accidental drift is caught; update deliberately when the
|
||||
// default prompt is intentionally changed.
|
||||
const EXPECTED_DEFAULT_CLINE_SYSTEM_PROMPT = `You are Cline, an AI coding agent. Your primary goal is to assist users with various coding tasks by leveraging your knowledge and the tools at your disposal. Given the user's prompt, you should use the tools available to you to answer user's question.
|
||||
|
||||
Always gather all the necessary context before starting to work on a task. For example, if you are generating a unit test or new code, make sure you understand the requirement, the naming conventions, frameworks and libraries used and aligned in the current codebase, and the environment and commands used to run and test the code etc. Always validate the new unit test at the end including running the code if possible for live feedback.
|
||||
Review each question carefully and answer it with detailed, accurate information.
|
||||
|
||||
Environment you are running in:
|
||||
<env>
|
||||
1. Platform: {{PLATFORM_NAME}}
|
||||
2. Date: {{CURRENT_DATE}}
|
||||
3. IDE: {{IDE_NAME}}
|
||||
4. Working Directory: {{CWD}}
|
||||
</env>
|
||||
|
||||
Remember:
|
||||
- If you need more information, use one of the available tools or ask for clarification instead of making assumptions or lies.
|
||||
- Always adhere to existing code conventions and patterns.
|
||||
- Use only libraries and frameworks that are confirmed to be in use in the current codebase.
|
||||
- Provide complete and functional code without omissions or placeholders.
|
||||
- Be explicit about any assumptions or limitations in your solution.
|
||||
- Always show your planning process before executing any task. This will help ensure that you have a clear understanding of the requirements and that your approach aligns with the user's needs.
|
||||
- Always use absolute paths when referring to files.
|
||||
- Always verify the files you have edited or created at the end of the task to ensure they are completed and working as expected.
|
||||
|
||||
Begin by analyzing the user's input and gathering any necessary additional context. Then, present your plan at the start of your response along with tool calls before proceeding with the task. It's OK for this section to be quite long.
|
||||
|
||||
REMEMBER, be helpful and proactive! Don't ask for permission to do something when you can do it! Do not indicates you will be using a tool unless you are actually going to use it.
|
||||
|
||||
IMPORTANT: Always includes tool calls in your response until the task is completed. Response without tool calls will considered as completed with final answer.
|
||||
|
||||
When you have completed the task, please provide a summary of what you did and any relevant information that the user should know. This will help ensure that the user understands the changes made and can easily follow up if they have any questions or need further assistance. Do not indicate that you will perform an action without actually doing it. Always provide the final result in your response. Always validate your answer with checking the code and running it if possible.${" "}
|
||||
|
||||
If user asked a simple question without any coding context, answer it directly without using any tools.
|
||||
{{CLINE_RULES}}
|
||||
{{CLINE_METADATA}}`;
|
||||
|
||||
// Everything after the persona is the always-on harness (env block, working
|
||||
// guidelines, tool-call contract, completion rules, rules/metadata). Derived
|
||||
// from the default so the harness text has a single source of truth.
|
||||
const HARNESS = EXPECTED_DEFAULT_CLINE_SYSTEM_PROMPT.slice(
|
||||
DEFAULT_CLINE_PERSONA.length,
|
||||
);
|
||||
|
||||
describe("composeClineSystemPrompt", () => {
|
||||
it("composes the canonical default prompt", () => {
|
||||
expect(DEFAULT_CLINE_SYSTEM_PROMPT).toBe(
|
||||
EXPECTED_DEFAULT_CLINE_SYSTEM_PROMPT,
|
||||
);
|
||||
expect(composeClineSystemPrompt()).toBe(
|
||||
EXPECTED_DEFAULT_CLINE_SYSTEM_PROMPT,
|
||||
);
|
||||
expect(composeClineSystemPrompt({})).toBe(
|
||||
EXPECTED_DEFAULT_CLINE_SYSTEM_PROMPT,
|
||||
);
|
||||
// The exported persona is the real prefix; the rest is the harness.
|
||||
expect(DEFAULT_CLINE_SYSTEM_PROMPT).toBe(DEFAULT_CLINE_PERSONA + HARNESS);
|
||||
});
|
||||
|
||||
it("treats a blank persona as the default", () => {
|
||||
expect(composeClineSystemPrompt({ persona: " " })).toBe(
|
||||
EXPECTED_DEFAULT_CLINE_SYSTEM_PROMPT,
|
||||
);
|
||||
});
|
||||
|
||||
it("swaps the persona but keeps the harness verbatim", () => {
|
||||
const persona =
|
||||
"You are Reviewer, a meticulous code review agent. Focus on correctness.";
|
||||
// Only the persona changes; the entire harness tail is byte-identical.
|
||||
expect(composeClineSystemPrompt({ persona })).toBe(persona + HARNESS);
|
||||
});
|
||||
|
||||
it("keeps the working guidelines, incl. the no-guessing norm, in the harness", () => {
|
||||
const norm =
|
||||
"If you need more information, use one of the available tools or ask for clarification instead of making assumptions or lies.";
|
||||
// The norm and the rest of the working guidelines are harness, not
|
||||
// persona, so a profile keeps them while the identity is swapped.
|
||||
expect(HARNESS).toContain(norm);
|
||||
expect(DEFAULT_CLINE_PERSONA).not.toContain(norm);
|
||||
});
|
||||
|
||||
it("inserts persona content literally, including replacement patterns", () => {
|
||||
const persona = "Echo the captured group $& and $' verbatim.";
|
||||
expect(composeClineSystemPrompt({ persona })).toBe(persona + HARNESS);
|
||||
});
|
||||
|
||||
it("keeps template-like tokens inside the persona literal", () => {
|
||||
const persona = "Mention {{AGENT_GUIDELINES}} and {{AGENT_PERSONA}} as-is.";
|
||||
expect(composeClineSystemPrompt({ persona })).toBe(persona + HARNESS);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,15 @@
|
||||
export const DEFAULT_CLINE_SYSTEM_PROMPT = `You are Cline, an AI coding agent. Your primary goal is to assist users with various coding tasks by leveraging your knowledge and the tools at your disposal. Given the user's prompt, you should use the tools available to you to answer user's question.
|
||||
export const DEFAULT_CLINE_PERSONA = `You are Cline, an AI coding agent. Your primary goal is to assist users with various coding tasks by leveraging your knowledge and the tools at your disposal. Given the user's prompt, you should use the tools available to you to answer user's question.
|
||||
|
||||
Always gather all the necessary context before starting to work on a task. For example, if you are generating a unit test or new code, make sure you understand the requirement, the naming conventions, frameworks and libraries used and aligned in the current codebase, and the environment and commands used to run and test the code etc. Always validate the new unit test at the end including running the code if possible for live feedback.
|
||||
Review each question carefully and answer it with detailed, accurate information.
|
||||
If you need more information, use one of the available tools or ask for clarification instead of making assumptions or lies.
|
||||
Review each question carefully and answer it with detailed, accurate information.`;
|
||||
|
||||
// The agent harness: env block, working guidelines (including the no-guessing
|
||||
// norm: use tools or ask instead of fabricating), tool-call loop contract,
|
||||
// completion instructions, and rules/metadata placeholders. Only the
|
||||
// {{AGENT_PERSONA}} slot holds the coding-agent identity and workflow
|
||||
// prompting; an agent profile body replaces that slot while the rest of the
|
||||
// harness is always preserved.
|
||||
const CLINE_SYSTEM_PROMPT_TEMPLATE = `{{AGENT_PERSONA}}
|
||||
|
||||
Environment you are running in:
|
||||
<env>
|
||||
@@ -13,6 +20,7 @@ Environment you are running in:
|
||||
</env>
|
||||
|
||||
Remember:
|
||||
- If you need more information, use one of the available tools or ask for clarification instead of making assumptions or lies.
|
||||
- Always adhere to existing code conventions and patterns.
|
||||
- Use only libraries and frameworks that are confirmed to be in use in the current codebase.
|
||||
- Provide complete and functional code without omissions or placeholders.
|
||||
@@ -33,6 +41,33 @@ If user asked a simple question without any coding context, answer it directly w
|
||||
{{CLINE_RULES}}
|
||||
{{CLINE_METADATA}}`;
|
||||
|
||||
/** The persona placeholder of the harness template. */
|
||||
export const AGENT_PERSONA_SLOT = "{{AGENT_PERSONA}}";
|
||||
|
||||
export interface ComposeClineSystemPromptInput {
|
||||
/**
|
||||
* Replaces the default Cline persona (the identity intro) while keeping the
|
||||
* agent harness. The working guidelines are part of the harness and are
|
||||
* always retained, even when a custom persona (e.g. an agent profile body)
|
||||
* is supplied.
|
||||
*/
|
||||
persona?: string;
|
||||
}
|
||||
|
||||
export function composeClineSystemPrompt(
|
||||
input: ComposeClineSystemPromptInput = {},
|
||||
): string {
|
||||
const persona = input.persona?.trim();
|
||||
// The persona is inserted via a replacer function so `{{...}}` and
|
||||
// `$&`-style sequences inside it stay literal.
|
||||
return CLINE_SYSTEM_PROMPT_TEMPLATE.replace(
|
||||
AGENT_PERSONA_SLOT,
|
||||
() => persona || DEFAULT_CLINE_PERSONA,
|
||||
);
|
||||
}
|
||||
|
||||
export const DEFAULT_CLINE_SYSTEM_PROMPT = composeClineSystemPrompt();
|
||||
|
||||
export const YOLO_CLINE_SYSTEM_PROMPT = `You are Cline, a careful and helpful coding agent that works in the background.
|
||||
You are tasked to solve an issue reported by the user who you cannot communicate with directly.
|
||||
Your goal is to utilize the tools at your disposal to investigate and answer the question according to user's instructions with the aim to verify that the issue is resolved.
|
||||
|
||||
Reference in New Issue
Block a user