From 8c00eb9a77f4a9f87a99c061a849d79bb18773cb Mon Sep 17 00:00:00 2001 From: Bee <68532117+abeatrix@users.noreply.github.com> Date: Mon, 18 May 2026 22:06:57 -0700 Subject: [PATCH 01/12] fix(cli): keep failed plugins visible in config UI ENG-2073 (#10878) * fix(cli): keep failed plugins visible in config UI ENG-2073 Preserve plugins that fail during load or setup and attach their diagnostic phase and error message. Surface those errors in config detail rows so users can identify and fix broken plugin definitions. * fix(cli): preserve multiple plugin initialization failures Aggregate all setup/load failures per plugin path instead of overwriting earlier errors. Summarize multiple errors in the config UI so rows remain readable while retaining the full failure details. --- .../runtime/interactive/config-data.test.ts | 81 ++++++++++++++++++- .../dialogs/config-dialogs-helpers.ts | 9 ++- sdk/apps/cli/src/tui/interactive-config.ts | 41 +++++++++- sdk/apps/cli/src/tui/views/config-view.tsx | 33 +++++++- sdk/packages/core/src/index.ts | 10 ++- .../core/src/services/plugin-tools.ts | 60 ++++++++++---- sdk/packages/core/src/types.ts | 10 ++- 7 files changed, 217 insertions(+), 27 deletions(-) diff --git a/sdk/apps/cli/src/runtime/interactive/config-data.test.ts b/sdk/apps/cli/src/runtime/interactive/config-data.test.ts index 08602a0052..e2e030bc60 100644 --- a/sdk/apps/cli/src/runtime/interactive/config-data.test.ts +++ b/sdk/apps/cli/src/runtime/interactive/config-data.test.ts @@ -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); diff --git a/sdk/apps/cli/src/tui/components/dialogs/config-dialogs-helpers.ts b/sdk/apps/cli/src/tui/components/dialogs/config-dialogs-helpers.ts index 376eb94bd1..2faec59a79 100644 --- a/sdk/apps/cli/src/tui/components/dialogs/config-dialogs-helpers.ts +++ b/sdk/apps/cli/src/tui/components/dialogs/config-dialogs-helpers.ts @@ -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) diff --git a/sdk/apps/cli/src/tui/interactive-config.ts b/sdk/apps/cli/src/tui/interactive-config.ts index 40b2559c97..7580f7070b 100644 --- a/sdk/apps/cli/src/tui/interactive-config.ts +++ b/sdk/apps/cli/src/tui/interactive-config.ts @@ -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(); + 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, diff --git a/sdk/apps/cli/src/tui/views/config-view.tsx b/sdk/apps/cli/src/tui/views/config-view.tsx index 78df5b11c7..f8eaa0ac96 100644 --- a/sdk/apps/cli/src/tui/views/config-view.tsx +++ b/sdk/apps/cli/src/tui/views/config-view.tsx @@ -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" diff --git a/sdk/packages/core/src/index.ts b/sdk/packages/core/src/index.ts index db1308c7ee..1b46c44185 100644 --- a/sdk/packages/core/src/index.ts +++ b/sdk/packages/core/src/index.ts @@ -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, diff --git a/sdk/packages/core/src/services/plugin-tools.ts b/sdk/packages/core/src/services/plugin-tools.ts index 8747a1c0e1..53b418b084 100644 --- a/sdk/packages/core/src/services/plugin-tools.ts +++ b/sdk/packages/core/src/services/plugin-tools.ts @@ -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; providerId?: string; modelId?: string; -}): Promise { +}): Promise { 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> | 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; + providerId?: string; + modelId?: string; +}): Promise { + return (await listPluginToolsWithDiagnostics(input)).tools; } diff --git a/sdk/packages/core/src/types.ts b/sdk/packages/core/src/types.ts index 72e2b270f0..45b86e7c91 100644 --- a/sdk/packages/core/src/types.ts +++ b/sdk/packages/core/src/types.ts @@ -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, From cb0515d5f46dc0a9b9f6e1be9f6a5a81e23709e4 Mon Sep 17 00:00:00 2001 From: Ara Date: Tue, 19 May 2026 08:16:38 -0700 Subject: [PATCH 02/12] fix(cli): use Telegram numeric participant ids (#10879) --- .../src/connectors/adapters/telegram.test.ts | 47 +++++++++++++++++++ .../cli/src/connectors/adapters/telegram.ts | 7 +-- 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/sdk/apps/cli/src/connectors/adapters/telegram.test.ts b/sdk/apps/cli/src/connectors/adapters/telegram.test.ts index 583a995d1d..3188608352 100644 --- a/sdk/apps/cli/src/connectors/adapters/telegram.test.ts +++ b/sdk/apps/cli/src/connectors/adapters/telegram.test.ts @@ -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( diff --git a/sdk/apps/cli/src/connectors/adapters/telegram.ts b/sdk/apps/cli/src/connectors/adapters/telegram.ts index dafaf981de..0fddd6d2ee 100644 --- a/sdk/apps/cli/src/connectors/adapters/telegram.ts +++ b/sdk/apps/cli/src/connectors/adapters/telegram.ts @@ -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, thread: Pick, "id" | "channelId" | "isDM"> & { From 2e8579ebecabd33a9a5608a65e92345f18647d0b Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 19 May 2026 09:54:36 -0700 Subject: [PATCH 03/12] bump version and update changelog (#10898) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Max Paulus 🥪 --- CHANGELOG.md | 14 ++++++++++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c5a913ab73..8a7a4c40a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/package-lock.json b/package-lock.json index 380481a98d..175331215d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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": [ "." diff --git a/package.json b/package.json index 2b9a23c48c..ffff5770ad 100644 --- a/package.json +++ b/package.json @@ -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": [ "." From 6e964c3ef054a7ed113a9ea91881db8aebc3ccd5 Mon Sep 17 00:00:00 2001 From: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com> Date: Tue, 19 May 2026 10:39:16 -0700 Subject: [PATCH 04/12] chore(cli): release v3.0.8 --- sdk/apps/cli/CHANGELOG.md | 8 ++++++++ sdk/apps/cli/package.json | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/sdk/apps/cli/CHANGELOG.md b/sdk/apps/cli/CHANGELOG.md index e2a0766467..2d609ec688 100644 --- a/sdk/apps/cli/CHANGELOG.md +++ b/sdk/apps/cli/CHANGELOG.md @@ -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. diff --git a/sdk/apps/cli/package.json b/sdk/apps/cli/package.json index 4959dc6f14..5c1520824a 100644 --- a/sdk/apps/cli/package.json +++ b/sdk/apps/cli/package.json @@ -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": { From ed008bd36e6d291b43e8ebc5214211babb724897 Mon Sep 17 00:00:00 2001 From: Robin Newhouse Date: Tue, 19 May 2026 12:12:51 -0700 Subject: [PATCH 05/12] Route GLM thinking via provider metadata ENG-2019 (#10692) * Route GLM thinking via provider metadata * Address GLM provider routing review feedback --- sdk/packages/llms/AGENTS.md | 5 ++ sdk/packages/llms/CHANGELOG.md | 3 +- .../llms/src/providers/builtins.test.ts | 20 +++++++ sdk/packages/llms/src/providers/builtins.ts | 3 + .../llms/src/providers/gateway.test.ts | 2 +- .../llms/src/providers/model-facts.ts | 20 +++++++ .../src/providers/routing/glm-thinking.ts | 54 ++++++++++------- .../routing/provider-option-rules.ts | 59 ++++++++++++------- .../routing/provider-options.test.ts | 15 +++++ sdk/packages/shared/src/llms/gateway.ts | 2 +- 10 files changed, 139 insertions(+), 44 deletions(-) diff --git a/sdk/packages/llms/AGENTS.md b/sdk/packages/llms/AGENTS.md index 49241a2cb8..1a370d6917 100644 --- a/sdk/packages/llms/AGENTS.md +++ b/sdk/packages/llms/AGENTS.md @@ -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 diff --git a/sdk/packages/llms/CHANGELOG.md b/sdk/packages/llms/CHANGELOG.md index 4e47fc4870..68bdb84f26 100644 --- a/sdk/packages/llms/CHANGELOG.md +++ b/sdk/packages/llms/CHANGELOG.md @@ -2,4 +2,5 @@ ## Next Release -- Supports Bedrock bearer API keys, direct IAM credentials, AWS profiles, and the default AWS SDK credential chain \ No newline at end of file +- 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 diff --git a/sdk/packages/llms/src/providers/builtins.test.ts b/sdk/packages/llms/src/providers/builtins.test.ts index 64d190d150..cb3b8281d5 100644 --- a/sdk/packages/llms/src/providers/builtins.test.ts +++ b/sdk/packages/llms/src/providers/builtins.test.ts @@ -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); + } + } + }); }); diff --git a/sdk/packages/llms/src/providers/builtins.ts b/sdk/packages/llms/src/providers/builtins.ts index c87bdc24cb..d7ed65e007 100644 --- a/sdk/packages/llms/src/providers/builtins.ts +++ b/sdk/packages/llms/src/providers/builtins.ts @@ -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", diff --git a/sdk/packages/llms/src/providers/gateway.test.ts b/sdk/packages/llms/src/providers/gateway.test.ts index 8d0c991f02..48f94bd465 100644 --- a/sdk/packages/llms/src/providers/gateway.test.ts +++ b/sdk/packages/llms/src/providers/gateway.test.ts @@ -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 } }, diff --git a/sdk/packages/llms/src/providers/model-facts.ts b/sdk/packages/llms/src/providers/model-facts.ts index a166e3646e..56a70f797f 100644 --- a/sdk/packages/llms/src/providers/model-facts.ts +++ b/sdk/packages/llms/src/providers/model-facts.ts @@ -1,5 +1,6 @@ import type { GatewayModelRoute, + GatewayReasoningFormat, GatewayProviderContext, GatewayStreamRequest, } from "@cline/shared"; @@ -128,6 +129,25 @@ export function modelRouteMatches( } } +export function providerReasoningRouteMatches( + format: GatewayReasoningFormat, + request: Pick, + 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, context: GatewayProviderContext, diff --git a/sdk/packages/llms/src/providers/routing/glm-thinking.ts b/sdk/packages/llms/src/providers/routing/glm-thinking.ts index 138c742d2b..54f557e6a7 100644 --- a/sdk/packages/llms/src/providers/routing/glm-thinking.ts +++ b/sdk/packages/llms/src/providers/routing/glm-thinking.ts @@ -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; } diff --git a/sdk/packages/llms/src/providers/routing/provider-option-rules.ts b/sdk/packages/llms/src/providers/routing/provider-option-rules.ts index 3d82785952..f361a22ec4 100644 --- a/sdk/packages/llms/src/providers/routing/provider-option-rules.ts +++ b/sdk/packages/llms/src/providers/routing/provider-option-rules.ts @@ -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 = [ kimiK26ThinkingRule, deepSeekThinkingRule, ollamaReasoningDefaultOnDisableRule, - nativeZaiNonGlmSuppressionRule, + nonGlmProviderRoutingSuppressionRule, nativeZaiGlmThinkingRule, routedGlmReasoningRule, ]; diff --git a/sdk/packages/llms/src/providers/routing/provider-options.test.ts b/sdk/packages/llms/src/providers/routing/provider-options.test.ts index 15f6db0cee..52d493eb8e 100644 --- a/sdk/packages/llms/src/providers/routing/provider-options.test.ts +++ b/sdk/packages/llms/src/providers/routing/provider-options.test.ts @@ -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", diff --git a/sdk/packages/shared/src/llms/gateway.ts b/sdk/packages/shared/src/llms/gateway.ts index 279fe72abb..0739284824 100644 --- a/sdk/packages/shared/src/llms/gateway.ts +++ b/sdk/packages/shared/src/llms/gateway.ts @@ -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" } | { From b601b6e62304ab2c77997775decbc5520c291768 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Barreiro?= <52393857+BarreiroT@users.noreply.github.com> Date: Tue, 19 May 2026 16:30:18 -0300 Subject: [PATCH 06/12] Update diff to 8.0.4 (#10904) * Update diff to 8.0.4 * Remove types diff --- package-lock.json | 46 ++++------------------------------------------ package.json | 7 ++----- 2 files changed, 6 insertions(+), 47 deletions(-) diff --git a/package-lock.json b/package-lock.json index 175331215d..73a383597b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index ffff5770ad..65544fc938 100644 --- a/package.json +++ b/package.json @@ -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": [ From e3b3e2306e6b3c85c310c233c8e10b9f2f148c57 Mon Sep 17 00:00:00 2001 From: Robin Newhouse Date: Tue, 19 May 2026 12:47:07 -0700 Subject: [PATCH 07/12] fix(cli): accept dash-prefixed prompts after separator (#10905) --- sdk/apps/cli/src/commands/program.ts | 2 +- sdk/apps/cli/src/main.test.ts | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/sdk/apps/cli/src/commands/program.ts b/sdk/apps/cli/src/commands/program.ts index e53eee33cb..415405ee38 100644 --- a/sdk/apps/cli/src/commands/program.ts +++ b/sdk/apps/cli/src/commands/program.ts @@ -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(" "); } diff --git a/sdk/apps/cli/src/main.test.ts b/sdk/apps/cli/src/main.test.ts index 8b9c24d218..4340433a37 100644 --- a/sdk/apps/cli/src/main.test.ts +++ b/sdk/apps/cli/src/main.test.ts @@ -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"]; From 3f808b369ef1b9f34ac44c4d9201de0b85a2b347 Mon Sep 17 00:00:00 2001 From: Renee Huang <100229782+reneehuang1@users.noreply.github.com> Date: Tue, 19 May 2026 13:22:04 -0700 Subject: [PATCH 08/12] sdk: add ClineCore CLI agent example (#10895) * add a separate CLI agent using ClineCore, in comparison to the one built using Agent * update SDK lockfile for ClineCore CLI example --- sdk/apps/examples/README.md | 1 + .../examples/cline-core-cli-agent/README.md | 57 ++++++ .../cline-core-cli-agent/package.json | 21 ++ .../cline-core-cli-agent/src/index.ts | 185 ++++++++++++++++++ .../cline-core-cli-agent/tsconfig.json | 13 ++ sdk/bun.lock | 25 +++ 6 files changed, 302 insertions(+) create mode 100644 sdk/apps/examples/cline-core-cli-agent/README.md create mode 100644 sdk/apps/examples/cline-core-cli-agent/package.json create mode 100644 sdk/apps/examples/cline-core-cli-agent/src/index.ts create mode 100644 sdk/apps/examples/cline-core-cli-agent/tsconfig.json diff --git a/sdk/apps/examples/README.md b/sdk/apps/examples/README.md index 3729c5347b..670faa7e75 100644 --- a/sdk/apps/examples/README.md +++ b/sdk/apps/examples/README.md @@ -33,6 +33,7 @@ Requires Node.js 22+. |---------|-------------|----------| | [quickstart](./quickstart) | Send one prompt, stream the response. ~15 lines of code. | `Agent`, `subscribe`, `run()` | | [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 diff --git a/sdk/apps/examples/cline-core-cli-agent/README.md b/sdk/apps/examples/cline-core-cli-agent/README.md new file mode 100644 index 0000000000..13229db982 --- /dev/null +++ b/sdk/apps/examples/cline-core-cli-agent/README.md @@ -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). diff --git a/sdk/apps/examples/cline-core-cli-agent/package.json b/sdk/apps/examples/cline-core-cli-agent/package.json new file mode 100644 index 0000000000..24f2a54574 --- /dev/null +++ b/sdk/apps/examples/cline-core-cli-agent/package.json @@ -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" + } +} diff --git a/sdk/apps/examples/cline-core-cli-agent/src/index.ts b/sdk/apps/examples/cline-core-cli-agent/src/index.ts new file mode 100644 index 0000000000..b12cdcc6f6 --- /dev/null +++ b/sdk/apps/examples/cline-core-cli-agent/src/index.ts @@ -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 { + 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 { + 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 { + return ask("\nyou: "); +} + +async function startSession(): Promise { + 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 { + 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!"); +} diff --git a/sdk/apps/examples/cline-core-cli-agent/tsconfig.json b/sdk/apps/examples/cline-core-cli-agent/tsconfig.json new file mode 100644 index 0000000000..9c69339e57 --- /dev/null +++ b/sdk/apps/examples/cline-core-cli-agent/tsconfig.json @@ -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"] +} diff --git a/sdk/bun.lock b/sdk/bun.lock index fcac781ce1..5b72547c3d 100644 --- a/sdk/bun.lock +++ b/sdk/bun.lock @@ -66,6 +66,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", @@ -186,6 +196,17 @@ "typescript": "^5.9.3", }, }, + "apps/examples/security-review-bot": { + "name": "@cline/example-security-review-bot", + "version": "0.0.0", + "dependencies": { + "@cline/sdk": "workspace:*", + "zod": "^4.3.6", + }, + "devDependencies": { + "typescript": "^5.9.3", + }, + }, "apps/examples/vscode": { "name": "@cline/vscode", "version": "0.0.0", @@ -605,12 +626,16 @@ "@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"], "@cline/example-quickstart": ["@cline/example-quickstart@workspace:apps/examples/quickstart"], + "@cline/example-security-review-bot": ["@cline/example-security-review-bot@workspace:apps/examples/security-review-bot"], + "@cline/llms": ["@cline/llms@workspace:packages/llms"], "@cline/menubar": ["@cline/menubar@workspace:apps/examples/menubar"], From 8a6d031e8fb514a751356a7f735f1fe764d41a4e Mon Sep 17 00:00:00 2001 From: Mark Percival Date: Tue, 19 May 2026 16:44:59 -0400 Subject: [PATCH 09/12] fix(cli): keep interactive session live after cancel (#10903) * fix(cli): keep interactive session live after cancel * fix(sdk): use shared finish reason type --- .../hub/runtime-host/hub-runtime-host.test.ts | 81 +++++++++++- .../src/hub/runtime-host/hub-runtime-host.ts | 4 + .../src/hub/server/handlers/run-handlers.ts | 1 - .../runtime/host/local-runtime-host.test.ts | 123 ++++++++++++++++-- .../src/runtime/host/local-runtime-host.ts | 69 +++++++--- sdk/packages/core/src/types/session.ts | 2 + 6 files changed, 243 insertions(+), 37 deletions(-) diff --git a/sdk/packages/core/src/hub/runtime-host/hub-runtime-host.test.ts b/sdk/packages/core/src/hub/runtime-host/hub-runtime-host.test.ts index 1789b20c6d..488a30a92d 100644 --- a/sdk/packages/core/src/hub/runtime-host/hub-runtime-host.test.ts +++ b/sdk/packages/core/src/hub/runtime-host/hub-runtime-host.test.ts @@ -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; + }) => 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( diff --git a/sdk/packages/core/src/hub/runtime-host/hub-runtime-host.ts b/sdk/packages/core/src/hub/runtime-host/hub-runtime-host.ts index 0afac554ed..6539663e13 100644 --- a/sdk/packages/core/src/hub/runtime-host/hub-runtime-host.ts +++ b/sdk/packages/core/src/hub/runtime-host/hub-runtime-host.ts @@ -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: { diff --git a/sdk/packages/core/src/hub/server/handlers/run-handlers.ts b/sdk/packages/core/src/hub/server/handlers/run-handlers.ts index f5db772e42..cb3af46310 100644 --- a/sdk/packages/core/src/hub/server/handlers/run-handlers.ts +++ b/sdk/packages/core/src/hub/server/handlers/run-handlers.ts @@ -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 }); } diff --git a/sdk/packages/core/src/runtime/host/local-runtime-host.test.ts b/sdk/packages/core/src/runtime/host/local-runtime-host.test.ts index ea02b775ea..b11d4d81e2 100644 --- a/sdk/packages/core/src/runtime/host/local-runtime-host.test.ts +++ b/sdk/packages/core/src/runtime/host/local-runtime-host.test.ts @@ -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((resolve) => { + markRunStarted = resolve; + }); + const run = vi.fn( + () => + new Promise((_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; expect(persistedMetadata.title).toBe("saved title"); diff --git a/sdk/packages/core/src/runtime/host/local-runtime-host.ts b/sdk/packages/core/src/runtime/host/local-runtime-host.ts index 5b5ad426dd..c7687ba136 100644 --- a/sdk/packages/core/src/runtime/host/local-runtime-host.ts +++ b/sdk/packages/core/src/runtime/host/local-runtime-host.ts @@ -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 { 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 { diff --git a/sdk/packages/core/src/types/session.ts b/sdk/packages/core/src/types/session.ts index e303f57138..79f7639217 100644 --- a/sdk/packages/core/src/types/session.ts +++ b/sdk/packages/core/src/types/session.ts @@ -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; + lastInteractiveTurnFinishReason?: AgentFinishReason; /** * Set to `true` once the assistant successfully invoked the canonical * completion tool (`submit_and_exit`) for this session. Used to: From 6d5c61f044163a7ccb591ee666933d6268cc56c9 Mon Sep 17 00:00:00 2001 From: Bee <68532117+abeatrix@users.noreply.github.com> Date: Tue, 19 May 2026 13:54:53 -0700 Subject: [PATCH 10/12] chore: gitignore SDK session and database files (#10907) Ignore .cline session data, temp directories, SQLite artifacts, and generated metadata to prevent local SDK state and user data from being committed. --- .gitignore | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.gitignore b/.gitignore index 2b995fcf86..6ca60be7e6 100644 --- a/.gitignore +++ b/.gitignore @@ -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 From a4d093703058cd08118cee59856990811cf44816 Mon Sep 17 00:00:00 2001 From: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com> Date: Tue, 19 May 2026 16:09:43 -0700 Subject: [PATCH 11/12] fix(cli): restore fuzzy file mention ranking (#10909) * fix(cli): restore fuzzy file mention ranking * fix(cli): address mention autocomplete review feedback --- sdk/apps/cli/package.json | 11 +- .../cli/src/tui/hooks/use-autocomplete.ts | 10 +- .../cli/src/tui/interactive-welcome.test.ts | 48 ++++++++ sdk/apps/cli/src/tui/interactive-welcome.ts | 110 ++++++++++++++---- sdk/bun.lock | 5 +- 5 files changed, 146 insertions(+), 38 deletions(-) create mode 100644 sdk/apps/cli/src/tui/interactive-welcome.test.ts diff --git a/sdk/apps/cli/package.json b/sdk/apps/cli/package.json index 5c1520824a..403f2c385a 100644 --- a/sdk/apps/cli/package.json +++ b/sdk/apps/cli/package.json @@ -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" }, diff --git a/sdk/apps/cli/src/tui/hooks/use-autocomplete.ts b/sdk/apps/cli/src/tui/hooks/use-autocomplete.ts index 3fca2889c7..c5a0f7472e 100644 --- a/sdk/apps/cli/src/tui/hooks/use-autocomplete.ts +++ b/sdk/apps/cli/src/tui/hooks/use-autocomplete.ts @@ -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({ diff --git a/sdk/apps/cli/src/tui/interactive-welcome.test.ts b/sdk/apps/cli/src/tui/interactive-welcome.test.ts new file mode 100644 index 0000000000..dba6251f4a --- /dev/null +++ b/sdk/apps/cli/src/tui/interactive-welcome.test.ts @@ -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"); + }); +}); diff --git a/sdk/apps/cli/src/tui/interactive-welcome.ts b/sdk/apps/cli/src/tui/interactive-welcome.ts index 077e9ef9f6..7d22b45e37 100644 --- a/sdk/apps/cli/src/tui/interactive-welcome.ts +++ b/sdk/apps/cli/src/tui/interactive-welcome.ts @@ -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 { + 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, + right: FzfResultItem, +): number { + return countGaps(left.positions) - countGaps(right.positions); +} + +export function rankMentionPaths( + paths: Iterable, + 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: { diff --git a/sdk/bun.lock b/sdk/bun.lock index 5b72547c3d..e311d88cef 100644 --- a/sdk/bun.lock +++ b/sdk/bun.lock @@ -19,7 +19,7 @@ }, "apps/cli": { "name": "@cline/cli", - "version": "3.0.3", + "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", @@ -2112,6 +2113,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=="], From 1ac5525feee5c24d1023470b78184ba718c70f7d Mon Sep 17 00:00:00 2001 From: Ara Date: Tue, 19 May 2026 16:40:19 -0700 Subject: [PATCH 12/12] fix: route Poolside Laguna models through next-gen prompts (#10910) --- src/utils/__tests__/model-utils.test.ts | 32 +++++++++++++++++++++++-- src/utils/model-utils.ts | 8 ++++++- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/src/utils/__tests__/model-utils.test.ts b/src/utils/__tests__/model-utils.test.ts index 3613fb9fd7..2b55cbcb34 100644 --- a/src/utils/__tests__/model-utils.test.ts +++ b/src/utils/__tests__/model-utils.test.ts @@ -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) diff --git a/src/utils/model-utils.ts b/src/utils/model-utils.ts index 8a227d033a..b062527bad 100644 --- a/src/utils/model-utils.ts +++ b/src/utils/model-utils.ts @@ -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) ) }