This commit is contained in:
Renee Huang
2026-05-19 16:41:40 -07:00
44 changed files with 1192 additions and 206 deletions
+10
View File
@@ -60,3 +60,13 @@ tests/**/cache
# Backup created by scripts/marketplace-readme.mjs while publishing.
# Should never be committed: only exists if a publish aborts mid-swap.
.README.github.bak
# SDK Session files / User data
.cline/data
.cline/tmp
*.db
*.db-shm
*.db-wal
.cline/**/managed.json
.cline/**/bundle.json
+14
View File
@@ -1,5 +1,19 @@
# Changelog
## [3.84.0]
### Added
- Add SAP AI Core support for additional hosted models
### Fixed
- Disable the MCP "Restart Server" button when a server is toggled off.
### Changed
- Remove the Cline Kanban launch modal and bundled demo media from the VS Code extension startup flow.
## [3.83.0]
### Fixed
+6 -44
View File
@@ -1,12 +1,12 @@
{
"name": "claude-dev",
"version": "3.83.0",
"version": "3.84.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "claude-dev",
"version": "3.83.0",
"version": "3.84.0",
"license": "Apache-2.0",
"workspaces": [
"."
@@ -61,7 +61,7 @@
"chrome-launcher": "^1.1.2",
"clone-deep": "^4.0.1",
"default-shell": "^2.2.0",
"diff": "^5.2.0",
"diff": "8.0.4",
"exceljs": "^4.4.0",
"execa": "^9.5.2",
"fast-deep-equal": "^3.1.3",
@@ -115,7 +115,6 @@
"@types/better-sqlite3": "^7.6.13",
"@types/chai": "^5.0.1",
"@types/clone-deep": "^4.0.4",
"@types/diff": "^5.2.1",
"@types/get-folder-size": "^3.0.4",
"@types/js-yaml": "^4.0.9",
"@types/mocha": "^10.0.7",
@@ -7206,13 +7205,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/diff": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/diff/-/diff-5.2.3.tgz",
"integrity": "sha512-K0Oqlrq3kQMaO2RhfrNQX5trmt+XLyom88zS0u84nnIcLvFnRUMRRHmrGny5GSM+kNO9IZLARsdQHDzkhAgmrQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -10155,9 +10147,9 @@
"license": "BSD-3-Clause"
},
"node_modules/diff": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/diff/-/diff-5.2.2.tgz",
"integrity": "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==",
"version": "8.0.4",
"resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz",
"integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.3.1"
@@ -15376,16 +15368,6 @@
"balanced-match": "^1.0.0"
}
},
"node_modules/mocha/node_modules/diff": {
"version": "8.0.4",
"resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz",
"integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.3.1"
}
},
"node_modules/mocha/node_modules/find-up": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
@@ -18769,16 +18751,6 @@
"url": "https://opencollective.com/sinon"
}
},
"node_modules/sinon/node_modules/diff": {
"version": "8.0.4",
"resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz",
"integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.3.1"
}
},
"node_modules/sinon/node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@@ -19878,16 +19850,6 @@
}
}
},
"node_modules/ts-node/node_modules/diff": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz",
"integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.3.1"
}
},
"node_modules/ts-poet": {
"version": "6.12.0",
"resolved": "https://registry.npmjs.org/ts-poet/-/ts-poet-6.12.0.tgz",
+3 -6
View File
@@ -2,7 +2,7 @@
"name": "claude-dev",
"displayName": "Cline",
"description": "Autonomous coding agent right in your IDE, capable of creating/editing files, running commands, using the browser, and more with your permission every step of the way.",
"version": "3.83.0",
"version": "3.84.0",
"icon": "assets/icons/icon.png",
"workspaces": [
"."
@@ -449,7 +449,6 @@
"@types/better-sqlite3": "^7.6.13",
"@types/chai": "^5.0.1",
"@types/clone-deep": "^4.0.4",
"@types/diff": "^5.2.1",
"@types/get-folder-size": "^3.0.4",
"@types/js-yaml": "^4.0.9",
"@types/mocha": "^10.0.7",
@@ -539,7 +538,7 @@
"chrome-launcher": "^1.1.2",
"clone-deep": "^4.0.1",
"default-shell": "^2.2.0",
"diff": "^5.2.0",
"diff": "8.0.4",
"exceljs": "^4.4.0",
"execa": "^9.5.2",
"fast-deep-equal": "^3.1.3",
@@ -593,9 +592,7 @@
"js-yaml": "^4.1.1",
"serialize-javascript": ">=7.0.3",
"protobufjs": "7.5.5",
"mocha": {
"diff": ">=8.0.3"
}
"diff": "8.0.4"
},
"c8": {
"reporter": [
+8
View File
@@ -1,5 +1,13 @@
# Cline CLI Changelog
## 3.0.8
- Use Telegram numeric participant ids so renamed users stay linked to the same participant in the Telegram connector.
- Keep failed plugins visible in the config UI with their load/setup phase and error details so broken plugin definitions are easier to diagnose.
- Move the Create Session Fork shortcut from Opt+F to Opt+R so terminal word-right navigation works again.
- Fix AWS Bedrock region and profile detection in the CLI onboarding, and surface bearer-token and additional Bedrock config fields in the provider config screens.
- Fix inflated token usage counts caused by AgentRuntime.execute() not resetting usage between calls, which the local runtime host was then double-counting on top of the session baseline.
## 3.0.7
- Skip the ChatGPT OAuth model refresh on session startup so the CLI launches without the extra network round-trip.
+7 -6
View File
@@ -1,7 +1,7 @@
{
"name": "@cline/cli",
"displayName": "cline",
"version": "3.0.7",
"version": "3.0.8",
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
"type": "module",
"publishConfig": {
@@ -68,26 +68,27 @@
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.16.1",
"@clack/prompts": "^1.2.0",
"@chat-adapter/discord": "^4.23.0",
"@chat-adapter/gchat": "^4.23.0",
"@chat-adapter/linear": "^4.23.0",
"@chat-adapter/slack": "^4.23.0",
"@chat-adapter/telegram": "^4.23.0",
"@chat-adapter/whatsapp": "^4.23.0",
"@clack/prompts": "^1.2.0",
"@gramio/format": "^0.7.0",
"chat": "^4.23.0",
"commander": "^14.0.3",
"@opentui-ui/dialog": "^0.1.2",
"@opentui/core": "0.1.102",
"@opentui/react": "0.1.102",
"@opentui-ui/dialog": "^0.1.2",
"chat": "^4.23.0",
"commander": "^14.0.3",
"fzf": "^0.5.2",
"marked": "^15.0.12",
"open": "^10.2.0",
"opentui-spinner": "^0.0.6",
"pino": "^10.3.1",
"react": "19.2.4",
"react-reconciler": "0.32.0",
"react-devtools-core": "^7.0.1",
"react-reconciler": "0.32.0",
"yaml": "^2.8.2",
"zod": "^4.1.11"
},
+1 -1
View File
@@ -232,7 +232,7 @@ export function commanderToParsedArgs(program: Command): ParsedArgs {
if (opts.id !== undefined) result.id = opts.id;
// Positional args → prompt
const positional = program.args.filter((a) => !a.startsWith("-"));
const positional = program.args;
if (positional.length > 0) {
result.prompt = positional.join(" ");
}
@@ -38,6 +38,53 @@ describe("telegramConnector", () => {
});
});
describe("telegram participant resolution", () => {
it("uses the stable numeric Telegram user id when username is also present", () => {
const result = __test__.resolveTelegramParticipant({
message: {
from: {
id: 1201547643,
username: "AraFatKatze",
first_name: "Ara",
},
},
});
expect(result).toEqual({
key: "telegram:id:1201547643",
label: "arafatkatze",
});
});
it("falls back to username when Telegram does not provide a numeric user id", () => {
const result = __test__.resolveTelegramParticipant({
message: {
from: {
username: "Alice",
},
},
});
expect(result).toEqual({
key: "telegram:user:alice",
label: "alice",
});
});
it("accepts string numeric user ids from raw Telegram payloads", () => {
const result = __test__.resolveTelegramParticipant({
message: {
from: {
id: "1201547643",
username: "arafatkatze",
},
},
});
expect(result?.key).toBe("telegram:id:1201547643");
});
});
describe("telegram binding lookup", () => {
it("falls back to channel identity when a restarted connector gets a new thread id", () => {
const result = __test__.findBindingForThread(
@@ -135,12 +135,12 @@ function resolveTelegramParticipant(
const lastName = readString(from?.last_name);
const label =
username || [firstName, lastName].filter(Boolean).join(" ") || userId;
if (username) {
return { key: `telegram:user:${username}`, label };
}
if (userId) {
return { key: `telegram:id:${userId}`, label };
}
if (username) {
return { key: `telegram:user:${username}`, label };
}
return undefined;
}
@@ -957,6 +957,7 @@ export const telegramConnector: ConnectCommandDefinition =
new TelegramConnector();
export const __test__ = {
resolveTelegramParticipant,
findBindingForThread: (
bindings: ConnectorBindingStore<TelegramThreadState>,
thread: Pick<Thread<TelegramThreadState>, "id" | "channelId" | "isDM"> & {
+21
View File
@@ -657,6 +657,27 @@ describe("runCli lightweight command dispatch", () => {
expect(runtimeMocks.runInteractive).not.toHaveBeenCalled();
});
it("treats dash-prefixed positional text after -- as a prompt", async () => {
forcePromptModeInput();
process.argv = [
"bun",
"src/index.ts",
"--",
"- You are given a PyTorch state dictionary.",
];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(runtimeMocks.runAgent).toHaveBeenCalledTimes(1);
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
"- You are given a PyTorch state dictionary.",
expect.any(Object),
expect.anything(),
);
expect(runtimeMocks.runInteractive).not.toHaveBeenCalled();
});
it("applies --auto-approve as a runtime policy without changing the config default", async () => {
process.argv = ["bun", "src/index.ts", "--auto-approve", "false"];
@@ -3,7 +3,10 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import type { UserInstructionConfigService } from "@cline/core";
import { afterEach, describe, expect, it } from "vitest";
import type { InteractiveConfigItem } from "../../tui/interactive-config";
import {
applyPluginFailures,
type InteractiveConfigItem,
} from "../../tui/interactive-config";
import type { Config } from "../../utils/types";
import { createInteractiveConfigDataLoader } from "./config-data";
@@ -206,6 +209,82 @@ Use this skill.`,
).toBe(true);
});
it("keeps failed plugins visible with their load error", async () => {
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-data-"));
tempRoots.push(tempRoot);
process.env.CLINE_GLOBAL_SETTINGS_PATH = join(
tempRoot,
"global-settings.json",
);
const pluginsDir = join(tempRoot, ".cline", "plugins");
await mkdir(pluginsDir, { recursive: true });
const pluginPath = join(pluginsDir, "broken-plugin.js");
const invalidPluginPath = join(pluginsDir, "invalid-plugin.js");
await writeFile(
pluginPath,
[
"export default {",
" name: 'broken-plugin',",
" manifest: { capabilities: ['tools'] },",
" setup() {",
" throw new Error('setup exploded');",
" },",
"};",
].join("\n"),
);
await writeFile(invalidPluginPath, "export default {};\n", "utf8");
const loader = createInteractiveConfigDataLoader({
config: createConfig(tempRoot),
});
const data = await loader.loadConfigData({ includePluginTools: true });
const plugin = data.plugins.find((item) => item.path === pluginPath);
expect(plugin?.name).toBe("broken-plugin");
expect(plugin?.loadErrorPhase).toBe("setup");
expect(plugin?.loadError).toContain("setup failed: setup exploded");
const invalidPlugin = data.plugins.find(
(item) => item.path === invalidPluginPath,
);
expect(invalidPlugin?.name).toBe("invalid-plugin");
expect(invalidPlugin?.loadErrorPhase).toBe("load");
expect(invalidPlugin?.loadError).toContain("load failed:");
});
it("preserves multiple load failures for the same plugin path", () => {
const plugin: InteractiveConfigItem = {
id: "/tmp/plugin.js",
name: "plugin",
path: "/tmp/plugin.js",
enabled: true,
kind: "plugin",
source: "workspace-plugin",
};
applyPluginFailures(
[plugin],
[
{
pluginPath: "/tmp/plugin.js",
pluginName: "plugin",
phase: "setup",
message: "first failure",
},
{
pluginPath: "/tmp/plugin.js",
phase: "setup",
message: "second failure",
},
],
);
expect(plugin.loadError).toBe(
"setup failed: first failure\nsetup failed: second failure",
);
expect(plugin.loadErrorPhase).toBeUndefined();
});
it("toggles every SDK tool name for a displayed built-in tool", async () => {
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-data-"));
tempRoots.push(tempRoot);
@@ -3,7 +3,7 @@ import { isToggleableInteractiveConfigItem } from "../../interactive-config";
export type ExtDetailRow =
| { kind: "header"; name: string; source: string }
| { kind: "field"; label: "Path" | "Description"; value: string[] }
| { kind: "field"; label: "Path" | "Description" | "Error"; value: string[] }
| { kind: "status"; enabled: boolean };
const TOGGLE_FOOTER = "Space toggle status, Tab/Enter/Esc to go back";
@@ -77,6 +77,13 @@ export function getExtDetailRows(item: InteractiveConfigItem): ExtDetailRow[] {
value: truncateDescription(item.description),
});
}
if (item.loadError) {
rows.push({
kind: "field",
label: "Error",
value: truncateDescription(item.loadError),
});
}
if (
typeof item.enabled === "boolean" &&
isToggleableInteractiveConfigItem(item)
@@ -198,13 +198,8 @@ export function useAutocomplete(opts: {
);
const getFilteredMentionOptions = useCallback(
(query: string): AutocompleteOption[] => {
const q = query.toLowerCase();
let filtered = mentionResults;
if (q) {
filtered = mentionResults.filter((f) => f.toLowerCase().includes(q));
}
return filtered.slice(0, MAX_COMPLETION_RESULTS).map((f) => ({
(_query: string): AutocompleteOption[] => {
return mentionResults.slice(0, MAX_COMPLETION_RESULTS).map((f) => ({
display: f,
value: formatMentionAutocompleteValue(f),
}));
@@ -233,6 +228,7 @@ export function useAutocomplete(opts: {
if (searchTimerRef.current) {
clearTimeout(searchTimerRef.current);
}
setMentionResults([]);
const counter = ++searchCounterRef.current;
searchTimerRef.current = setTimeout(() => {
searchWorkspaceFilesForMention({
+38 -3
View File
@@ -5,7 +5,8 @@ import {
discoverPluginModulePaths,
hasMcpSettingsFile,
listHookConfigFiles,
listPluginTools,
listPluginToolsWithDiagnostics,
type PluginInitializationFailure,
type RuleConfig,
readGlobalSettings,
resolveAgentConfigSearchPaths,
@@ -49,6 +50,8 @@ export interface InteractiveConfigItem {
toolNames?: string[];
configKind?: "tool" | "plugin";
pluginName?: string;
loadError?: string;
loadErrorPhase?: PluginInitializationFailure["phase"];
source:
| "global"
| "workspace"
@@ -216,6 +219,36 @@ function getPluginDisplayName(filePath: string): string {
return basename(filePath, extname(filePath));
}
function formatPluginFailure(failure: PluginInitializationFailure): string {
return `${failure.phase === "setup" ? "setup failed" : "load failed"}: ${failure.message}`;
}
export function applyPluginFailures(
plugins: InteractiveConfigItem[],
failures: readonly PluginInitializationFailure[],
): void {
const pluginsByPath = new Map(plugins.map((plugin) => [plugin.path, plugin]));
const failuresByPath = new Map<string, PluginInitializationFailure[]>();
for (const failure of failures) {
const failuresForPath = failuresByPath.get(failure.pluginPath) ?? [];
failuresForPath.push(failure);
failuresByPath.set(failure.pluginPath, failuresForPath);
}
for (const [pluginPath, failuresForPath] of failuresByPath) {
const plugin = pluginsByPath.get(pluginPath);
if (!plugin) {
continue;
}
const namedFailure = failuresForPath.find((failure) => failure.pluginName);
if (namedFailure?.pluginName) {
plugin.name = namedFailure.pluginName;
}
plugin.loadError = failuresForPath.map(formatPluginFailure).join("\n");
plugin.loadErrorPhase =
failuresForPath.length === 1 ? failuresForPath[0]?.phase : undefined;
}
}
export async function loadInteractiveConfigData(input: {
userInstructionService?: UserInstructionConfigService;
cwd: string;
@@ -356,12 +389,14 @@ export async function loadInteractiveConfigData(input: {
);
if (input.includePluginTools !== false) {
try {
for (const pluginTool of await listPluginTools({
const pluginToolResult = await listPluginToolsWithDiagnostics({
workspacePath: input.workspaceRoot,
cwd: input.cwd,
providerId: input.availabilityContext?.providerId,
modelId: input.availabilityContext?.modelId,
})) {
});
applyPluginFailures(plugins, pluginToolResult.failures);
for (const pluginTool of pluginToolResult.tools) {
tools.push({
id: `${pluginTool.pluginName}:${pluginTool.name}:${pluginTool.path}`,
name: pluginTool.name,
@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import { rankMentionPaths } from "./interactive-welcome";
describe("TUI file mention search ranking", () => {
it("keeps initialism matches for compact and hyphenated input", () => {
const paths = [
"src/components/Button.tsx",
"src/domain/MyAmazingClassDefinition.ts",
"docs/MACD.md",
"packages/core/src/runtime/manager.ts",
];
expect(rankMentionPaths(paths, "MACD", 10)).toEqual([
"docs/MACD.md",
"src/domain/MyAmazingClassDefinition.ts",
]);
expect(rankMentionPaths(paths, "M-A-C-D", 10)).toEqual([
"docs/MACD.md",
"src/domain/MyAmazingClassDefinition.ts",
]);
});
it("normalizes common mention prefixes before matching workspace paths", () => {
const paths = [
"docs/architecture.md",
"src/tui/interactive-welcome.ts",
"src/tui/hooks/use-autocomplete.ts",
];
expect(rankMentionPaths(paths, "./src/tui", 10)).toEqual([
"src/tui/hooks/use-autocomplete.ts",
"src/tui/interactive-welcome.ts",
]);
expect(rankMentionPaths(paths, "/docs", 10)).toEqual([
"docs/architecture.md",
]);
});
it("ranks filename matches ahead of path-only fuzzy matches", () => {
const paths = [
"src/migrations/add-column.ts",
"src/domain/MyAmazingClassDefinition.ts",
"docs/classes.md",
];
expect(rankMentionPaths(paths, "class", 10)[0]).toBe("docs/classes.md");
});
});
+85 -25
View File
@@ -3,6 +3,7 @@ import {
type ProviderSettings,
type UserInstructionConfigService,
} from "@cline/core";
import { byLengthAsc, Fzf, type FzfResultItem } from "fzf";
import type { Config } from "../utils/types";
import { formatClineCredits, loadClineAccountSnapshot } from "./cline-account";
@@ -17,24 +18,95 @@ function normalizeLimit(limit: number | undefined): number {
if (typeof limit !== "number" || Number.isNaN(limit)) {
return 10;
}
return Math.min(50, Math.max(1, Math.trunc(limit)));
return Math.min(200, Math.max(1, Math.trunc(limit)));
}
function rankPath(path: string, query: string): number {
if (query.length === 0) {
return 3;
function getPathLabel(filePath: string): string {
const parts = filePath.split("/");
return parts[parts.length - 1] ?? filePath;
}
function normalizeMentionQuery(query: string): string {
const trimmed = query.trim().replace(/^["']/, "");
if (trimmed.startsWith("./")) {
return trimmed.slice(2);
}
const lowerPath = path.toLowerCase();
if (lowerPath.startsWith(query)) {
return 0;
if (trimmed.startsWith("/")) {
return trimmed.slice(1);
}
if (lowerPath.includes(`/${query}`)) {
return 1;
return trimmed;
}
function compactSearchText(text: string): string {
return text.toLowerCase().replace(/[^a-z0-9]/g, "");
}
interface MentionPathItem {
path: string;
label: string;
searchText: string;
}
function countGaps(positions: Iterable<number>): number {
const sortedPositions = Array.from(positions).sort((a, b) => a - b);
let gaps = 0;
let previous = Number.NEGATIVE_INFINITY;
for (const position of sortedPositions) {
if (previous !== Number.NEGATIVE_INFINITY && position - previous > 1) {
gaps++;
}
previous = position;
}
if (lowerPath.includes(query)) {
return 2;
return gaps;
}
function orderByMatchScore(
left: FzfResultItem<MentionPathItem>,
right: FzfResultItem<MentionPathItem>,
): number {
return countGaps(left.positions) - countGaps(right.positions);
}
export function rankMentionPaths(
paths: Iterable<string>,
query: string,
limit: number,
): string[] {
const items = Array.from(paths, (path): MentionPathItem => {
const label = getPathLabel(path);
return {
path,
label,
searchText: [
label,
label,
path,
compactSearchText(label),
compactSearchText(path),
].join(" "),
};
});
const normalizedQuery = normalizeMentionQuery(query);
if (!normalizedQuery) {
return items
.sort((left, right) => left.path.localeCompare(right.path))
.slice(0, limit)
.map((item) => item.path);
}
return Number.POSITIVE_INFINITY;
const fzf = new Fzf(items, {
selector: (item) => item.searchText,
tiebreakers: [orderByMatchScore, byLengthAsc],
limit,
});
const rawResults = fzf.find(normalizedQuery);
const results =
rawResults.length > 0
? rawResults
: fzf.find(compactSearchText(normalizedQuery));
return results.map((result) => result.item.path);
}
export function listInteractiveSlashCommands(
@@ -90,21 +162,9 @@ export async function searchWorkspaceFilesForMention(input: {
if (!workspaceRoot) {
return [];
}
const query = input.query.trim().toLowerCase();
const limit = normalizeLimit(input.limit);
const index = await getFileIndex(workspaceRoot);
const allPaths = Array.from(index).sort((a, b) => a.localeCompare(b));
return allPaths
.map((path) => ({ path, rank: rankPath(path, query) }))
.filter((item) => Number.isFinite(item.rank))
.sort((left, right) => {
if (left.rank !== right.rank) {
return left.rank - right.rank;
}
return left.path.localeCompare(right.path);
})
.slice(0, limit)
.map((item) => item.path);
return rankMentionPaths(index, input.query, limit);
}
export async function resolveClineWelcomeLine(input: {
+29 -4
View File
@@ -235,6 +235,17 @@ function appendToolRows(
}
}
function getPluginLoadErrorLabel(
item: InteractiveConfigItem,
): string | undefined {
if (!item.loadError) {
return undefined;
}
const lines = item.loadError.split("\n");
const first = lines[0] ?? item.loadError;
return lines.length > 1 ? `${first} (+${lines.length - 1} more)` : first;
}
export function ConfigPanelContent(props: ConfigPanelProps) {
const { resolve, dismiss, dialogId, config, loadConfigData } = props;
const { height } = useTerminalDimensions();
@@ -264,7 +275,7 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
useEffect(() => {
if (
activeTab !== "tools" ||
(activeTab !== "tools" && activeTab !== "plugins") ||
pluginToolsLoaded ||
pluginToolsError ||
!loadConfigData
@@ -288,7 +299,7 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
return;
}
const message = error instanceof Error ? error.message : String(error);
setPluginToolsError(`Failed to load plugin tools: ${message}`);
setPluginToolsError(`Failed to load plugin diagnostics: ${message}`);
})
.finally(() => {
if (!cancelled) {
@@ -351,6 +362,19 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
enabled: item.enabled,
description: item.description,
item,
rightLabel: getPluginLoadErrorLabel(item),
});
}
if (activeTab === "plugins" && pluginToolsLoading) {
r.push({
kind: "detail",
text: "Loading plugin diagnostics...",
});
}
if (activeTab === "plugins" && pluginToolsError) {
r.push({
kind: "detail",
text: pluginToolsError,
});
}
}
@@ -635,8 +659,9 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
const rightLabel = row.rightLabel ?? "";
const toggleable = isToggleableConfigItem(row.item);
const prefix = " ".repeat(row.indent ?? 0);
const rowColor =
toggleable && enabledState === "enabled"
const rowColor = row.item.loadError
? "red"
: toggleable && enabledState === "enabled"
? palette.success
: enabledState === "partial"
? "yellow"
+1
View File
@@ -34,6 +34,7 @@ Requires Node.js 22+.
| [quickstart](./quickstart) | Send one prompt, stream the response. ~15 lines of code. | `Agent`, `subscribe`, `run()` |
| [quickstart-clinecore](./quickstart-clinecore) | Send one prompt through a persisted Core session. | `ClineCore`, `start()`, `CoreSessionEvent`, `dispose()` |
| [cli-agent](./cli-agent) | Interactive terminal chat with a shell tool. | `createTool`, multi-turn `run()`/`continue()`, streaming |
| [cline-core-cli-agent](./cline-core-cli-agent) | Interactive terminal chat powered by ClineCore. | `ClineCore.create()`, `cline.start()`, `cline.send()`, built-in tools, streaming |
### Intermediate
@@ -0,0 +1,57 @@
# Cline Core CLI Agent
An interactive terminal chat agent powered by the `ClineCore` runtime. This example is similar in spirit to [`cli-agent`](../cli-agent), but uses stateful ClineCore sessions and built-in runtime tools instead of the stateless `Agent` class, to leverage Cline's internal agent harness.
## Getting started
Install dependencies:
```bash
bun install
bun run build:sdk
```
Set an API key:
```bash
export CLINE_API_KEY="sk_..."
```
Run:
```bash
bun dev
```
Type any message at the `you:` prompt to see a streaming response. Type `exit` to quit.
## Optional model configuration
The example defaults to Cline's gateway provider and Claude Sonnet:
```bash
export CLINE_PROVIDER_ID="cline"
export CLINE_MODEL_ID="anthropic/claude-sonnet-4.6"
```
## What it does
- Creates a local `ClineCore` runtime with `ClineCore.create()`
- Starts one interactive session with `cline.start()`
- Sends each user turn with `cline.send({ sessionId, prompt })`
- Streams `agent_event` text to stdout as the assistant responds
- Logs tool calls and tool results inline
- Uses ClineCore's built-in tools instead of defining custom tools
- Calls `cline.stop()` and `cline.dispose()` during shutdown
## Concepts demonstrated
- Stateful sessions with `ClineCore`
- Multi-turn conversation using a single `sessionId`
- `CoreSessionEvent` subscription via `cline.subscribe()`
- Built-in runtime tools (`read_files`, `search_codebase`, `run_commands`, etc.)
- Basic tool policies: file reads/search are auto-approved, other tools request approval
## Notes
Use this example when you want the full ClineCore runtime with sessions, persistence, and built-in tools. For the smallest possible SDK example, see [quickstart](../quickstart). For the lightweight stateless runtime, see [cli-agent](../cli-agent).
@@ -0,0 +1,21 @@
{
"name": "@cline/example-cline-core-cli-agent",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "bun run src/index.ts",
"build:sdk": "bun run --cwd ../../.. build:sdk",
"build": "tsc",
"start": "node dist/index.js"
},
"dependencies": {
"@cline/sdk": "workspace:*"
},
"devDependencies": {
"typescript": "^5.9.3"
},
"engines": {
"node": ">=22"
}
}
@@ -0,0 +1,185 @@
import * as readline from "node:readline";
import {
type AgentEvent,
ClineCore,
type ToolApprovalRequest,
} from "@cline/sdk";
// ClineCore does not choose a model automatically; each session config must provide one.
// These example defaults use the Cline gateway with Claude Sonnet, and can be overridden with env vars.
const providerId = process.env.CLINE_PROVIDER_ID ?? "cline";
const modelId = process.env.CLINE_MODEL_ID ?? "anthropic/claude-sonnet-4.6";
const apiKey = process.env.CLINE_API_KEY;
const cwd = process.cwd();
const systemPrompt = `You are a helpful assistant in an interactive terminal chat.
Be concise. You can use built-in tools to inspect files, search the workspace, and run shell commands when helpful.`;
let cline: ClineCore | undefined;
let unsubscribe: (() => void) | undefined;
let activeSessionId: string | undefined;
let hasPrintedAssistantPrefix = false;
function printAssistantPrefix(): void {
if (!hasPrintedAssistantPrefix) {
process.stdout.write("\nagent: ");
hasPrintedAssistantPrefix = true;
}
}
function formatToolValue(value: unknown): string {
const output =
typeof value === "string" ? value : (JSON.stringify(value, null, 2) ?? "");
return output.length > 200 ? `${output.slice(0, 200)}...` : output;
}
function handleAgentEvent(event: AgentEvent): void {
switch (event.type) {
case "content_start":
if (event.contentType === "text" && event.text) {
printAssistantPrefix();
process.stdout.write(event.text);
}
if (event.contentType === "tool" && event.toolName) {
console.log(
`\n[tool] ${event.toolName}(${JSON.stringify(event.input ?? {})})`,
);
}
break;
case "content_update":
if (event.contentType === "tool" && event.toolName) {
console.log(
`[update] ${event.toolName}: ${formatToolValue(event.update)}`,
);
}
break;
case "content_end":
if (event.contentType === "tool" && event.toolName) {
if (event.error) {
console.log(`[error] ${event.toolName}: ${event.error}`);
} else {
console.log(`[result] ${formatToolValue(event.output)}`);
}
}
break;
case "notice":
console.log(`\n[notice] ${event.message}`);
break;
case "error":
console.error(`\n[error] ${event.error.message}`);
break;
}
}
async function ensureCline(): Promise<ClineCore> {
if (cline) {
return cline;
}
cline = await ClineCore.create({
clientName: "cline-core-cli-agent",
backendMode: "local",
capabilities: {
requestToolApproval,
},
});
unsubscribe = cline.subscribe((event) => {
if (event.type === "agent_event") {
handleAgentEvent(event.payload.event);
}
});
return cline;
}
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
function ask(question: string): Promise<string> {
return new Promise((resolve) => {
rl.question(question, resolve);
});
}
async function requestToolApproval(request: ToolApprovalRequest) {
console.log(`\n[approval] ${request.toolName} wants to run:`);
console.log(formatToolValue(request.input));
const answer = await ask("Approve? [y/N] ");
const approved = answer.trim().toLowerCase() === "y";
return {
approved,
...(approved ? {} : { reason: "User denied tool execution" }),
};
}
function prompt(): Promise<string> {
return ask("\nyou: ");
}
async function startSession(): Promise<string> {
const runtime = await ensureCline();
const result = await runtime.start({
source: "cli",
interactive: true,
config: {
providerId,
modelId,
apiKey,
cwd,
workspaceRoot: cwd,
mode: "act",
systemPrompt,
maxIterations: 10,
enableTools: true,
enableSpawnAgent: false,
enableAgentTeams: false,
disableMcpSettingsTools: true,
},
toolPolicies: {
"*": { autoApprove: false },
read_files: { autoApprove: true },
search_codebase: { autoApprove: true },
},
});
return result.sessionId;
}
async function runTurn(input: string): Promise<void> {
activeSessionId ??= await startSession();
hasPrintedAssistantPrefix = false;
const runtime = await ensureCline();
await runtime.send({
sessionId: activeSessionId,
prompt: input,
});
console.log();
}
console.log("ClineCore CLI Agent (type 'exit' to quit)\n");
console.log(`Provider: ${providerId}`);
console.log(`Model: ${modelId}`);
console.log(`CWD: ${cwd}`);
try {
while (true) {
const input = await prompt();
const trimmed = input.trim();
if (trimmed.toLowerCase() === "exit") {
break;
}
if (!trimmed) {
continue;
}
await runTurn(trimmed);
}
} finally {
rl.close();
unsubscribe?.();
if (activeSessionId && cline) {
await cline.stop(activeSessionId).catch(() => undefined);
}
await cline?.dispose();
console.log("Goodbye!");
}
@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.apps.json",
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"outDir": "dist",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true
},
"include": ["src"]
}
+16 -1
View File
@@ -19,7 +19,7 @@
},
"apps/cli": {
"name": "@cline/cli",
"version": "3.0.7",
"version": "3.0.8",
"bin": {
"cline": "src/index.ts",
},
@@ -38,6 +38,7 @@
"@opentui/react": "0.1.102",
"chat": "^4.23.0",
"commander": "^14.0.3",
"fzf": "^0.5.2",
"marked": "^15.0.12",
"open": "^10.2.0",
"opentui-spinner": "^0.0.6",
@@ -66,6 +67,16 @@
"typescript": "^5.9.3",
},
},
"apps/examples/cline-core-cli-agent": {
"name": "@cline/example-cline-core-cli-agent",
"version": "0.0.0",
"dependencies": {
"@cline/sdk": "workspace:*",
},
"devDependencies": {
"typescript": "^5.9.3",
},
},
"apps/examples/code-review-bot": {
"name": "@cline/example-code-review-bot",
"version": "0.0.0",
@@ -626,6 +637,8 @@
"@cline/example-cli-agent": ["@cline/example-cli-agent@workspace:apps/examples/cli-agent"],
"@cline/example-cline-core-cli-agent": ["@cline/example-cline-core-cli-agent@workspace:apps/examples/cline-core-cli-agent"],
"@cline/example-code-review-bot": ["@cline/example-code-review-bot@workspace:apps/examples/code-review-bot"],
"@cline/example-multi-agent": ["@cline/example-multi-agent@workspace:apps/examples/multi-agent"],
@@ -2112,6 +2125,8 @@
"fuzzysort": ["fuzzysort@3.1.0", "", {}, "sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ=="],
"fzf": ["fzf@0.5.2", "", {}, "sha512-Tt4kuxLXFKHy8KT40zwsUPUkg1CrsgY25FxA2U/j/0WgEDCk3ddc/zLTCCcbSHX9FcKtLuVaDGtGE/STWC+j3Q=="],
"gaxios": ["gaxios@7.1.4", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2" } }, "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA=="],
"gcp-metadata": ["gcp-metadata@8.1.2", "", { "dependencies": { "gaxios": "^7.0.0", "google-logging-utils": "^1.0.0", "json-bigint": "^1.0.0" } }, "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg=="],
@@ -1097,7 +1097,86 @@ describe("HubRuntimeHost", () => {
version: 1,
event: "run.aborted",
sessionId: "sess-1",
payload: {},
payload: {
snapshot: {
version: 1,
sessionId: "sess-1",
status: "running",
interactive: true,
},
},
});
expect(events).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: "agent_event",
payload: expect.objectContaining({
event: expect.objectContaining({
type: "done",
reason: "aborted",
}),
}),
}),
]),
);
expect(events).not.toContainEqual(
expect.objectContaining({
type: "ended",
payload: expect.objectContaining({ sessionId: "sess-1" }),
}),
);
});
it("emits ended for terminal interactive hub run events", async () => {
let onEvent:
| ((event: {
version: 1;
event: "run.aborted";
sessionId: string;
payload?: Record<string, unknown>;
}) => void)
| undefined;
subscribeMock.mockImplementation((listener) => {
onEvent = listener;
return () => {};
});
commandMock.mockResolvedValue({
payload: {
session: {
sessionId: "sess-1",
status: "running",
createdAt: Date.now(),
updatedAt: Date.now(),
workspaceRoot: "/tmp/project",
cwd: "/tmp/project",
},
},
});
const events: unknown[] = [];
const { HubRuntimeHost } = await import("./hub-runtime-host");
const host = new HubRuntimeHost({ url: "ws://127.0.0.1:25463/hub" });
host.subscribe((event) => events.push(event));
await host.startSession({
config: createConfig(),
source: SessionSource.CLI,
prompt: "Hey",
});
onEvent?.({
version: 1,
event: "run.aborted",
sessionId: "sess-1",
payload: {
snapshot: {
version: 1,
sessionId: "sess-1",
status: "cancelled",
interactive: true,
},
},
});
expect(events).toEqual(
@@ -1671,6 +1671,7 @@ export class HubRuntimeHost implements RuntimeHost {
case "run.completed":
case "run.failed":
case "run.aborted": {
const snapshot = parseCoreSessionSnapshot(event.payload?.snapshot);
const reason =
typeof event.payload?.reason === "string"
? event.payload.reason
@@ -1686,6 +1687,9 @@ export class HubRuntimeHost implements RuntimeHost {
reason,
},
});
if (snapshot?.interactive === true && snapshot.status === "running") {
return;
}
this.events.emit({
type: "ended",
payload: {
@@ -287,7 +287,6 @@ export async function handleRunAbort(
(request) => request.sessionId === sessionId,
reason,
);
ctx.publish(ctx.buildEvent("run.aborted", { reason }, sessionId));
return okReply(envelope, { applied: true });
}
+8 -2
View File
@@ -427,8 +427,14 @@ export {
toggleDisabledTool,
writeGlobalSettings,
} from "./services/global-settings";
export type { PluginToolSummary } from "./services/plugin-tools";
export { listPluginTools } from "./services/plugin-tools";
export type {
ListPluginToolsResult,
PluginToolSummary,
} from "./services/plugin-tools";
export {
listPluginTools,
listPluginToolsWithDiagnostics,
} from "./services/plugin-tools";
export {
addLocalProvider,
type DeleteLocalProviderRequest,
@@ -688,7 +688,7 @@ describe("LocalRuntimeHost", () => {
expect(sessionService.readSessionManifest).toHaveBeenCalledWith(sessionId);
});
it("marks interactive turns completed without disposing the session", async () => {
it("keeps interactive sessions running after a completed turn", async () => {
const sessionId = "sess-interactive-turn-status";
const manifest = createManifest(sessionId);
const sessionService = {
@@ -741,20 +741,16 @@ describe("LocalRuntimeHost", () => {
}),
);
expect(sessionService.updateSessionStatus).toHaveBeenCalledWith(
sessionId,
"completed",
0,
);
expect(sessionService.updateSessionStatus).not.toHaveBeenCalled();
await expect(manager.getSession(sessionId)).resolves.toMatchObject({
sessionId,
status: "completed",
status: "running",
});
expect(agent.shutdown).not.toHaveBeenCalled();
expect(runtime.shutdown).not.toHaveBeenCalled();
});
it("disposes idle interactive sessions without changing completed status", async () => {
it("disposes idle interactive sessions without changing status", async () => {
const sessionId = "sess-interactive-dispose";
const manifest = createManifest(sessionId);
const sessionService = {
@@ -2138,6 +2134,110 @@ describe("LocalRuntimeHost", () => {
).toEqual([]);
});
it("keeps the same live interactive session usable after aborting before the first response", async () => {
const sessionId = "sess-abort-then-next-turn";
const manifest = createManifest(sessionId);
const sessionService = {
ensureSessionsDir: vi.fn().mockReturnValue("/tmp/sessions"),
createRootSessionWithArtifacts: vi.fn().mockResolvedValue({
manifestPath: "/tmp/manifest.json",
messagesPath: "/tmp/messages.json",
manifest,
}),
persistSessionMessages: vi.fn(),
updateSessionStatus: vi.fn().mockResolvedValue({ updated: true }),
writeSessionManifest: vi.fn(),
listSessions: vi.fn().mockResolvedValue([]),
deleteSession: vi.fn().mockResolvedValue({ deleted: true }),
};
const runtime = { tools: [], shutdown: vi.fn() };
const runtimeBuilder = {
build: vi.fn().mockReturnValue(runtime),
};
let messages: AgentResult["messages"] = [];
let activeRun = false;
let rejectRun: ((error: Error) => void) | undefined;
let markRunStarted: (() => void) | undefined;
const runStarted = new Promise<void>((resolve) => {
markRunStarted = resolve;
});
const run = vi.fn(
() =>
new Promise<AgentResult>((_resolve, reject) => {
activeRun = true;
messages = [{ role: "user", content: "slow" }];
rejectRun = (error) => {
activeRun = false;
reject(error);
};
markRunStarted?.();
}),
);
const continueTurn = vi.fn().mockResolvedValue(
createResult({
text: "continued after abort",
}),
);
const agent = {
run,
continue: continueTurn,
abort: vi.fn(),
subscribeEvents: vi.fn().mockReturnValue(() => {}),
getAgentId: vi.fn().mockReturnValue("agent-root-1"),
getConversationId: vi.fn().mockReturnValue("conv-root-1"),
shutdown: vi.fn().mockResolvedValue(undefined),
getMessages: vi.fn(() => messages),
canStartRun: vi.fn(() => !activeRun),
};
agent.abort.mockImplementation(() => {
rejectRun?.(new Error("user cancelled before first response"));
});
const manager = new RuntimeHostUnderTest({
distinctId,
sessionService: sessionService as never,
runtimeBuilder: runtimeBuilder as never,
createAgent: () => agent as never,
});
await manager.startSession(
normalizeStartInput({
config: createConfig({ sessionId }),
interactive: true,
}),
);
const events: unknown[] = [];
manager.subscribe((event) => events.push(event));
const firstTurn = manager.runTurn({ sessionId, prompt: "slow" });
await runStarted;
await manager.abort(sessionId, new Error("test abort"));
await expect(firstTurn).resolves.toMatchObject({
finishReason: "aborted",
});
await expect(
manager.runTurn({ sessionId, prompt: "next turn after abort" }),
).resolves.toMatchObject({
finishReason: "completed",
text: "continued after abort",
});
await expect(manager.getSession(sessionId)).resolves.toMatchObject({
sessionId,
status: "running",
});
expect(run).toHaveBeenCalledTimes(1);
expect(continueTurn).toHaveBeenCalledTimes(1);
expect(agent.shutdown).not.toHaveBeenCalled();
expect(runtime.shutdown).not.toHaveBeenCalled();
expect(sessionService.updateSessionStatus).not.toHaveBeenCalled();
expect(events).not.toContainEqual(
expect.objectContaining({
type: "ended",
payload: expect.objectContaining({ sessionId, reason: "aborted" }),
}),
);
});
it("preserves per-turn metadata on prior assistant messages across turns", async () => {
const sessionId = "sess-meta-multi";
const manifest = createManifest(sessionId);
@@ -2822,12 +2922,7 @@ describe("LocalRuntimeHost", () => {
"running",
null,
);
expect(updateSessionStatus).toHaveBeenNthCalledWith(
2,
sessionId,
"completed",
0,
);
expect(updateSessionStatus).toHaveBeenCalledTimes(1);
const persistedMetadata = updateSession.mock.calls[0]?.[0]
.metadata as Record<string, unknown>;
expect(persistedMetadata.title).toBe("saved title");
@@ -559,6 +559,7 @@ export class LocalRuntimeHost implements RuntimeHost {
drainingPendingPrompts: false,
pluginSandboxShutdown: bootstrap.pluginSandboxShutdown,
submitAndExitObserved: false,
lastInteractiveTurnFinishReason: undefined,
};
this.sessions.set(sessionId, active);
this.emitStatus(sessionId, "running");
@@ -747,6 +748,15 @@ export class LocalRuntimeHost implements RuntimeHost {
await this.releaseSessionRuntime(session, "session_stop");
return;
}
if (session.interactive && session.agent.canStartRun()) {
await this.shutdownSession(session, {
status: this.resolveInteractiveStopStatus(session),
exitCode: this.resolveInteractiveStopExitCode(session),
shutdownReason: "session_stop",
endReason: "stopped",
});
return;
}
// Abort the agent first if it's running, so shutdown can proceed
session.aborting = true;
session.agent.abort(new Error("session_stop"));
@@ -765,12 +775,19 @@ export class LocalRuntimeHost implements RuntimeHost {
sessions.map((session) =>
session.interactive && session.status !== "running"
? this.releaseSessionRuntime(session, reason)
: this.shutdownSession(session, {
status: "cancelled",
exitCode: 0,
shutdownReason: reason,
endReason: "disposed",
}),
: session.interactive && session.agent.canStartRun()
? this.shutdownSession(session, {
status: this.resolveInteractiveStopStatus(session),
exitCode: this.resolveInteractiveStopExitCode(session),
shutdownReason: reason,
endReason: "disposed",
})
: this.shutdownSession(session, {
status: "cancelled",
exitCode: 0,
shutdownReason: reason,
endReason: "disposed",
}),
),
);
this.usageBySession.clear();
@@ -945,24 +962,34 @@ export class LocalRuntimeHost implements RuntimeHost {
finishReason: AgentResult["finishReason"],
): Promise<void> {
if (hasPendingTeamRunWork(session)) return;
const isAborted = finishReason === "aborted" || session.aborting;
const isError = finishReason === "error";
await this.updateStatus(
session,
isAborted ? "cancelled" : isError ? "failed" : "completed",
isError ? 1 : 0,
);
this.emit({
type: "ended",
payload: {
sessionId: session.sessionId,
reason: finishReason,
ts: Date.now(),
},
});
session.lastInteractiveTurnFinishReason = finishReason;
await this.markTurnRunning(session);
session.aborting = false;
}
private resolveInteractiveStopStatus(session: ActiveSession): SessionStatus {
const finishReason = session.lastInteractiveTurnFinishReason;
if (!finishReason) return "cancelled";
switch (finishReason) {
case "completed":
return "completed";
case "error":
return "failed";
case "aborted":
case "max_iterations":
case "mistake_limit":
return "cancelled";
}
const _exhaustive: never = finishReason;
return _exhaustive;
}
private resolveInteractiveStopExitCode(session: ActiveSession): number {
return session.lastInteractiveTurnFinishReason === "error" ? 1 : 0;
}
private async completeAbortedInteractiveTurn(
session: ActiveSession,
): Promise<AgentResult> {
+46 -14
View File
@@ -1,5 +1,9 @@
import type { AgentConfig, AgentTool } from "@cline/shared";
import { resolveAgentPluginPaths } from "../extensions/plugin/plugin-config-loader";
import type {
PluginInitializationFailure,
PluginInitializationWarning,
} from "../extensions/plugin/plugin-load-report";
import { loadSandboxedPlugins } from "../extensions/plugin/plugin-sandbox";
import { resolveDisabledToolNames } from "./global-settings";
@@ -15,6 +19,12 @@ export interface PluginToolSummary {
description?: string;
}
export interface ListPluginToolsResult {
tools: PluginToolSummary[];
failures: PluginInitializationFailure[];
warnings: PluginInitializationWarning[];
}
function collectRegisteredTools(
extension: AgentExtension,
workspaceInfo?: { rootPath: string },
@@ -36,19 +46,21 @@ function collectRegisteredTools(
return tools;
}
export async function listPluginTools(input: {
export async function listPluginToolsWithDiagnostics(input: {
workspacePath: string;
cwd?: string;
disabledToolNames?: ReadonlyArray<string>;
providerId?: string;
modelId?: string;
}): Promise<PluginToolSummary[]> {
}): Promise<ListPluginToolsResult> {
const pluginPaths = resolveAgentPluginPaths({
workspacePath: input.workspacePath,
cwd: input.cwd,
});
const disabled = resolveDisabledToolNames(input.disabledToolNames);
const summaries: PluginToolSummary[] = [];
const tools: PluginToolSummary[] = [];
const failures: PluginInitializationFailure[] = [];
const warnings: PluginInitializationWarning[] = [];
for (const pluginPath of pluginPaths) {
let sandboxed: Awaited<ReturnType<typeof loadSandboxedPlugins>> | undefined;
@@ -60,11 +72,13 @@ export async function listPluginTools(input: {
modelId: input.modelId,
workspaceInfo: { rootPath: input.workspacePath },
});
failures.push(...sandboxed.failures);
warnings.push(...sandboxed.warnings);
for (const extension of sandboxed.extensions ?? []) {
for (const tool of collectRegisteredTools(extension, {
rootPath: input.workspacePath,
})) {
summaries.push({
tools.push({
name: tool.name,
pluginName: extension.name,
path: pluginPath,
@@ -76,9 +90,13 @@ export async function listPluginTools(input: {
});
}
}
} catch {
// Tool listing is best effort so settings can still render built-in tools
// if one plugin fails to initialize.
} catch (error) {
failures.push({
pluginPath,
phase: "load",
message: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
});
} finally {
await sandboxed?.shutdown().catch(() => {
// Best effort cleanup after contribution discovery.
@@ -86,11 +104,25 @@ export async function listPluginTools(input: {
}
}
return summaries.sort((left, right) => {
const nameOrder = left.name.localeCompare(right.name);
if (nameOrder !== 0) {
return nameOrder;
}
return left.path.localeCompare(right.path);
});
return {
tools: tools.sort((left, right) => {
const nameOrder = left.name.localeCompare(right.name);
if (nameOrder !== 0) {
return nameOrder;
}
return left.path.localeCompare(right.path);
}),
failures,
warnings,
};
}
export async function listPluginTools(input: {
workspacePath: string;
cwd?: string;
disabledToolNames?: ReadonlyArray<string>;
providerId?: string;
modelId?: string;
}): Promise<PluginToolSummary[]> {
return (await listPluginToolsWithDiagnostics(input)).tools;
}
+8 -2
View File
@@ -120,8 +120,14 @@ export {
toggleDisabledTool,
writeGlobalSettings,
} from "./services/global-settings";
export type { PluginToolSummary } from "./services/plugin-tools";
export { listPluginTools } from "./services/plugin-tools";
export type {
ListPluginToolsResult,
PluginToolSummary,
} from "./services/plugin-tools";
export {
listPluginTools,
listPluginToolsWithDiagnostics,
} from "./services/plugin-tools";
export type {
WorkspaceManager,
WorkspaceManagerEvent,
+2
View File
@@ -1,4 +1,5 @@
import type * as LlmsProviders from "@cline/llms";
import type { AgentFinishReason } from "@cline/shared";
import type { SessionAccumulatedUsage } from "../runtime/host/runtime-host";
import type { BuiltRuntime } from "../runtime/orchestration/session-runtime";
import type { SessionRuntime } from "../runtime/orchestration/session-runtime-orchestrator";
@@ -35,6 +36,7 @@ export type ActiveSession = {
turnAggregateUsageBaseline?: SessionAccumulatedUsage;
turnPrimaryUsage?: SessionAccumulatedUsage;
turnUsageByAgent?: Map<string, SessionAccumulatedUsage>;
lastInteractiveTurnFinishReason?: AgentFinishReason;
/**
* Set to `true` once the assistant successfully invoked the canonical
* completion tool (`submit_and_exit`) for this session. Used to:
+5
View File
@@ -15,6 +15,11 @@ alwaysApply: true
quirks, default behavior, or wire-format details.
- Stable, reliable known-model facts belong in typed `ModelInfo.metadata`
helpers or `src/providers/model-facts.ts`.
- Stable provider routing facts belong in `GatewayProviderMetadata.routing`
and the relevant builtin provider manifest. For example, if a native
provider uses a known reasoning wire format for a model route, add a typed
`GatewayReasoningFormat` value and route metadata instead of matching that
provider id directly in a rule predicate.
- Provider wire-format encoding belongs in `PROVIDER_OPTION_RULES` and codec
helpers under `src/providers/routing`.
- Local or dynamic provider fallbacks, such as Ollama or routed model-id
+2 -1
View File
@@ -2,4 +2,5 @@
## Next Release
- Supports Bedrock bearer API keys, direct IAM credentials, AWS profiles, and the default AWS SDK credential chain
- Supports Bedrock bearer API keys, direct IAM credentials, AWS profiles, and the default AWS SDK credential chain
- Routes Z.AI GLM thinking through provider metadata while preserving generic thinking suppression for non-GLM Z.AI custom models
@@ -103,4 +103,24 @@ describe("built-in provider metadata", () => {
}),
);
});
it("routes native Z.AI providers through GLM thinking metadata", async () => {
for (const providerId of ["zai", "zai-coding-plan"] as const) {
await expect(getProvider(providerId)).resolves.toMatchObject({
metadata: {
routing: {
reasoning: {
format: "glm-thinking",
},
},
},
});
const models = Object.values(await getModelsForProvider(providerId));
expect(models.length).toBeGreaterThan(0);
for (const model of models) {
expect(model.family?.startsWith("glm")).toBe(true);
}
}
});
});
@@ -21,6 +21,7 @@ import {
ANTHROPIC_ROUTING_METADATA,
QWEN_CACHE_ROUTING_METADATA,
} from "./routing/anthropic-compatible";
import { GLM_THINKING_ROUTING_METADATA } from "./routing/glm-thinking";
export const DEFAULT_INTERNAL_OCA_BASE_URL =
"https://code-internal.aiservice.us-chicago-1.oci.oraclecloud.com/20250206/app/litellm";
@@ -507,6 +508,7 @@ const OPENAI_COMPATIBLE_SPECS: BuiltinSpec[] = [
apiKeyEnv: ["ZHIPU_API_KEY"],
modelsProviderId: "zai",
defaults: { baseUrl: "https://api.z.ai/api/paas/v4" },
metadata: GLM_THINKING_ROUTING_METADATA,
},
{
id: "zai-coding-plan",
@@ -518,6 +520,7 @@ const OPENAI_COMPATIBLE_SPECS: BuiltinSpec[] = [
apiKeyEnv: ["ZHIPU_API_KEY"],
modelsProviderId: "zai-coding-plan",
defaults: { baseUrl: "https://api.z.ai/api/coding/paas/v4" },
metadata: GLM_THINKING_ROUTING_METADATA,
},
{
id: "moonshot",
@@ -2712,7 +2712,7 @@ describe("sdk-gateway", () => {
);
});
it("does not apply Z.AI GLM thinking controls to non-GLM native Z.AI models", async () => {
it("does not apply generic thinking to non-GLM native Z.AI custom models", async () => {
streamTextSpy.mockReturnValue({
fullStream: makeStreamParts([
{ type: "finish", usage: { inputTokens: 1, outputTokens: 1 } },
+26 -2
View File
@@ -1,6 +1,7 @@
import type {
GatewayModelRoute,
GatewayProviderContext,
GatewayReasoningFormat,
GatewayStreamRequest,
} from "@cline/shared";
@@ -20,7 +21,9 @@ function normalizedFamily(context: GatewayProviderContext): string {
return normalizeRoutingValue(resolveModelFamily(context)) ?? "";
}
function normalizedModelId(request: Pick<GatewayStreamRequest, "modelId">): string {
function normalizedModelId(
request: Pick<GatewayStreamRequest, "modelId">,
): string {
return normalizeRoutingValue(request.modelId) ?? "";
}
@@ -37,7 +40,9 @@ function isClaudeLineageValue(value: string | undefined): boolean {
function isQwenLineageValue(value: string | undefined): boolean {
const normalized = normalizeRoutingValue(value);
return normalized ? /(^|[/:._-])qwen(?:$|[/:._-]|\d)/.test(normalized) : false;
return normalized
? /(^|[/:._-])qwen(?:$|[/:._-]|\d)/.test(normalized)
: false;
}
export function isAnthropicCompatibleModel(options: {
@@ -128,6 +133,25 @@ export function modelRouteMatches(
}
}
export function providerReasoningRouteMatches(
format: GatewayReasoningFormat,
request: Pick<GatewayStreamRequest, "modelId">,
context: GatewayProviderContext,
): boolean {
const reasoning = context.provider.metadata?.routing?.reasoning;
if (reasoning?.format !== format) {
return false;
}
return reasoning.routes.some((route) =>
modelRouteMatches(route, {
modelId: request.modelId,
family: resolveModelFamily(context),
capabilities: context.model.capabilities,
}),
);
}
export function isGlmModel(
request: Pick<GatewayStreamRequest, "modelId">,
context: GatewayProviderContext,
@@ -1,5 +1,6 @@
import type {
GatewayProviderContext,
GatewayProviderMetadata,
GatewayStreamRequest,
} from "@cline/shared";
import { isGlmModel } from "../model-facts";
@@ -14,9 +15,18 @@ import type { ProviderOptionsPatch } from "./utils";
* composer can rely on merge order instead of out-of-band flags.
*/
export function isNativeZaiProvider(providerId: string): boolean {
return providerId === "zai" || providerId === "zai-coding-plan";
}
export const GLM_THINKING_ROUTING_METADATA: GatewayProviderMetadata = {
routing: {
reasoning: {
format: "glm-thinking",
routes: [
{ matcher: "model-family", family: "glm" },
{ matcher: "model-family", family: "glm-air" },
{ matcher: "model-family", family: "glm-flash" },
],
},
},
};
function buildNativeZaiThinkingOptions(request: GatewayStreamRequest) {
if (request.reasoning?.enabled === undefined) {
@@ -47,28 +57,32 @@ function buildRoutedGlmReasoningOptions(request: GatewayStreamRequest) {
return undefined;
}
export function buildGlmThinkingProviderOptionsPatch(
export function buildNativeGlmThinkingProviderOptionsPatch(
request: GatewayStreamRequest,
providerOptionsKey: string,
): ProviderOptionsPatch | undefined {
// Native Z.AI GLM endpoints expect `thinking.type`; they do not accept the
// routed `reasoning.enabled` / `reasoning.exclude` shape.
const nativeThinking = buildNativeZaiThinkingOptions(request);
return nativeThinking
? {
openaiCompatible: nativeThinking,
[request.providerId]: nativeThinking,
...(providerOptionsKey !== request.providerId
? { [providerOptionsKey]: nativeThinking }
: {}),
}
: undefined;
}
export function buildRoutedGlmReasoningProviderOptionsPatch(
request: GatewayStreamRequest,
context: GatewayProviderContext,
providerOptionsKey: string,
options?: { includeProviderBuckets?: boolean },
): ProviderOptionsPatch | undefined {
if (isNativeZaiProvider(request.providerId)) {
if (!isGlmModel(request, context)) {
return undefined;
}
const nativeThinking = buildNativeZaiThinkingOptions(request);
return nativeThinking
? {
openaiCompatible: nativeThinking,
[request.providerId]: nativeThinking,
...(providerOptionsKey !== request.providerId
? { [providerOptionsKey]: nativeThinking }
: {}),
}
: undefined;
}
// Routed GLM endpoints stay OpenAI-compatible and use the generic
// `reasoning` include/exclude shape instead of native Z.AI `thinking.type`.
if (!isGlmModel(request, context)) {
return undefined;
}
@@ -1,16 +1,17 @@
import { buildGatewayReasoningOptions } from "./anthropic-compatible";
import { buildOpenAINativeProviderOptions } from "./generic-compatible";
import {
buildGlmThinkingProviderOptionsPatch,
isNativeZaiProvider,
} from "./glm-thinking";
import {
isDeepSeekFamily,
isGlmModel,
isKimiK26Family as isKimiK26FamilyFact,
isMoonshotKimiModelIdFallback,
modelReasoningDefaultsOn,
providerReasoningRouteMatches,
} from "../model-facts";
import { buildGatewayReasoningOptions } from "./anthropic-compatible";
import { buildOpenAINativeProviderOptions } from "./generic-compatible";
import {
buildNativeGlmThinkingProviderOptionsPatch,
buildRoutedGlmReasoningProviderOptionsPatch,
} from "./glm-thinking";
import type {
MatchedProviderOptionRule,
ProviderOptionBuildInput,
@@ -54,6 +55,25 @@ function isOllamaReasoningDefaultOnDisable(
);
}
function usesGlmThinkingProviderRouting(
input: ProviderOptionMatchInput,
): boolean {
return providerReasoningRouteMatches(
"glm-thinking",
input.request,
input.context,
);
}
function hasGlmThinkingProviderRouting(
input: ProviderOptionMatchInput,
): boolean {
return (
input.context.provider.metadata?.routing?.reasoning?.format ===
"glm-thinking"
);
}
function resolveFamilyThinkingType(
input: ProviderOptionMatchInput,
defaultWhenUnset: "enabled" | "disabled" | undefined,
@@ -285,31 +305,28 @@ const ollamaReasoningDefaultOnDisableRule: ProviderOptionRule = {
},
};
const nativeZaiNonGlmSuppressionRule: ProviderOptionRule = {
id: "provider.zai.non-glm.suppress-generic-thinking",
const nonGlmProviderRoutingSuppressionRule: ProviderOptionRule = {
id: "provider.routing.glm-thinking.non-glm.suppress-generic-thinking",
phase: "provider",
description:
"Native Z.AI non-GLM models should not inherit adaptive OpenAI-compatible thinking.",
"Providers with GLM thinking routing should not apply generic adaptive thinking to non-GLM models.",
applies: (input) =>
isNativeZaiProvider(input.request.providerId) &&
hasGlmThinkingProviderRouting(input) &&
input.request.reasoning?.enabled !== undefined &&
!isGlmModel(input.request, input.context),
!usesGlmThinkingProviderRouting(input),
suppresses: { genericThinking: true },
build: () => undefined,
};
const nativeZaiGlmThinkingRule: ProviderOptionRule = {
id: "family.glm.native-zai-thinking",
id: "provider.routing.glm-thinking",
phase: "model-overlay",
description: "Native Z.AI GLM models use thinking.type.",
applies: (input) =>
isNativeZaiProvider(input.request.providerId) &&
isGlmModel(input.request, input.context),
description: "Providers routed to the GLM thinking format use thinking.type.",
applies: usesGlmThinkingProviderRouting,
suppresses: { genericThinking: true },
build: (input) =>
buildGlmThinkingProviderOptionsPatch(
buildNativeGlmThinkingProviderOptionsPatch(
input.request,
input.context,
input.providerOptionsKey,
),
};
@@ -320,11 +337,11 @@ const routedGlmReasoningRule: ProviderOptionRule = {
description:
"Routed GLM models use the generic reasoning include/exclude shape, not thinking.type.",
applies: (input) =>
!isNativeZaiProvider(input.request.providerId) &&
!usesGlmThinkingProviderRouting(input) &&
isGlmModel(input.request, input.context),
suppresses: { genericThinking: true },
build: (input) =>
buildGlmThinkingProviderOptionsPatch(
buildRoutedGlmReasoningProviderOptionsPatch(
input.request,
input.context,
input.providerOptionsKey,
@@ -353,7 +370,7 @@ export const PROVIDER_OPTION_RULES: ReadonlyArray<ProviderOptionRule> = [
kimiK26ThinkingRule,
deepSeekThinkingRule,
ollamaReasoningDefaultOnDisableRule,
nativeZaiNonGlmSuppressionRule,
nonGlmProviderRoutingSuppressionRule,
nativeZaiGlmThinkingRule,
routedGlmReasoningRule,
];
@@ -3,6 +3,7 @@ import type {
GatewayStreamRequest,
} from "@cline/shared";
import { describe, expect, it } from "vitest";
import { GLM_THINKING_ROUTING_METADATA } from "./glm-thinking";
import {
composeAiSdkProviderOptions,
mergeProviderOptionPatches,
@@ -770,6 +771,7 @@ describe("composeAiSdkProviderOptions: family/provider thinking patches", () =>
modelId: "glm-4.7",
reasoning: { enabled: true },
},
context: { family: "glm", metadata: GLM_THINKING_ROUTING_METADATA },
expect: [
{ bucket: "zai", has: { thinking: { type: "enabled" } } },
{
@@ -779,6 +781,19 @@ describe("composeAiSdkProviderOptions: family/provider thinking patches", () =>
},
],
},
{
name: "native zai custom non-GLM -> no generic adaptive thinking",
request: {
providerId: "zai",
modelId: "zai-other-model",
reasoning: { enabled: true },
},
context: { family: "other", metadata: GLM_THINKING_ROUTING_METADATA },
expect: [
{ bucket: "zai", lacks: ["thinking", "reasoning"] },
{ bucket: "openaiCompatible", lacks: ["thinking", "reasoning"] },
],
},
// Kimi K2.6 family: explicit enabled/disabled and unset defaults to enabled
{
name: "cline Kimi K2.6 family reasoning.enabled=false -> thinking.type=disabled",
+1 -1
View File
@@ -35,7 +35,7 @@ export type GatewayModelCapability =
export type GatewayPromptCacheStrategy = "anthropic-automatic";
export type GatewayUsageCostDisplay = "show" | "hide";
export type GatewayPromptCacheFormat = "anthropic-cache-control";
export type GatewayReasoningFormat = "anthropic-thinking";
export type GatewayReasoningFormat = "anthropic-thinking" | "glm-thinking";
export type GatewayModelRoute =
| { matcher: "anthropic-compatible" }
| {
+30 -2
View File
@@ -1,6 +1,6 @@
import { describe, it } from "mocha"
import "should"
import type { ApiHandlerModel } from "@core/api"
import type { ApiHandlerModel, ApiProviderInfo } from "@core/api"
import {
GEMINI_FLASH_MAX_OUTPUT_TOKENS,
isClaude4PlusModelFamily,
@@ -8,12 +8,20 @@ import {
isGLMModelFamily,
isGPT5ModelFamily,
isGptOssModelFamily,
isNativeToolCallingConfig,
isNextGenModelFamily,
isPoolsideModelFamily,
modelDoesntSupportWebp,
shouldSkipReasoningForModel,
} from "../model-utils"
// Minimal helper — modelDoesntSupportWebp only reads apiHandlerModel.id
const m = (id: string): ApiHandlerModel => ({ id, info: {} as any })
const m = (id: string): ApiHandlerModel => ({ id, info: { supportsPromptCache: false } })
const providerInfo = (providerId: string, modelId: string): ApiProviderInfo => ({
providerId,
model: m(modelId),
mode: "act",
})
describe("shouldSkipReasoningForModel", () => {
it("should return true for grok-4 models", () => {
@@ -116,6 +124,26 @@ describe("isGptOssModelFamily", () => {
})
})
describe("isPoolsideModelFamily", () => {
it("should return true for Laguna model IDs", () => {
isPoolsideModelFamily("poolside/laguna-m.1").should.equal(true)
isPoolsideModelFamily("laguna-enterprise").should.equal(true)
isPoolsideModelFamily("LAGUNA").should.equal(true)
})
it("should return false for non-Poolside model IDs", () => {
isPoolsideModelFamily("gpt-5").should.equal(false)
isPoolsideModelFamily("deepseek-chat").should.equal(false)
isPoolsideModelFamily("claude-sonnet-4").should.equal(false)
})
it("should qualify Laguna models for next-gen and native tool calling paths", () => {
isNextGenModelFamily("poolside/laguna-m.1").should.equal(true)
isNativeToolCallingConfig(providerInfo("openai-compatible", "poolside/laguna-m.1"), true).should.equal(true)
isNativeToolCallingConfig(providerInfo("openai-compatible", "poolside/laguna-m.1"), false).should.equal(false)
})
})
describe("isGeminiFlashModel", () => {
it("should return true for Gemini Flash model IDs", () => {
isGeminiFlashModel("google/gemini-2.5-flash").should.equal(true)
+7 -1
View File
@@ -189,6 +189,11 @@ export function isDeepSeekNativeModelFamily(id: string): boolean {
return modelId.includes("deepseek-chat") || modelId.includes("deepseek-reasoner")
}
export function isPoolsideModelFamily(id: string): boolean {
const modelId = normalize(id)
return modelId.includes("laguna")
}
export function isNextGenModelFamily(id: string): boolean {
const modelId = normalize(id)
return (
@@ -201,7 +206,8 @@ export function isNextGenModelFamily(id: string): boolean {
isGemini3ModelFamily(modelId) ||
isNextGenOpenSourceModelFamily(modelId) ||
isDeepSeek32ModelFamily(modelId) ||
isDeepSeekNativeModelFamily(modelId)
isDeepSeekNativeModelFamily(modelId) ||
isPoolsideModelFamily(modelId)
)
}